authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 14:22:15-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 14:22:15-05:00
log0e7346ccd9427c06161e222319e718e6bd70887f
tree41b0e5c84e8024ec724c523386b229cfa7b89eba
parentb821fa04f1b9fe9c6fdb8d19ab7e4197b94c934b
signature Signed by PGP key B5690EEEBB952194

fix: expose running terminals and prevent setup install stalls (#18740)


7 files changed, 177 insertions(+), 40 deletions(-)

apps/web/src/components/ChatView.tsx+12
......@@ -4803,6 +4803,15 @@ export default function ChatView(props: ChatViewProps) {
48034803 )
48044804 return;
48054805 if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) {
4806 // Server-created setup terminals exist before this client's drawer mounts.
4807 // Reuse them before allocating a shell from the still-empty UI state.
4808 const existingTerminalId =
4809 runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ??
4810 activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id));
4811 if (existingTerminalId !== undefined) {
4812 storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true });
4813 return;
4814 }
48064815 if (!activeThreadId || !activeProject) {
48074816 return;
48084817 }
......@@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) {
48294838 }
48304839 setTerminalOpen(nextOpen);
48314840 }, [
4841 activeServerOrderedTerminalIds,
48324842 activeProject,
48334843 activeThreadId,
48344844 activeThreadRef,
......@@ -4837,6 +4847,8 @@ export default function ChatView(props: ChatViewProps) {
48374847 environmentId,
48384848 gitCwd,
48394849 openTerminal,
4850 panelTerminalIds,
4851 runningTerminalIds,
48404852 hasTerminalWriteAccess,
48414853 setTerminalOpen,
48424854 storeEnsureTerminal,
apps/web/src/components/LegacySidebar.tsx+6-23
......@@ -9,15 +9,13 @@ import {
99 Globe2Icon,
1010 SearchIcon,
1111 SquarePenIcon,
12 TerminalIcon,
1312 TriangleAlertIcon,
1413} from "lucide-react";
1514import {
1615 ChangeRequestStatusIcon,
1716 prStatusIndicator,
1817 PrStatusTooltipContent,
19 terminalStatusFromRunningIds,
20 synchronizeTerminalPulse,
18 ThreadTerminalStatusButton,
2119 ThreadStatusLabel,
2220 ThreadWorktreeIndicator,
2321 useLinkedThreadPullRequest,
......@@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
506504 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
507505 : null;
508506 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
509 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
510507 const isConfirmingArchive =
511508 canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;
512509 const threadMetaClassName = isConfirmingArchive
......@@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
849846 </Tooltip>
850847 )}
851848 <ThreadWorktreeIndicator thread={thread} />
852 {terminalStatus && (
853 <Tooltip>
854 <TooltipTrigger
855 render={
856 <span
857 role="img"
858 aria-label={terminalStatus.label}
859 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
860 />
861 }
862 >
863 <TerminalIcon
864 className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`}
865 onAnimationStart={synchronizeTerminalPulse}
866 />
867 </TooltipTrigger>
868 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
869 </Tooltip>
870 )}
849 <ThreadTerminalStatusButton
850 threadRef={threadRef}
851 terminalIds={runningTerminalIds}
852 onThreadActivate={navigateToThread}
853 />
871854 <div
872855 className={`flex min-w-12 justify-end ${
873856 isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"
apps/web/src/components/Sidebar.tsx+11-15
......@@ -1,7 +1,10 @@
11import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";
22import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
33import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
4import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";
4import {
5 setThreadChangeRequestSnapshot,
6 ThreadTerminalStatusButton,
7} from "./ThreadStatusIndicators";
58import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost";
69import {
710 dropThreadContext,
......@@ -248,7 +251,6 @@ import {
248251 terminalStatusFromRunningIds,
249252 threadChangeRequestSnapshotsAtom,
250253 type ThreadChangeRequestSnapshot,
251 synchronizeTerminalPulse,
252254 type TerminalStatusIndicator,
253255 useLinkedThreadPullRequest,
254256 ThreadSearchPullRequestNumber,
......@@ -1725,19 +1727,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17251727 onOpenPullRequest={handlePrClick}
17261728 />
17271729 ) : null;
1728 const terminalStatusIcon = terminalStatus ? (
1729 <span
1730 role="img"
1731 aria-label={terminalProcessLabel(terminalProcessCount)}
1732 data-testid={`sidebar-terminal-status-${thread.id}`}
1733 className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}
1734 >
1735 <TerminalIcon
1736 className={cn("size-3.5", terminalStatus.pulse && "motion-safe:animate-status-pulse")}
1737 onAnimationStart={synchronizeTerminalPulse}
1738 />
1739 </span>
1740 ) : null;
1730 const terminalStatusIcon = (
1731 <ThreadTerminalStatusButton
1732 threadRef={threadRef}
1733 terminalIds={runningTerminalIds}
1734 onThreadActivate={onThreadActivate}
1735 />
1736 );
17411737 // Same pen the new-thread draft rows lead with, so both kinds of unsent
17421738 // work read the same way in the list.
17431739 const draftIndicator = hasUnsentDraft ? (
apps/web/src/components/ThreadStatusIndicators.tsx+51
......@@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull
99import {
1010 resolveEnvironmentMachineKind,
1111 type EnvironmentId,
12 type ScopedThreadRef,
1213 type ThreadLinkedPullRequest,
1314 type ThreadPullRequestLink,
1415 type VcsStatusResult,
......@@ -35,6 +36,7 @@ import {
3536 type ComponentProps,
3637} from "react";
3738import { cn } from "../lib/utils";
39import { revealThreadTerminal } from "../lib/revealThreadTerminal";
3840
3941import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
4042import { useEnvironmentQuery } from "../state/query";
......@@ -786,6 +788,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {
786788 }
787789}
788790
791export function ThreadTerminalStatusButton({
792 threadRef,
793 terminalIds,
794 onThreadActivate,
795}: {
796 threadRef: ScopedThreadRef;
797 terminalIds: readonly string[];
798 onThreadActivate: (threadRef: ScopedThreadRef) => void;
799}) {
800 const terminalId = terminalIds[0];
801 if (terminalId === undefined) return null;
802 const status = terminalStatusFromRunningIds(terminalIds);
803 const label =
804 terminalIds.length === 1
805 ? "1 terminal process running. Open terminal"
806 : `${terminalIds.length} terminal processes running. Open terminal`;
807 return (
808 <Tooltip>
809 <TooltipTrigger
810 render={
811 <button
812 type="button"
813 aria-label={label}
814 data-testid={`sidebar-terminal-status-${threadRef.threadId}`}
815 className={cn(
816 "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",
817 status?.colorClass,
818 )}
819 onPointerDown={(event) => event.stopPropagation()}
820 onKeyDown={(event) => event.stopPropagation()}
821 onClick={(event) => {
822 event.preventDefault();
823 event.stopPropagation();
824 revealThreadTerminal(threadRef, terminalId);
825 onThreadActivate(threadRef);
826 }}
827 />
828 }
829 >
830 <TerminalIcon
831 className="size-3.5 motion-safe:animate-status-pulse"
832 onAnimationStart={synchronizeTerminalPulse}
833 />
834 </TooltipTrigger>
835 <TooltipPopup side="top">{label}</TooltipPopup>
836 </Tooltip>
837 );
838}
839
789840export function ThreadWorktreeIndicator({
790841 thread,
791842}: {
apps/web/src/lib/revealThreadTerminal.test.ts created+72
......@@ -0,0 +1,72 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, expect, it } from "vite-plus/test";
4
5import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
6import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
7import { revealThreadTerminal } from "./revealThreadTerminal";
8
9const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("settled-thread"));
10const remoteRef = scopeThreadRef(EnvironmentId.make("remote"), threadRef.threadId);
11
12beforeEach(() => {
13 useTerminalUiStateStore.setState({
14 terminalUiStateByThreadKey: {},
15 suppressedTerminalIdsByThreadKey: {},
16 });
17 useRightPanelStore.setState({ byThreadKey: {} });
18});
19
20it("reveals a server-created setup terminal without adding a default shell", () => {
21 revealThreadTerminal(threadRef, "setup-install");
22 expect(
23 selectThreadTerminalUiState(
24 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
25 threadRef,
26 ),
27 ).toMatchObject({
28 terminalOpen: true,
29 terminalIds: ["setup-install"],
30 activeTerminalId: "setup-install",
31 });
32});
33
34it("reopens the existing panel and selects a running terminal in its split", () => {
35 const panels = useRightPanelStore.getState();
36 panels.openTerminal(threadRef, "terminal-1");
37 const surfaceId = selectThreadRightPanelState(
38 useRightPanelStore.getState().byThreadKey,
39 threadRef,
40 ).activeSurfaceId!;
41 panels.splitTerminal(threadRef, surfaceId, "setup-install");
42 panels.activateTerminal(threadRef, surfaceId, "terminal-1");
43 panels.close(threadRef);
44
45 revealThreadTerminal(threadRef, "setup-install");
46 expect(
47 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef),
48 ).toMatchObject({
49 isOpen: true,
50 activeSurfaceId: surfaceId,
51 surfaces: [
52 {
53 terminalIds: ["terminal-1", "setup-install"],
54 activeTerminalId: "setup-install",
55 },
56 ],
57 });
58 expect(
59 selectThreadTerminalUiState(
60 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
61 threadRef,
62 ).terminalIds,
63 ).toEqual([]);
64});
65
66it("reveals terminals in their own environment without changing another environment's drawer", () => {
67 revealThreadTerminal(threadRef, "default");
68 revealThreadTerminal(remoteRef, "setup-install");
69 const state = useTerminalUiStateStore.getState().terminalUiStateByThreadKey;
70 expect(selectThreadTerminalUiState(state, threadRef).terminalIds).toEqual(["default"]);
71 expect(selectThreadTerminalUiState(state, remoteRef).terminalIds).toEqual(["setup-install"]);
72});
apps/web/src/lib/revealThreadTerminal.ts created+21
......@@ -0,0 +1,21 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
4import { useTerminalUiStateStore } from "../terminalUiStateStore";
5
6/** Reveal an existing terminal in the panel that owns it, without opening another PTY. */
7export function revealThreadTerminal(threadRef: ScopedThreadRef, terminalId: string) {
8 const panels = useRightPanelStore.getState();
9 const surface = selectThreadRightPanelState(panels.byThreadKey, threadRef).surfaces.find(
10 (surface) => surface.kind === "terminal" && surface.terminalIds.includes(terminalId),
11 );
12 if (surface) {
13 panels.activateTerminal(threadRef, surface.id, terminalId);
14 panels.activateSurface(threadRef, surface.id);
15 return;
16 }
17 useTerminalUiStateStore.getState().ensureTerminal(threadRef, terminalId, {
18 open: true,
19 active: true,
20 });
21}
scripts/setup-worktree.ts+4-2
......@@ -19,10 +19,12 @@ if (!projectRoot) {
1919const worktree = NodePath.dirname(import.meta.dirname);
2020
2121// `shell` resolves `vp` through PATH, including Windows command shims.
22const install = NodeChildProcess.spawnSync("vp i", {
22// Setup has no one answering install prompts. Pass pnpm's confirmation flag
23// through vp and leave stdin closed so other prompts fail instead of hanging.
24const install = NodeChildProcess.spawnSync("vp i -- --yes", {
2325 cwd: worktree,
2426 shell: true,
25 stdio: "inherit",
27 stdio: ["ignore", "inherit", "inherit"],
2628});
2729if (install.status !== 0) process.exit(install.status ?? 1);
2830