Unified · Split
| @@ -509,6 +509,7 @@ export function ThreadTerminalRouteScreen() { · 508 unmodified lines | ||
| 509 | 509 | cols: lastGridSize.cols, |
| 510 | 510 | rows: lastGridSize.rows, |
| 511 | 511 | env: pendingLaunch?.env, |
| 512 | + ...(pendingLaunch ? { restartIfNotRunning: true } : {}), | |
| 512 | 513 | }, |
| 513 | 514 | onEvent: logAttachStreamEvent, |
| 514 | 515 | onSnapshot: () => { |
| @@ -312,6 +312,85 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 311 unmodified lines | ||
| 312 | 312 | }), |
| 313 | 313 | ); |
| 314 | 314 | |
| 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 | + | |
| 315 | 394 | const makeDirectory = (filePath: string) => |
| 316 | 395 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => |
| 317 | 396 | fs.makeDirectory(filePath, { recursive: true }), |
| @@ -1995,7 +1995,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1994 unmodified lines | ||
| 1995 | 1995 | const targetCols = input.cols ?? session.cols; |
| 1996 | 1996 | const targetRows = input.rows ?? session.rows; |
| 1997 | 1997 | |
| 1998 | − if (!session.process && input.cwd) { | |
| 1998 | + if (!session.process && input.cwd && input.restartIfNotRunning === true) { | |
| 1999 | 1999 | return yield* openLocked({ |
| 2000 | 2000 | ...input, |
| 2001 | 2001 | terminalId, |
| @@ -1811,9 +1811,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1810 unmodified lines | ||
| 1811 | 1811 | try { |
| 1812 | 1812 | await vi.waitFor( |
| 1813 | 1813 | () => { |
| 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 | |
| 1817 | 1817 | | { |
| 1818 | 1818 | _tag: string; |
| 1819 | 1819 | cwd?: string; |
| @@ -1821,15 +1821,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line | ||
| 1821 | 1821 | env?: Record<string, string>; |
| 1822 | 1822 | } |
| 1823 | 1823 | | undefined; |
| 1824 | − expect(openRequest).toMatchObject({ | |
| 1825 | − _tag: WS_METHODS.terminalOpen, | |
| 1824 | + expect(attachRequest).toMatchObject({ | |
| 1825 | + _tag: WS_METHODS.terminalAttach, | |
| 1826 | 1826 | cwd: "/repo/project", |
| 1827 | 1827 | worktreePath: null, |
| 1828 | 1828 | env: { |
| 1829 | 1829 | T3CODE_PROJECT_ROOT: "/repo/project", |
| 1830 | 1830 | }, |
| 1831 | 1831 | }); |
| 1832 | − expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); | |
| 1832 | + expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); | |
| 1833 | 1833 | }, |
| 1834 | 1834 | { timeout: 8_000, interval: 16 }, |
| 1835 | 1835 | ); |
| @@ -501,6 +501,38 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 500 unmodified lines | ||
| 501 | 501 | } |
| 502 | 502 | return next; |
| 503 | 503 | }, [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]); | |
| 504 | 536 | const serverOrderedTerminalIds = useMemo( |
| 505 | 537 | () => knownTerminalSessions.map((session) => session.target.terminalId), |
| 506 | 538 | [knownTerminalSessions], |
| @@ -709,6 +741,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 202 unmodified lines | ||
| 709 | 741 | onHeightChange={setTerminalHeight} |
| 710 | 742 | onAddTerminalContext={handleAddTerminalContext} |
| 711 | 743 | terminalLabelsById={terminalLabelsById} |
| 744 | + terminalLaunchLocationsById={terminalLaunchLocationsById} | |
| 712 | 745 | /> |
| 713 | 746 | </div> |
| 714 | 747 | ); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import "../index.css"; |
| 2 | 2 | |
| 3 | 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 4 | −import { ThreadId } from "@t3tools/contracts"; | |
| 4 | +import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; | |
| 5 | 5 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 6 | 6 | import { render } from "vitest-browser-react"; |
| 7 | 7 | |
| @@ -18,7 +18,17 @@ const { · 10 unmodified lines | ||
| 18 | 18 | terminalDisposeSpy: vi.fn(), |
| 19 | 19 | fitAddonFitSpy: vi.fn(), |
| 20 | 20 | 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 | + >(), | |
| 22 | 32 | readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), |
| 23 | 33 | readLocalApiMock: vi.fn< |
| 24 | 34 | () => |
| @@ -124,21 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; · 99 unmodified lines | ||
| 124 | 134 | const THREAD_ID = ThreadId.make("thread-terminal-browser"); |
| 125 | 135 | |
| 126 | 136 | 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 | + | |
| 127 | 151 | return { |
| 128 | 152 | 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 | + ), | |
| 142 | 164 | write: vi.fn(async () => undefined), |
| 143 | 165 | resize: vi.fn(async () => undefined), |
| 144 | 166 | }, |
| @@ -237,7 +259,7 @@ describe("TerminalViewport", () => { · 92 unmodified lines | ||
| 237 | 259 | } |
| 238 | 260 | }); |
| 239 | 261 | |
| 240 | − it("reopens the terminal when the scoped thread reference changes", async () => { | |
| 262 | + it("reattaches the terminal when the scoped thread reference changes", async () => { | |
| 241 | 263 | const environmentA = createEnvironmentApi(); |
| 242 | 264 | const environmentB = createEnvironmentApi(); |
| 243 | 265 | environmentApiById.set("environment-a", environmentA); |
| @@ -249,7 +271,7 @@ describe("TerminalViewport", () => { · 5 unmodified lines | ||
| 249 | 271 | |
| 250 | 272 | try { |
| 251 | 273 | await vi.waitFor(() => { |
| 252 | − expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); | |
| 274 | + expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); | |
| 253 | 275 | }); |
| 254 | 276 | |
| 255 | 277 | await mounted.rerender({ |
| @@ -257,7 +279,7 @@ describe("TerminalViewport", () => { · 1 unmodified line | ||
| 257 | 279 | }); |
| 258 | 280 | |
| 259 | 281 | await vi.waitFor(() => { |
| 260 | − expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); | |
| 282 | + expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); | |
| 261 | 283 | }); |
| 262 | 284 | expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); |
| 263 | 285 | } finally { |
| @@ -265,7 +287,7 @@ describe("TerminalViewport", () => { · 1 unmodified line | ||
| 265 | 287 | } |
| 266 | 288 | }); |
| 267 | 289 | |
| 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 () => { | |
| 269 | 291 | const environment = createEnvironmentApi(); |
| 270 | 292 | environmentApiById.set("environment-a", environment); |
| 271 | 293 | |
| @@ -275,7 +297,7 @@ describe("TerminalViewport", () => { · 3 unmodified lines | ||
| 275 | 297 | |
| 276 | 298 | try { |
| 277 | 299 | await vi.waitFor(() => { |
| 278 | − expect(environment.terminal.open).toHaveBeenCalledTimes(1); | |
| 300 | + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | |
| 279 | 301 | }); |
| 280 | 302 | |
| 281 | 303 | await mounted.rerender({ |
| @@ -283,7 +305,7 @@ describe("TerminalViewport", () => { · 1 unmodified line | ||
| 283 | 305 | }); |
| 284 | 306 | |
| 285 | 307 | await vi.waitFor(() => { |
| 286 | − expect(environment.terminal.open).toHaveBeenCalledTimes(1); | |
| 308 | + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | |
| 287 | 309 | }); |
| 288 | 310 | expect(terminalDisposeSpy).not.toHaveBeenCalled(); |
| 289 | 311 | } finally { |
| @@ -250,6 +250,12 @@ interface TerminalViewportProps { · 249 unmodified lines | ||
| 250 | 250 | keybindings: ResolvedKeybindingsConfig; |
| 251 | 251 | } |
| 252 | 252 | |
| 253 | +interface TerminalLaunchLocation { | |
| 254 | + readonly cwd: string; | |
| 255 | + readonly worktreePath?: string | null; | |
| 256 | + readonly runtimeEnv?: Record<string, string>; | |
| 257 | +} | |
| 258 | + | |
| 253 | 259 | export function TerminalViewport({ |
| 254 | 260 | threadRef, |
| 255 | 261 | threadId, |
| @@ -694,7 +700,7 @@ export function TerminalViewport({ · 438 unmodified lines | ||
| 694 | 700 | // autoFocus is intentionally omitted; |
| 695 | 701 | // it is only read at mount time and must not trigger terminal teardown/recreation. |
| 696 | 702 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 697 | − }, [cwd, environmentId, runtimeEnv, terminalId, threadId]); | |
| 703 | + }, [cwd, environmentId, runtimeEnv, terminalId, threadId, worktreePath]); | |
| 698 | 704 | |
| 699 | 705 | useEffect(() => { |
| 700 | 706 | if (!autoFocus) return; |
| @@ -765,6 +771,8 @@ interface ThreadTerminalDrawerProps { · 64 unmodified lines | ||
| 765 | 771 | keybindings: ResolvedKeybindingsConfig; |
| 766 | 772 | /** Prefer server-provided tab titles when present (e.g. active subprocess name). */ |
| 767 | 773 | terminalLabelsById?: ReadonlyMap<string, string>; |
| 774 | + /** Prefer per-session launch locations when the server already knows a terminal. */ | |
| 775 | + terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>; | |
| 768 | 776 | } |
| 769 | 777 | |
| 770 | 778 | interface TerminalActionButtonProps { |
| @@ -820,6 +828,7 @@ export default function ThreadTerminalDrawer({ · 49 unmodified lines | ||
| 820 | 828 | onAddTerminalContext, |
| 821 | 829 | keybindings, |
| 822 | 830 | terminalLabelsById, |
| 831 | + terminalLaunchLocationsById, | |
| 823 | 832 | }: ThreadTerminalDrawerProps) { |
| 824 | 833 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| 825 | 834 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| @@ -938,6 +947,18 @@ export default function ThreadTerminalDrawer({ · 112 unmodified lines | ||
| 938 | 947 | } |
| 939 | 948 | return next; |
| 940 | 949 | }, [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 | + ); | |
| 941 | 962 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 942 | 963 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` |
| 943 | 964 | : splitShortcutLabel |
| @@ -1086,6 +1107,8 @@ export default function ThreadTerminalDrawer({ · 142 unmodified lines | ||
| 1086 | 1107 | ); |
| 1087 | 1108 | } |
| 1088 | 1109 | |
| 1110 | + const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); | |
| 1111 | + | |
| 1089 | 1112 | return ( |
| 1090 | 1113 | <aside |
| 1091 | 1114 | 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 | ||
| 1143 | 1166 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, |
| 1144 | 1167 | }} |
| 1145 | 1168 | > |
| 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> | |
| 1175 | 1207 | </div> |
| 1176 | − </div> | |
| 1177 | − ))} | |
| 1208 | + ); | |
| 1209 | + })} | |
| 1178 | 1210 | </div> |
| 1179 | 1211 | ) : ( |
| 1180 | 1212 | <div className="h-full p-1"> |
| @@ -1184,9 +1216,13 @@ export default function ThreadTerminalDrawer({ · 3 unmodified lines | ||
| 1184 | 1216 | threadId={threadId} |
| 1185 | 1217 | terminalId={resolvedActiveTerminalId} |
| 1186 | 1218 | 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 | + : {})} | |
| 1190 | 1226 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 1191 | 1227 | onAddTerminalContext={onAddTerminalContext} |
| 1192 | 1228 | focusRequestId={focusRequestId} |
| @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | DEFAULT_TERMINAL_ID, |
| 6 | + TerminalAttachInput, | |
| 6 | 7 | TerminalClearInput, |
| 7 | 8 | TerminalCloseInput, |
| 8 | 9 | TerminalEvent, |
| @@ -109,6 +110,19 @@ describe("TerminalOpenInput", () => { · 100 unmodified lines | ||
| 109 | 110 | }); |
| 110 | 111 | }); |
| 111 | 112 | |
| 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 | + | |
| 112 | 126 | describe("TerminalWriteInput", () => { |
| 113 | 127 | it("accepts non-empty data", () => { |
| 114 | 128 | expect( |
| @@ -53,6 +53,7 @@ export const TerminalAttachInput = Schema.Struct({ · 52 unmodified lines | ||
| 53 | 53 | cols: Schema.optional(TerminalColsSchema), |
| 54 | 54 | rows: Schema.optional(TerminalRowsSchema), |
| 55 | 55 | env: Schema.optional(TerminalEnvSchema), |
| 56 | + restartIfNotRunning: Schema.optional(Schema.Boolean), | |
| 56 | 57 | }); |
| 57 | 58 | export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>; |
| 58 | 59 | |