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);