| author | |
| committer | |
| log | 485747b892fc9825a7b87719d70211fd8d794c5b |
| tree | 3e54053c41a82d16228bdc771c152aa2d5abc8a3 |
| parent | 15380f2b5f99065efb0100cc47ad96255594ae4e |
- add `terminalRunning` to thread state and hydration defaults
- apply terminal events in reducer to sync running/open state (close drawer on exit)
- wire drawer running-state callbacks and show a Terminal status pill in sidebar
- extend store and persistence tests for terminal running/exited behavior7 files changed, 164 insertions(+), 3 deletions(-)
apps/web/src/App.tsx+11| ... | ... | @@ -27,6 +27,16 @@ function EventRouter() { |
| 27 | 27 | }); |
| 28 | 28 | }, [api, dispatch]); |
| 29 | 29 | |
| 30 | useEffect(() => { | |
| 31 | if (!api) return; | |
| 32 | return api.terminal.onEvent((event) => { | |
| 33 | dispatch({ | |
| 34 | type: "APPLY_TERMINAL_EVENT", | |
| 35 | event, | |
| 36 | }); | |
| 37 | }); | |
| 38 | }, [api, dispatch]); | |
| 39 | ||
| 30 | 40 | return null; |
| 31 | 41 | } |
| 32 | 42 | |
| ... | ... | @@ -82,6 +92,7 @@ function AutoProjectBootstrap() { |
| 82 | 92 | terminalOpen: false, |
| 83 | 93 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 84 | 94 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 95 | runningTerminalIds: [], | |
| 85 | 96 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 86 | 97 | terminalGroups: [ |
| 87 | 98 | { |
apps/web/src/components/Sidebar.tsx+27-1| ... | ... | @@ -32,7 +32,7 @@ function inferProjectName(cwd: string): string { |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | interface ThreadStatusPill { |
| 35 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; | |
| 35 | label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Terminal"; | |
| 36 | 36 | colorClass: string; |
| 37 | 37 | dotClass: string; |
| 38 | 38 | pulse: boolean; |
| ... | ... | @@ -89,6 +89,18 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS |
| 89 | 89 | return null; |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | function terminalStatusPill(thread: Thread): ThreadStatusPill | null { | |
| 93 | if (thread.runningTerminalIds.length === 0) { | |
| 94 | return null; | |
| 95 | } | |
| 96 | return { | |
| 97 | label: "Terminal", | |
| 98 | colorClass: "text-teal-600 dark:text-teal-300/90", | |
| 99 | dotClass: "bg-teal-500 dark:bg-teal-300/90", | |
| 100 | pulse: true, | |
| 101 | }; | |
| 102 | } | |
| 103 | ||
| 92 | 104 | export default function Sidebar() { |
| 93 | 105 | const { state, dispatch } = useStore(); |
| 94 | 106 | const api = useNativeApi(); |
| ... | ... | @@ -118,6 +130,7 @@ export default function Sidebar() { |
| 118 | 130 | terminalOpen: false, |
| 119 | 131 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 120 | 132 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 133 | runningTerminalIds: [], | |
| 121 | 134 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 122 | 135 | terminalGroups: [ |
| 123 | 136 | { |
| ... | ... | @@ -388,6 +401,7 @@ export default function Sidebar() { |
| 388 | 401 | thread, |
| 389 | 402 | pendingApprovalByThreadId.get(thread.id) === true, |
| 390 | 403 | ); |
| 404 | const terminalStatus = terminalStatusPill(thread); | |
| 391 | 405 | return ( |
| 392 | 406 | <button |
| 393 | 407 | key={thread.id} |
| ... | ... | @@ -412,6 +426,18 @@ export default function Sidebar() { |
| 412 | 426 | }} |
| 413 | 427 | > |
| 414 | 428 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 429 | {terminalStatus && ( | |
| 430 | <span | |
| 431 | className={`inline-flex items-center gap-1 text-[10px] ${terminalStatus.colorClass}`} | |
| 432 | > | |
| 433 | <span | |
| 434 | className={`h-1.5 w-1.5 rounded-full ${terminalStatus.dotClass} ${ | |
| 435 | terminalStatus.pulse ? "animate-pulse" : "" | |
| 436 | }`} | |
| 437 | /> | |
| 438 | <span className="hidden md:inline">{terminalStatus.label}</span> | |
| 439 | </span> | |
| 440 | )} | |
| 415 | 441 | {threadStatus && ( |
| 416 | 442 | <span |
| 417 | 443 | className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} |
apps/web/src/persistenceSchema.test.ts+1| ... | ... | @@ -278,6 +278,7 @@ describe("toPersistedState", () => { |
| 278 | 278 | terminalOpen: true, |
| 279 | 279 | terminalHeight: 320, |
| 280 | 280 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], |
| 281 | runningTerminalIds: [], | |
| 281 | 282 | activeTerminalId: "term-2", |
| 282 | 283 | terminalGroups: [ |
| 283 | 284 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
apps/web/src/persistenceSchema.ts+1| ... | ... | @@ -243,6 +243,7 @@ function hydrateThread( |
| 243 | 243 | terminalOpen: thread.terminalOpen ?? false, |
| 244 | 244 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 245 | 245 | terminalIds: safeTerminalIds, |
| 246 | runningTerminalIds: [], | |
| 246 | 247 | activeTerminalId, |
| 247 | 248 | terminalGroups: normalizedGroups, |
| 248 | 249 | activeTerminalGroupId, |
apps/web/src/store.test.ts+78-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | |
| 1 | import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { type AppState, reducer } from "./store"; |
| ... | ... | @@ -38,6 +38,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 38 | 38 | terminalOpen: false, |
| 39 | 39 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 40 | 40 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 41 | runningTerminalIds: [], | |
| 41 | 42 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 42 | 43 | terminalGroups: [ |
| 43 | 44 | { |
| ... | ... | @@ -75,6 +76,27 @@ function makeState(thread: Thread): AppState { |
| 75 | 76 | }; |
| 76 | 77 | } |
| 77 | 78 | |
| 79 | function makeTerminalStartedEvent(overrides: Partial<TerminalEvent> = {}): TerminalEvent { | |
| 80 | return { | |
| 81 | type: "started", | |
| 82 | threadId: "thread-local-1", | |
| 83 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 84 | createdAt: "2026-02-09T00:00:01.000Z", | |
| 85 | snapshot: { | |
| 86 | threadId: "thread-local-1", | |
| 87 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 88 | cwd: "/tmp/project", | |
| 89 | status: "running", | |
| 90 | pid: 1234, | |
| 91 | history: "", | |
| 92 | exitCode: null, | |
| 93 | exitSignal: null, | |
| 94 | updatedAt: "2026-02-09T00:00:01.000Z", | |
| 95 | }, | |
| 96 | ...overrides, | |
| 97 | }; | |
| 98 | } | |
| 99 | ||
| 78 | 100 | describe("store reducer thread continuity", () => { |
| 79 | 101 | it("stores codexThreadId from UPDATE_SESSION", () => { |
| 80 | 102 | const state = makeState( |
| ... | ... | @@ -255,6 +277,7 @@ describe("store reducer thread continuity", () => { |
| 255 | 277 | const state = makeState( |
| 256 | 278 | makeThread({ |
| 257 | 279 | terminalOpen: true, |
| 280 | runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 258 | 281 | }), |
| 259 | 282 | ); |
| 260 | 283 | const next = reducer(state, { |
| ... | ... | @@ -265,6 +288,7 @@ describe("store reducer thread continuity", () => { |
| 265 | 288 | |
| 266 | 289 | expect(next.threads[0]?.terminalOpen).toBe(false); |
| 267 | 290 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); |
| 291 | expect(next.threads[0]?.runningTerminalIds).toEqual([]); | |
| 268 | 292 | expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); |
| 269 | 293 | expect(next.threads[0]?.terminalGroups).toEqual([ |
| 270 | 294 | { |
| ... | ... | @@ -274,6 +298,59 @@ describe("store reducer thread continuity", () => { |
| 274 | 298 | ]); |
| 275 | 299 | }); |
| 276 | 300 | |
| 301 | it("tracks running terminals from terminal lifecycle events", () => { | |
| 302 | const state = makeState(makeThread()); | |
| 303 | const started = reducer(state, { | |
| 304 | type: "APPLY_TERMINAL_EVENT", | |
| 305 | event: makeTerminalStartedEvent(), | |
| 306 | }); | |
| 307 | expect(started.threads[0]?.runningTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | |
| 308 | ||
| 309 | const exited = reducer(started, { | |
| 310 | type: "APPLY_TERMINAL_EVENT", | |
| 311 | event: { | |
| 312 | type: "exited", | |
| 313 | threadId: "thread-local-1", | |
| 314 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 315 | createdAt: "2026-02-09T00:00:05.000Z", | |
| 316 | exitCode: 0, | |
| 317 | exitSignal: null, | |
| 318 | }, | |
| 319 | }); | |
| 320 | expect(exited.threads[0]?.runningTerminalIds).toEqual([]); | |
| 321 | }); | |
| 322 | ||
| 323 | it("keeps running status when another terminal in the thread is still running", () => { | |
| 324 | const state = makeState( | |
| 325 | makeThread({ | |
| 326 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 327 | runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 328 | activeTerminalId: "term-2", | |
| 329 | terminalGroups: [ | |
| 330 | { | |
| 331 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 332 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 333 | }, | |
| 334 | ], | |
| 335 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 336 | }), | |
| 337 | ); | |
| 338 | ||
| 339 | const next = reducer(state, { | |
| 340 | type: "APPLY_TERMINAL_EVENT", | |
| 341 | event: { | |
| 342 | type: "exited", | |
| 343 | threadId: "thread-local-1", | |
| 344 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 345 | createdAt: "2026-02-09T00:00:07.000Z", | |
| 346 | exitCode: 0, | |
| 347 | exitSignal: null, | |
| 348 | }, | |
| 349 | }); | |
| 350 | ||
| 351 | expect(next.threads[0]?.runningTerminalIds).toEqual(["term-2"]); | |
| 352 | }); | |
| 353 | ||
| 277 | 354 | it("backfills codexThreadId from routed provider events", () => { |
| 278 | 355 | const state = makeState(makeThread({ codexThreadId: null })); |
| 279 | 356 | const next = reducer(state, { |
apps/web/src/store.ts+45-1| ... | ... | @@ -8,7 +8,7 @@ import { |
| 8 | 8 | useReducer, |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | |
| 11 | import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; | |
| 12 | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; |
| ... | ... | @@ -43,6 +43,7 @@ type Action = |
| 43 | 43 | event: ProviderEvent; |
| 44 | 44 | activeAssistantItemRef: { current: string | null }; |
| 45 | 45 | } |
| 46 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | |
| 46 | 47 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| 47 | 48 | | { |
| 48 | 49 | type: "PUSH_USER_MESSAGE"; |
| ... | ... | @@ -149,6 +150,17 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 149 | 150 | return [DEFAULT_THREAD_TERMINAL_ID]; |
| 150 | 151 | } |
| 151 | 152 | |
| 153 | function normalizeRunningTerminalIds( | |
| 154 | runningTerminalIds: string[], | |
| 155 | terminalIds: string[], | |
| 156 | ): string[] { | |
| 157 | if (runningTerminalIds.length === 0) return []; | |
| 158 | const validTerminalIdSet = new Set(terminalIds); | |
| 159 | return [...new Set(runningTerminalIds)] | |
| 160 | .map((id) => id.trim()) | |
| 161 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | |
| 162 | } | |
| 163 | ||
| 152 | 164 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 153 | 165 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 154 | 166 | } |
| ... | ... | @@ -252,6 +264,7 @@ function normalizeThreadTerminals(thread: Thread): Thread { |
| 252 | 264 | return { |
| 253 | 265 | ...thread, |
| 254 | 266 | terminalIds, |
| 267 | runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds), | |
| 255 | 268 | activeTerminalId, |
| 256 | 269 | terminalGroups, |
| 257 | 270 | activeTerminalGroupId, |
| ... | ... | @@ -269,6 +282,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 269 | 282 | ...thread, |
| 270 | 283 | terminalOpen: false, |
| 271 | 284 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 285 | runningTerminalIds: [], | |
| 272 | 286 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, |
| 273 | 287 | terminalGroups: [ |
| 274 | 288 | { |
| ... | ... | @@ -309,6 +323,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 309 | 323 | return normalizeThreadTerminals({ |
| 310 | 324 | ...thread, |
| 311 | 325 | terminalIds: remainingTerminalIds, |
| 326 | runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId), | |
| 312 | 327 | activeTerminalId: nextActiveTerminalId, |
| 313 | 328 | terminalGroups: nextTerminalGroups, |
| 314 | 329 | }); |
| ... | ... | @@ -650,6 +665,35 @@ export function reducer(state: AppState, action: Action): AppState { |
| 650 | 665 | case "TOGGLE_DIFF": |
| 651 | 666 | return { ...state, diffOpen: !state.diffOpen }; |
| 652 | 667 | |
| 668 | case "APPLY_TERMINAL_EVENT": | |
| 669 | if (!state.threads.some((thread) => thread.id === action.event.threadId)) { | |
| 670 | return state; | |
| 671 | } | |
| 672 | return { | |
| 673 | ...state, | |
| 674 | threads: updateThread(state.threads, action.event.threadId, (thread) => { | |
| 675 | const normalizedThread = normalizeThreadTerminals(thread); | |
| 676 | const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds); | |
| 677 | if ( | |
| 678 | action.event.type === "started" || | |
| 679 | action.event.type === "restarted" | |
| 680 | ) { | |
| 681 | if (action.event.snapshot.status === "running") { | |
| 682 | runningTerminalIdSet.add(action.event.terminalId); | |
| 683 | } else { | |
| 684 | runningTerminalIdSet.delete(action.event.terminalId); | |
| 685 | } | |
| 686 | } else if (action.event.type === "exited" || action.event.type === "error") { | |
| 687 | runningTerminalIdSet.delete(action.event.terminalId); | |
| 688 | } | |
| 689 | ||
| 690 | return normalizeThreadTerminals({ | |
| 691 | ...normalizedThread, | |
| 692 | runningTerminalIds: [...runningTerminalIdSet], | |
| 693 | }); | |
| 694 | }), | |
| 695 | }; | |
| 696 | ||
| 653 | 697 | case "APPLY_EVENT": { |
| 654 | 698 | const { event, activeAssistantItemRef } = action; |
| 655 | 699 | const target = findThreadBySessionId(state.threads, event.sessionId); |
apps/web/src/types.ts+1| ... | ... | @@ -48,6 +48,7 @@ export interface Thread { |
| 48 | 48 | terminalOpen: boolean; |
| 49 | 49 | terminalHeight: number; |
| 50 | 50 | terminalIds: string[]; |
| 51 | runningTerminalIds: string[]; | |
| 51 | 52 | activeTerminalId: string; |
| 52 | 53 | terminalGroups: ThreadTerminalGroup[]; |
| 53 | 54 | activeTerminalGroupId: string; |