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) {...@@ -4803,6 +4803,15 @@ export default function ChatView(props: ChatViewProps) {
4803 )4803 )
4804 return;4804 return;
4805 if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) {4805 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 }
4806 if (!activeThreadId || !activeProject) {4815 if (!activeThreadId || !activeProject) {
4807 return;4816 return;
4808 }4817 }
...@@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) {
4829 }4838 }
4830 setTerminalOpen(nextOpen);4839 setTerminalOpen(nextOpen);
4831 }, [4840 }, [
4841 activeServerOrderedTerminalIds,
4832 activeProject,4842 activeProject,
4833 activeThreadId,4843 activeThreadId,
4834 activeThreadRef,4844 activeThreadRef,
...@@ -4837,6 +4847,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -4837,6 +4847,8 @@ export default function ChatView(props: ChatViewProps) {
4837 environmentId,4847 environmentId,
4838 gitCwd,4848 gitCwd,
4839 openTerminal,4849 openTerminal,
4850 panelTerminalIds,
4851 runningTerminalIds,
4840 hasTerminalWriteAccess,4852 hasTerminalWriteAccess,
4841 setTerminalOpen,4853 setTerminalOpen,
4842 storeEnsureTerminal,4854 storeEnsureTerminal,
apps/web/src/components/LegacySidebar.tsx+6-23
...@@ -9,15 +9,13 @@ import {...@@ -9,15 +9,13 @@ import {
9 Globe2Icon,9 Globe2Icon,
10 SearchIcon,10 SearchIcon,
11 SquarePenIcon,11 SquarePenIcon,
12 TerminalIcon,
13 TriangleAlertIcon,12 TriangleAlertIcon,
14} from "lucide-react";13} from "lucide-react";
15import {14import {
16 ChangeRequestStatusIcon,15 ChangeRequestStatusIcon,
17 prStatusIndicator,16 prStatusIndicator,
18 PrStatusTooltipContent,17 PrStatusTooltipContent,
19 terminalStatusFromRunningIds,18 ThreadTerminalStatusButton,
20 synchronizeTerminalPulse,
21 ThreadStatusLabel,19 ThreadStatusLabel,
22 ThreadWorktreeIndicator,20 ThreadWorktreeIndicator,
23 useLinkedThreadPullRequest,21 useLinkedThreadPullRequest,
...@@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
506 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)504 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
507 : null;505 : null;
508 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);506 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
509 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
510 const isConfirmingArchive =507 const isConfirmingArchive =
511 canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;508 canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;
512 const threadMetaClassName = isConfirmingArchive509 const threadMetaClassName = isConfirmingArchive
...@@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
849 </Tooltip>846 </Tooltip>
850 )}847 )}
851 <ThreadWorktreeIndicator thread={thread} />848 <ThreadWorktreeIndicator thread={thread} />
852 {terminalStatus && (849 <ThreadTerminalStatusButton
853 <Tooltip>850 threadRef={threadRef}
854 <TooltipTrigger851 terminalIds={runningTerminalIds}
855 render={852 onThreadActivate={navigateToThread}
856 <span853 />
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 )}
871 <div854 <div
872 className={`flex min-w-12 justify-end ${855 className={`flex min-w-12 justify-end ${
873 isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"856 isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"
apps/web/src/components/Sidebar.tsx+11-15
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";1import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";
2import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";2import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
3import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";3import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
4import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";4import {
5 setThreadChangeRequestSnapshot,
6 ThreadTerminalStatusButton,
7} from "./ThreadStatusIndicators";
5import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost";8import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost";
6import {9import {
7 dropThreadContext,10 dropThreadContext,
...@@ -248,7 +251,6 @@ import {...@@ -248,7 +251,6 @@ import {
248 terminalStatusFromRunningIds,251 terminalStatusFromRunningIds,
249 threadChangeRequestSnapshotsAtom,252 threadChangeRequestSnapshotsAtom,
250 type ThreadChangeRequestSnapshot,253 type ThreadChangeRequestSnapshot,
251 synchronizeTerminalPulse,
252 type TerminalStatusIndicator,254 type TerminalStatusIndicator,
253 useLinkedThreadPullRequest,255 useLinkedThreadPullRequest,
254 ThreadSearchPullRequestNumber,256 ThreadSearchPullRequestNumber,
...@@ -1725,19 +1727,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1725,19 +1727,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1725 onOpenPullRequest={handlePrClick}1727 onOpenPullRequest={handlePrClick}
1726 />1728 />
1727 ) : null;1729 ) : null;
1728 const terminalStatusIcon = terminalStatus ? (1730 const terminalStatusIcon = (
1729 <span1731 <ThreadTerminalStatusButton
1730 role="img"1732 threadRef={threadRef}
1731 aria-label={terminalProcessLabel(terminalProcessCount)}1733 terminalIds={runningTerminalIds}
1732 data-testid={`sidebar-terminal-status-${thread.id}`}1734 onThreadActivate={onThreadActivate}
1733 className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}1735 />
1734 >1736 );
1735 <TerminalIcon
1736 className={cn("size-3.5", terminalStatus.pulse && "motion-safe:animate-status-pulse")}
1737 onAnimationStart={synchronizeTerminalPulse}
1738 />
1739 </span>
1740 ) : null;
1741 // Same pen the new-thread draft rows lead with, so both kinds of unsent1737 // Same pen the new-thread draft rows lead with, so both kinds of unsent
1742 // work read the same way in the list.1738 // work read the same way in the list.
1743 const draftIndicator = hasUnsentDraft ? (1739 const draftIndicator = hasUnsentDraft ? (
apps/web/src/components/ThreadStatusIndicators.tsx+51
...@@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull...@@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull
9import {9import {
10 resolveEnvironmentMachineKind,10 resolveEnvironmentMachineKind,
11 type EnvironmentId,11 type EnvironmentId,
12 type ScopedThreadRef,
12 type ThreadLinkedPullRequest,13 type ThreadLinkedPullRequest,
13 type ThreadPullRequestLink,14 type ThreadPullRequestLink,
14 type VcsStatusResult,15 type VcsStatusResult,
...@@ -35,6 +36,7 @@ import {...@@ -35,6 +36,7 @@ import {
35 type ComponentProps,36 type ComponentProps,
36} from "react";37} from "react";
37import { cn } from "../lib/utils";38import { cn } from "../lib/utils";
39import { revealThreadTerminal } from "../lib/revealThreadTerminal";
3840
39import { parseChangeRequestUrl } from "../lib/openPullRequestLink";41import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
40import { useEnvironmentQuery } from "../state/query";42import { useEnvironmentQuery } from "../state/query";
...@@ -786,6 +788,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {...@@ -786,6 +788,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {
786 }788 }
787}789}
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
789export function ThreadWorktreeIndicator({840export function ThreadWorktreeIndicator({
790 thread,841 thread,
791}: {842}: {
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) {...@@ -19,10 +19,12 @@ if (!projectRoot) {
19const worktree = NodePath.dirname(import.meta.dirname);19const worktree = NodePath.dirname(import.meta.dirname);
2020
21// `shell` resolves `vp` through PATH, including Windows command shims.21// `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", {
23 cwd: worktree,25 cwd: worktree,
24 shell: true,26 shell: true,
25 stdio: "inherit",27 stdio: ["ignore", "inherit", "inherit"],
26});28});
27if (install.status !== 0) process.exit(install.status ?? 1);29if (install.status !== 0) process.exit(install.status ?? 1);
2830