From c7c6b395a6dc1c5ef7c7a5b172884bef166e3dd2 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 11 Oct 2026 23:58:10 -0500 Subject: [PATCH 1/2] fix: expose running terminals and prevent setup install stalls --- apps/web/src/components/ChatView.tsx | 12 ++++ apps/web/src/components/LegacySidebar.tsx | 29 ++------ apps/web/src/components/Sidebar.tsx | 26 +++---- .../src/components/ThreadStatusIndicators.tsx | 51 +++++++++++++ apps/web/src/lib/revealThreadTerminal.test.ts | 72 +++++++++++++++++++ apps/web/src/lib/revealThreadTerminal.ts | 21 ++++++ scripts/setup-worktree.ts | 6 +- 7 files changed, 177 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/lib/revealThreadTerminal.test.ts create mode 100644 apps/web/src/lib/revealThreadTerminal.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 69a204ccd8a35..21e736383ed35 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4788,6 +4788,15 @@ export default function ChatView(props: ChatViewProps) { ) return; if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { + // Server-created setup terminals exist before this client's drawer mounts. + // Reuse them before allocating a shell from the still-empty UI state. + const existingTerminalId = + runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? + activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); + if (existingTerminalId !== undefined) { + storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); + return; + } if (!activeThreadId || !activeProject) { return; } @@ -4814,6 +4823,7 @@ export default function ChatView(props: ChatViewProps) { } setTerminalOpen(nextOpen); }, [ + activeServerOrderedTerminalIds, activeProject, activeThreadId, activeThreadRef, @@ -4822,6 +4832,8 @@ export default function ChatView(props: ChatViewProps) { environmentId, gitCwd, openTerminal, + panelTerminalIds, + runningTerminalIds, hasTerminalWriteAccess, setTerminalOpen, storeEnsureTerminal, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index b78c6f955e2bd..64326924e2875 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -9,15 +9,13 @@ import { Globe2Icon, SearchIcon, SquarePenIcon, - TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { ChangeRequestStatusIcon, prStatusIndicator, PrStatusTooltipContent, - terminalStatusFromRunningIds, - synchronizeTerminalPulse, + ThreadTerminalStatusButton, ThreadStatusLabel, ThreadWorktreeIndicator, useLinkedThreadPullRequest, @@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} - {terminalStatus && ( - - - } - > - - - {terminalStatus.label} - - )} +
) : null; - const terminalStatusIcon = terminalStatus ? ( - - - - ) : null; + const terminalStatusIcon = ( + + ); // Same pen the new-thread draft rows lead with, so both kinds of unsent // work read the same way in the list. const draftIndicator = hasUnsentDraft ? ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a2921..ec579a87bcb80 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull import { resolveEnvironmentMachineKind, type EnvironmentId, + type ScopedThreadRef, type ThreadLinkedPullRequest, type ThreadPullRequestLink, type VcsStatusResult, @@ -29,6 +30,7 @@ import { import { useRender } from "@base-ui/react/use-render"; import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; +import { revealThreadTerminal } from "../lib/revealThreadTerminal"; import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; @@ -780,6 +782,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent) { } } +export function ThreadTerminalStatusButton({ + threadRef, + terminalIds, + onThreadActivate, +}: { + threadRef: ScopedThreadRef; + terminalIds: readonly string[]; + onThreadActivate: (threadRef: ScopedThreadRef) => void; +}) { + const terminalId = terminalIds[0]; + if (terminalId === undefined) return null; + const status = terminalStatusFromRunningIds(terminalIds); + const label = + terminalIds.length === 1 + ? "1 terminal process running. Open terminal" + : `${terminalIds.length} terminal processes running. Open terminal`; + return ( + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + revealThreadTerminal(threadRef, terminalId); + onThreadActivate(threadRef); + }} + /> + } + > + + + {label} + + ); +} + export function ThreadWorktreeIndicator({ thread, }: { diff --git a/apps/web/src/lib/revealThreadTerminal.test.ts b/apps/web/src/lib/revealThreadTerminal.test.ts new file mode 100644 index 0000000000000..8a7cc95e024ca --- /dev/null +++ b/apps/web/src/lib/revealThreadTerminal.test.ts @@ -0,0 +1,72 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, expect, it } from "vite-plus/test"; + +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { revealThreadTerminal } from "./revealThreadTerminal"; + +const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("settled-thread")); +const remoteRef = scopeThreadRef(EnvironmentId.make("remote"), threadRef.threadId); + +beforeEach(() => { + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, + }); + useRightPanelStore.setState({ byThreadKey: {} }); +}); + +it("reveals a server-created setup terminal without adding a default shell", () => { + revealThreadTerminal(threadRef, "setup-install"); + expect( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ), + ).toMatchObject({ + terminalOpen: true, + terminalIds: ["setup-install"], + activeTerminalId: "setup-install", + }); +}); + +it("reopens the existing panel and selects a running terminal in its split", () => { + const panels = useRightPanelStore.getState(); + panels.openTerminal(threadRef, "terminal-1"); + const surfaceId = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + threadRef, + ).activeSurfaceId!; + panels.splitTerminal(threadRef, surfaceId, "setup-install"); + panels.activateTerminal(threadRef, surfaceId, "terminal-1"); + panels.close(threadRef); + + revealThreadTerminal(threadRef, "setup-install"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: surfaceId, + surfaces: [ + { + terminalIds: ["terminal-1", "setup-install"], + activeTerminalId: "setup-install", + }, + ], + }); + expect( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ).terminalIds, + ).toEqual([]); +}); + +it("reveals terminals in their own environment without changing another environment's drawer", () => { + revealThreadTerminal(threadRef, "default"); + revealThreadTerminal(remoteRef, "setup-install"); + const state = useTerminalUiStateStore.getState().terminalUiStateByThreadKey; + expect(selectThreadTerminalUiState(state, threadRef).terminalIds).toEqual(["default"]); + expect(selectThreadTerminalUiState(state, remoteRef).terminalIds).toEqual(["setup-install"]); +}); diff --git a/apps/web/src/lib/revealThreadTerminal.ts b/apps/web/src/lib/revealThreadTerminal.ts new file mode 100644 index 0000000000000..c6ce017000569 --- /dev/null +++ b/apps/web/src/lib/revealThreadTerminal.ts @@ -0,0 +1,21 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; + +/** Reveal an existing terminal in the panel that owns it, without opening another PTY. */ +export function revealThreadTerminal(threadRef: ScopedThreadRef, terminalId: string) { + const panels = useRightPanelStore.getState(); + const surface = selectThreadRightPanelState(panels.byThreadKey, threadRef).surfaces.find( + (surface) => surface.kind === "terminal" && surface.terminalIds.includes(terminalId), + ); + if (surface) { + panels.activateTerminal(threadRef, surface.id, terminalId); + panels.activateSurface(threadRef, surface.id); + return; + } + useTerminalUiStateStore.getState().ensureTerminal(threadRef, terminalId, { + open: true, + active: true, + }); +} diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 027112ca1858e..daf2f175aec40 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -19,10 +19,12 @@ if (!projectRoot) { const worktree = NodePath.dirname(import.meta.dirname); // `shell` resolves `vp` through PATH, including Windows command shims. -const install = NodeChildProcess.spawnSync("vp i", { +// Setup has no one answering install prompts. Pass pnpm's confirmation flag +// through vp and leave stdin closed so other prompts fail instead of hanging. +const install = NodeChildProcess.spawnSync("vp i -- --yes", { cwd: worktree, shell: true, - stdio: "inherit", + stdio: ["ignore", "inherit", "inherit"], }); if (install.status !== 0) process.exit(install.status ?? 1); From f20fcce59cca1207aafe4bfe9eef1a3a4dbee31d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 12 Oct 2026 14:00:42 -0500 Subject: [PATCH 2/2] fix(web): keep terminal focus ring inside its button --- apps/web/src/components/ThreadStatusIndicators.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 136536b17c0c0..051077b469a26 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -813,7 +813,7 @@ export function ThreadTerminalStatusButton({ aria-label={label} data-testid={`sidebar-terminal-status-${threadRef.threadId}`} className={cn( - "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring", + "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none hover:opacity-80 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", status?.colorClass, )} onPointerDown={(event) => event.stopPropagation()}