From 0e7346ccd9427c06161e222319e718e6bd70887f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 12 Oct 2026 14:22:15 -0500 Subject: [PATCH] fix: expose running terminals and prevent setup install stalls (#18740) --- 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 b760810a38767c13eb935052c7047fbf1e7db9f4..1fb029cc3484a2a3aeecd8ac1cbf7f630329f4d2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4803,6 +4803,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; } @@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) { } setTerminalOpen(nextOpen); }, [ + activeServerOrderedTerminalIds, activeProject, activeThreadId, activeThreadRef, @@ -4837,6 +4847,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 bff23a3a365e5df023cac2fa031465c495fa84cc..50de33c68fd3d629b327021435bb7c1e2ac1088a 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 6c0b0dbfb9c7cea4ae6e9e724b096a1c5ac24eee..051077b469a2683c2131c42d0faf5ae5ae427d7e 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, @@ -35,6 +36,7 @@ import { type ComponentProps, } from "react"; import { cn } from "../lib/utils"; +import { revealThreadTerminal } from "../lib/revealThreadTerminal"; import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; @@ -786,6 +788,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 0000000000000000000000000000000000000000..8a7cc95e024cabaa94e77d9a36674ca729f30e18 --- /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 0000000000000000000000000000000000000000..c6ce017000569633736d1f2c6ff97c0ab2334b21 --- /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 027112ca1858e496657105e6d345b9bdda8b6994..daf2f175aec40bb878e0a61f05643d59326c3fd4 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); -- 2.54.0