History

Fix terminal attach session launch context

Julius Marminge committed 6 months ago

Commit 93ec45e1 parent b4615a89 files · +249 −63Browse files

Unified · Split

9 files
  1. apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +1 −0
  2. apps/server/src/terminal/Layers/Manager.test.ts +79 −0
  3. apps/server/src/terminal/Layers/Manager.ts +1 −1
  4. apps/web/src/components/ChatView.browser.tsx +6 −6
  5. apps/web/src/components/ChatView.tsx +33 −0
  6. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +43 −21
  7. apps/web/src/components/ThreadTerminalDrawer.tsx +71 −35
  8. packages/contracts/src/terminal.test.ts +14 −0
  9. packages/contracts/src/terminal.ts +1 −0
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+1−0
@@ -509,6 +509,7 @@ export function ThreadTerminalRouteScreen() { · 508 unmodified lines
509509 cols: lastGridSize.cols,
510510 rows: lastGridSize.rows,
511511 env: pendingLaunch?.env,
512+ ...(pendingLaunch ? { restartIfNotRunning: true } : {}),
512513 },
513514 onEvent: logAttachStreamEvent,
514515 onSnapshot: () => {
apps/server/src/terminal/Layers/Manager.test.ts+79−0
@@ -312,6 +312,85 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 311 unmodified lines
312312 }),
313313 );
314314
315+ it.effect("attaches to exited sessions without restarting them", () =>
316+ Effect.gen(function* () {
317+ const { manager, ptyAdapter, getEvents } = yield* createManager();
318+
319+ yield* manager.open(openInput());
320+ const process = ptyAdapter.processes[0];
321+ expect(process).toBeDefined();
322+ if (!process) return;
323+
324+ process.emitExit({ exitCode: 0, signal: 0 });
325+
326+ yield* waitFor(
327+ Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
328+ "1200 millis",
329+ );
330+
331+ const scope = yield* Effect.scope;
332+ const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
333+ const unsubscribe = yield* manager.attachStream(
334+ openInput({
335+ env: {
336+ T3CODE_WORKTREE_PATH: "/tmp/should-not-restart",
337+ },
338+ worktreePath: "/tmp/should-not-restart",
339+ }),
340+ (event) => Ref.update(attachEvents, (events) => [...events, event]),
341+ );
342+ yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
343+
344+ const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
345+ expect(snapshot).toBeDefined();
346+ if (!snapshot || snapshot.type !== "snapshot") return;
347+ assert.equal(snapshot.snapshot.status, "exited");
348+ assert.equal(snapshot.snapshot.worktreePath, null);
349+ expect(ptyAdapter.spawnInputs).toHaveLength(1);
350+ }),
351+ );
352+
353+ it.effect("restarts inactive sessions from attach only when requested", () =>
354+ Effect.gen(function* () {
355+ const { manager, ptyAdapter, getEvents } = yield* createManager();
356+
357+ yield* manager.open(openInput());
358+ const process = ptyAdapter.processes[0];
359+ expect(process).toBeDefined();
360+ if (!process) return;
361+
362+ process.emitExit({ exitCode: 0, signal: 0 });
363+
364+ yield* waitFor(
365+ Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
366+ "1200 millis",
367+ );
368+
369+ const scope = yield* Effect.scope;
370+ const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
371+ const unsubscribe = yield* manager.attachStream(
372+ {
373+ ...openInput({
374+ env: {
375+ T3CODE_WORKTREE_PATH: "/tmp/restart-requested",
376+ },
377+ worktreePath: "/tmp/restart-requested",
378+ }),
379+ restartIfNotRunning: true,
380+ },
381+ (event) => Ref.update(attachEvents, (events) => [...events, event]),
382+ );
383+ yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe));
384+
385+ const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot");
386+ expect(snapshot).toBeDefined();
387+ if (!snapshot || snapshot.type !== "snapshot") return;
388+ assert.equal(snapshot.snapshot.status, "running");
389+ assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested");
390+ expect(ptyAdapter.spawnInputs).toHaveLength(2);
391+ }),
392+ );
393+
315394 const makeDirectory = (filePath: string) =>
316395 Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>
317396 fs.makeDirectory(filePath, { recursive: true }),
apps/server/src/terminal/Layers/Manager.ts+1−1
@@ -1995,7 +1995,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1994 unmodified lines
19951995 const targetCols = input.cols ?? session.cols;
19961996 const targetRows = input.rows ?? session.rows;
19971997
1998− if (!session.process && input.cwd) {
1998+ if (!session.process && input.cwd && input.restartIfNotRunning === true) {
19991999 return yield* openLocked({
20002000 ...input,
20012001 terminalId,
apps/web/src/components/ChatView.browser.tsx+6−6
@@ -1811,9 +1811,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1810 unmodified lines
18111811 try {
18121812 await vi.waitFor(
18131813 () => {
1814− const openRequest = wsRequests.find(
1815− (request) => request._tag === WS_METHODS.terminalOpen,
1816− ) as
1814+ const attachRequest = wsRequests
1815+ .toReversed()
1816+ .find((request) => request._tag === WS_METHODS.terminalAttach) as
18171817 | {
18181818 _tag: string;
18191819 cwd?: string;
@@ -1821,15 +1821,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
18211821 env?: Record<string, string>;
18221822 }
18231823 | undefined;
1824− expect(openRequest).toMatchObject({
1825− _tag: WS_METHODS.terminalOpen,
1824+ expect(attachRequest).toMatchObject({
1825+ _tag: WS_METHODS.terminalAttach,
18261826 cwd: "/repo/project",
18271827 worktreePath: null,
18281828 env: {
18291829 T3CODE_PROJECT_ROOT: "/repo/project",
18301830 },
18311831 });
1832− expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();
1832+ expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();
18331833 },
18341834 { timeout: 8_000, interval: 16 },
18351835 );
apps/web/src/components/ChatView.tsx+33−0
@@ -501,6 +501,38 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 500 unmodified lines
501501 }
502502 return next;
503503 }, [knownTerminalSessions]);
504+ const terminalLaunchLocationsById = useMemo(() => {
505+ const next = new Map<
506+ string,
507+ {
508+ readonly cwd: string;
509+ readonly worktreePath: string | null;
510+ readonly runtimeEnv: Record<string, string>;
511+ }
512+ >();
513+ if (!project) {
514+ return next;
515+ }
516+
517+ for (const session of knownTerminalSessions) {
518+ const summary = session.state.summary;
519+ if (!summary) {
520+ continue;
521+ }
522+ const worktreePathForLaunch =
523+ launchContext !== null ? launchContext.worktreePath : summary.worktreePath;
524+ next.set(session.target.terminalId, {
525+ cwd: launchContext?.cwd ?? summary.cwd,
526+ worktreePath: worktreePathForLaunch,
527+ runtimeEnv: projectScriptRuntimeEnv({
528+ project: { cwd: project.cwd },
529+ worktreePath: worktreePathForLaunch,
530+ }),
531+ });
532+ }
533+
534+ return next;
535+ }, [knownTerminalSessions, launchContext, project]);
504536 const serverOrderedTerminalIds = useMemo(
505537 () => knownTerminalSessions.map((session) => session.target.terminalId),
506538 [knownTerminalSessions],
@@ -709,6 +741,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 202 unmodified lines
709741 onHeightChange={setTerminalHeight}
710742 onAddTerminalContext={handleAddTerminalContext}
711743 terminalLabelsById={terminalLabelsById}
744+ terminalLaunchLocationsById={terminalLaunchLocationsById}
712745 />
713746 </div>
714747 );
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+43−21
@@ -1,7 +1,7 @@
11 import "../index.css";
22
33 import { scopeThreadRef } from "@t3tools/client-runtime";
4−import { ThreadId } from "@t3tools/contracts";
4+import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts";
55 import { afterEach, describe, expect, it, vi } from "vitest";
66 import { render } from "vitest-browser-react";
77
@@ -18,7 +18,17 @@ const { · 10 unmodified lines
1818 terminalDisposeSpy: vi.fn(),
1919 fitAddonFitSpy: vi.fn(),
2020 fitAddonLoadSpy: vi.fn(),
21− environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(),
21+ environmentApiById: new Map<
22+ string,
23+ {
24+ terminal: {
25+ open: ReturnType<typeof vi.fn>;
26+ attach: ReturnType<typeof vi.fn>;
27+ write: ReturnType<typeof vi.fn>;
28+ resize: ReturnType<typeof vi.fn>;
29+ };
30+ }
31+ >(),
2232 readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
2333 readLocalApiMock: vi.fn<
2434 () =>
@@ -124,21 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; · 99 unmodified lines
124134 const THREAD_ID = ThreadId.make("thread-terminal-browser");
125135
126136 function createEnvironmentApi() {
137+ const snapshot = {
138+ threadId: THREAD_ID,
139+ terminalId: "term-1",
140+ cwd: "/repo/project",
141+ worktreePath: null,
142+ status: "running" as const,
143+ pid: 123,
144+ history: "",
145+ exitCode: null,
146+ exitSignal: null,
147+ label: "Terminal 1",
148+ updatedAt: "2026-04-07T00:00:00.000Z",
149+ };
150+
127151 return {
128152 terminal: {
129− open: vi.fn(async () => ({
130− threadId: THREAD_ID,
131− terminalId: "term-1",
132− cwd: "/repo/project",
133− worktreePath: null,
134− status: "running" as const,
135− pid: 123,
136− history: "",
137− exitCode: null,
138− exitSignal: null,
139− label: "Terminal 1",
140− updatedAt: "2026-04-07T00:00:00.000Z",
141− })),
153+ open: vi.fn(async () => snapshot),
154+ attach: vi.fn(
155+ (
156+ _input: unknown,
157+ listener: (event: TerminalAttachStreamEvent) => void,
158+ _options?: unknown,
159+ ) => {
160+ listener({ type: "snapshot", snapshot });
161+ return vi.fn();
162+ },
163+ ),
142164 write: vi.fn(async () => undefined),
143165 resize: vi.fn(async () => undefined),
144166 },
@@ -237,7 +259,7 @@ describe("TerminalViewport", () => { · 92 unmodified lines
237259 }
238260 });
239261
240− it("reopens the terminal when the scoped thread reference changes", async () => {
262+ it("reattaches the terminal when the scoped thread reference changes", async () => {
241263 const environmentA = createEnvironmentApi();
242264 const environmentB = createEnvironmentApi();
243265 environmentApiById.set("environment-a", environmentA);
@@ -249,7 +271,7 @@ describe("TerminalViewport", () => { · 5 unmodified lines
249271
250272 try {
251273 await vi.waitFor(() => {
252− expect(environmentA.terminal.open).toHaveBeenCalledTimes(1);
274+ expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1);
253275 });
254276
255277 await mounted.rerender({
@@ -257,7 +279,7 @@ describe("TerminalViewport", () => { · 1 unmodified line
257279 });
258280
259281 await vi.waitFor(() => {
260− expect(environmentB.terminal.open).toHaveBeenCalledTimes(1);
282+ expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1);
261283 });
262284 expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);
263285 } finally {
@@ -265,7 +287,7 @@ describe("TerminalViewport", () => { · 1 unmodified line
265287 }
266288 });
267289
268− it("does not reopen the terminal when the scoped thread reference values stay the same", async () => {
290+ it("does not reattach the terminal when the scoped thread reference values stay the same", async () => {
269291 const environment = createEnvironmentApi();
270292 environmentApiById.set("environment-a", environment);
271293
@@ -275,7 +297,7 @@ describe("TerminalViewport", () => { · 3 unmodified lines
275297
276298 try {
277299 await vi.waitFor(() => {
278− expect(environment.terminal.open).toHaveBeenCalledTimes(1);
300+ expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
279301 });
280302
281303 await mounted.rerender({
@@ -283,7 +305,7 @@ describe("TerminalViewport", () => { · 1 unmodified line
283305 });
284306
285307 await vi.waitFor(() => {
286− expect(environment.terminal.open).toHaveBeenCalledTimes(1);
308+ expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
287309 });
288310 expect(terminalDisposeSpy).not.toHaveBeenCalled();
289311 } finally {
apps/web/src/components/ThreadTerminalDrawer.tsx+71−35
@@ -250,6 +250,12 @@ interface TerminalViewportProps { · 249 unmodified lines
250250 keybindings: ResolvedKeybindingsConfig;
251251 }
252252
253+interface TerminalLaunchLocation {
254+ readonly cwd: string;
255+ readonly worktreePath?: string | null;
256+ readonly runtimeEnv?: Record<string, string>;
257+}
258+
253259 export function TerminalViewport({
254260 threadRef,
255261 threadId,
@@ -694,7 +700,7 @@ export function TerminalViewport({ · 438 unmodified lines
694700 // autoFocus is intentionally omitted;
695701 // it is only read at mount time and must not trigger terminal teardown/recreation.
696702 // eslint-disable-next-line react-hooks/exhaustive-deps
697− }, [cwd, environmentId, runtimeEnv, terminalId, threadId]);
703+ }, [cwd, environmentId, runtimeEnv, terminalId, threadId, worktreePath]);
698704
699705 useEffect(() => {
700706 if (!autoFocus) return;
@@ -765,6 +771,8 @@ interface ThreadTerminalDrawerProps { · 64 unmodified lines
765771 keybindings: ResolvedKeybindingsConfig;
766772 /** Prefer server-provided tab titles when present (e.g. active subprocess name). */
767773 terminalLabelsById?: ReadonlyMap<string, string>;
774+ /** Prefer per-session launch locations when the server already knows a terminal. */
775+ terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
768776 }
769777
770778 interface TerminalActionButtonProps {
@@ -820,6 +828,7 @@ export default function ThreadTerminalDrawer({ · 49 unmodified lines
820828 onAddTerminalContext,
821829 keybindings,
822830 terminalLabelsById,
831+ terminalLaunchLocationsById,
823832 }: ThreadTerminalDrawerProps) {
824833 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
825834 const [resizeEpoch, setResizeEpoch] = useState(0);
@@ -938,6 +947,18 @@ export default function ThreadTerminalDrawer({ · 112 unmodified lines
938947 }
939948 return next;
940949 }, [normalizedTerminalIds, terminalLabelsById]);
950+ const resolveTerminalLaunchLocation = useCallback(
951+ (terminalId: string): TerminalLaunchLocation => {
952+ return (
953+ terminalLaunchLocationsById?.get(terminalId) ?? {
954+ cwd,
955+ ...(worktreePath !== undefined ? { worktreePath } : {}),
956+ ...(runtimeEnv ? { runtimeEnv } : {}),
957+ }
958+ );
959+ },
960+ [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
961+ );
941962 const splitTerminalActionLabel = hasReachedSplitLimit
942963 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
943964 : splitShortcutLabel
@@ -1086,6 +1107,8 @@ export default function ThreadTerminalDrawer({ · 142 unmodified lines
10861107 );
10871108 }
10881109
1110+ const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1111+
10891112 return (
10901113 <aside
10911114 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
@@ -1143,38 +1166,47 @@ export default function ThreadTerminalDrawer({ · 51 unmodified lines
11431166 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
11441167 }}
11451168 >
1146− {visibleTerminalIds.map((terminalId) => (
1147− <div
1148− key={terminalId}
1149− className={`min-h-0 min-w-0 border-l first:border-l-0 ${
1150− terminalId === resolvedActiveTerminalId ? "border-border" : "border-border/70"
1151− }`}
1152− onMouseDown={() => {
1153− if (terminalId !== resolvedActiveTerminalId) {
1154− onActiveTerminalChange(terminalId);
1155− }
1156− }}
1157− >
1158− <div className="h-full p-1">
1159− <TerminalViewport
1160− threadRef={threadRef}
1161− threadId={threadId}
1162− terminalId={terminalId}
1163− terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1164− cwd={cwd}
1165− {...(worktreePath !== undefined ? { worktreePath } : {})}
1166− {...(runtimeEnv ? { runtimeEnv } : {})}
1167− onSessionExited={() => onCloseTerminal(terminalId)}
1168− onAddTerminalContext={onAddTerminalContext}
1169− focusRequestId={focusRequestId}
1170− autoFocus={terminalId === resolvedActiveTerminalId}
1171− resizeEpoch={resizeEpoch}
1172− drawerHeight={drawerHeight}
1173− keybindings={keybindings}
1174− />
1169+ {visibleTerminalIds.map((terminalId) => {
1170+ const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1171+ return (
1172+ <div
1173+ key={terminalId}
1174+ className={`min-h-0 min-w-0 border-l first:border-l-0 ${
1175+ terminalId === resolvedActiveTerminalId
1176+ ? "border-border"
1177+ : "border-border/70"
1178+ }`}
1179+ onMouseDown={() => {
1180+ if (terminalId !== resolvedActiveTerminalId) {
1181+ onActiveTerminalChange(terminalId);
1182+ }
1183+ }}
1184+ >
1185+ <div className="h-full p-1">
1186+ <TerminalViewport
1187+ threadRef={threadRef}
1188+ threadId={threadId}
1189+ terminalId={terminalId}
1190+ terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1191+ cwd={terminalLaunchLocation.cwd}
1192+ {...(terminalLaunchLocation.worktreePath !== undefined
1193+ ? { worktreePath: terminalLaunchLocation.worktreePath }
1194+ : {})}
1195+ {...(terminalLaunchLocation.runtimeEnv
1196+ ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1197+ : {})}
1198+ onSessionExited={() => onCloseTerminal(terminalId)}
1199+ onAddTerminalContext={onAddTerminalContext}
1200+ focusRequestId={focusRequestId}
1201+ autoFocus={terminalId === resolvedActiveTerminalId}
1202+ resizeEpoch={resizeEpoch}
1203+ drawerHeight={drawerHeight}
1204+ keybindings={keybindings}
1205+ />
1206+ </div>
11751207 </div>
1176− </div>
1177− ))}
1208+ );
1209+ })}
11781210 </div>
11791211 ) : (
11801212 <div className="h-full p-1">
@@ -1184,9 +1216,13 @@ export default function ThreadTerminalDrawer({ · 3 unmodified lines
11841216 threadId={threadId}
11851217 terminalId={resolvedActiveTerminalId}
11861218 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1187− cwd={cwd}
1188− {...(worktreePath !== undefined ? { worktreePath } : {})}
1189− {...(runtimeEnv ? { runtimeEnv } : {})}
1219+ cwd={activeTerminalLaunchLocation.cwd}
1220+ {...(activeTerminalLaunchLocation.worktreePath !== undefined
1221+ ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1222+ : {})}
1223+ {...(activeTerminalLaunchLocation.runtimeEnv
1224+ ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1225+ : {})}
11901226 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
11911227 onAddTerminalContext={onAddTerminalContext}
11921228 focusRequestId={focusRequestId}
packages/contracts/src/terminal.test.ts+14−0
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
33
44 import {
55 DEFAULT_TERMINAL_ID,
6+ TerminalAttachInput,
67 TerminalClearInput,
78 TerminalCloseInput,
89 TerminalEvent,
@@ -109,6 +110,19 @@ describe("TerminalOpenInput", () => { · 100 unmodified lines
109110 });
110111 });
111112
113+describe("TerminalAttachInput", () => {
114+ it("accepts explicit inactive-session restart intent", () => {
115+ const parsed = decodeSync(TerminalAttachInput, {
116+ threadId: "thread-1",
117+ terminalId: DEFAULT_TERMINAL_ID,
118+ cwd: "/tmp/project",
119+ restartIfNotRunning: true,
120+ });
121+
122+ expect(parsed.restartIfNotRunning).toBe(true);
123+ });
124+});
125+
112126 describe("TerminalWriteInput", () => {
113127 it("accepts non-empty data", () => {
114128 expect(
packages/contracts/src/terminal.ts+1−0
@@ -53,6 +53,7 @@ export const TerminalAttachInput = Schema.Struct({ · 52 unmodified lines
5353 cols: Schema.optional(TerminalColsSchema),
5454 rows: Schema.optional(TerminalRowsSchema),
5555 env: Schema.optional(TerminalEnvSchema),
56+ restartIfNotRunning: Schema.optional(Schema.Boolean),
5657 });
5758 export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>;
5859