authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 20:13:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 22:20:01-08:00
log485747b892fc9825a7b87719d70211fd8d794c5b
tree3e54053c41a82d16228bdc771c152aa2d5abc8a3
parent15380f2b5f99065efb0100cc47ad96255594ae4e

Track thread terminal running state across UI and store

- 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 behavior

7 files changed, 164 insertions(+), 3 deletions(-)

apps/web/src/App.tsx+11
...@@ -27,6 +27,16 @@ function EventRouter() {...@@ -27,6 +27,16 @@ function EventRouter() {
27 });27 });
28 }, [api, dispatch]);28 }, [api, dispatch]);
2929
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 return null;40 return null;
31}41}
3242
...@@ -82,6 +92,7 @@ function AutoProjectBootstrap() {...@@ -82,6 +92,7 @@ function AutoProjectBootstrap() {
82 terminalOpen: false,92 terminalOpen: false,
83 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,93 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
84 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],94 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
95 runningTerminalIds: [],
85 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,96 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
86 terminalGroups: [97 terminalGroups: [
87 {98 {
apps/web/src/components/Sidebar.tsx+27-1
...@@ -32,7 +32,7 @@ function inferProjectName(cwd: string): string {...@@ -32,7 +32,7 @@ function inferProjectName(cwd: string): string {
32}32}
3333
34interface ThreadStatusPill {34interface ThreadStatusPill {
35 label: "Working" | "Connecting" | "Completed" | "Awaiting response";35 label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Terminal";
36 colorClass: string;36 colorClass: string;
37 dotClass: string;37 dotClass: string;
38 pulse: boolean;38 pulse: boolean;
...@@ -89,6 +89,18 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS...@@ -89,6 +89,18 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
89 return null;89 return null;
90}90}
9191
92function 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
92export default function Sidebar() {104export default function Sidebar() {
93 const { state, dispatch } = useStore();105 const { state, dispatch } = useStore();
94 const api = useNativeApi();106 const api = useNativeApi();
...@@ -118,6 +130,7 @@ export default function Sidebar() {...@@ -118,6 +130,7 @@ export default function Sidebar() {
118 terminalOpen: false,130 terminalOpen: false,
119 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,131 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
120 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],132 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
133 runningTerminalIds: [],
121 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,134 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
122 terminalGroups: [135 terminalGroups: [
123 {136 {
...@@ -388,6 +401,7 @@ export default function Sidebar() {...@@ -388,6 +401,7 @@ export default function Sidebar() {
388 thread,401 thread,
389 pendingApprovalByThreadId.get(thread.id) === true,402 pendingApprovalByThreadId.get(thread.id) === true,
390 );403 );
404 const terminalStatus = terminalStatusPill(thread);
391 return (405 return (
392 <button406 <button
393 key={thread.id}407 key={thread.id}
...@@ -412,6 +426,18 @@ export default function Sidebar() {...@@ -412,6 +426,18 @@ export default function Sidebar() {
412 }}426 }}
413 >427 >
414 <div className="flex min-w-0 flex-1 items-center gap-1.5">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 {threadStatus && (441 {threadStatus && (
416 <span442 <span
417 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}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,6 +278,7 @@ describe("toPersistedState", () => {
278 terminalOpen: true,278 terminalOpen: true,
279 terminalHeight: 320,279 terminalHeight: 320,
280 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],280 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
281 runningTerminalIds: [],
281 activeTerminalId: "term-2",282 activeTerminalId: "term-2",
282 terminalGroups: [283 terminalGroups: [
283 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },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,6 +243,7 @@ function hydrateThread(
243 terminalOpen: thread.terminalOpen ?? false,243 terminalOpen: thread.terminalOpen ?? false,
244 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,244 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
245 terminalIds: safeTerminalIds,245 terminalIds: safeTerminalIds,
246 runningTerminalIds: [],
246 activeTerminalId,247 activeTerminalId,
247 terminalGroups: normalizedGroups,248 terminalGroups: normalizedGroups,
248 activeTerminalGroupId,249 activeTerminalGroupId,
apps/web/src/store.test.ts+78-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";1import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { type AppState, reducer } from "./store";4import { type AppState, reducer } from "./store";
...@@ -38,6 +38,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -38,6 +38,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
38 terminalOpen: false,38 terminalOpen: false,
39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
40 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],40 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
41 runningTerminalIds: [],
41 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,42 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
42 terminalGroups: [43 terminalGroups: [
43 {44 {
...@@ -75,6 +76,27 @@ function makeState(thread: Thread): AppState {...@@ -75,6 +76,27 @@ function makeState(thread: Thread): AppState {
75 };76 };
76}77}
7778
79function 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
78describe("store reducer thread continuity", () => {100describe("store reducer thread continuity", () => {
79 it("stores codexThreadId from UPDATE_SESSION", () => {101 it("stores codexThreadId from UPDATE_SESSION", () => {
80 const state = makeState(102 const state = makeState(
...@@ -255,6 +277,7 @@ describe("store reducer thread continuity", () => {...@@ -255,6 +277,7 @@ describe("store reducer thread continuity", () => {
255 const state = makeState(277 const state = makeState(
256 makeThread({278 makeThread({
257 terminalOpen: true,279 terminalOpen: true,
280 runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID],
258 }),281 }),
259 );282 );
260 const next = reducer(state, {283 const next = reducer(state, {
...@@ -265,6 +288,7 @@ describe("store reducer thread continuity", () => {...@@ -265,6 +288,7 @@ describe("store reducer thread continuity", () => {
265288
266 expect(next.threads[0]?.terminalOpen).toBe(false);289 expect(next.threads[0]?.terminalOpen).toBe(false);
267 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);290 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
291 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
268 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);292 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
269 expect(next.threads[0]?.terminalGroups).toEqual([293 expect(next.threads[0]?.terminalGroups).toEqual([
270 {294 {
...@@ -274,6 +298,59 @@ describe("store reducer thread continuity", () => {...@@ -274,6 +298,59 @@ describe("store reducer thread continuity", () => {
274 ]);298 ]);
275 });299 });
276300
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 it("backfills codexThreadId from routed provider events", () => {354 it("backfills codexThreadId from routed provider events", () => {
278 const state = makeState(makeThread({ codexThreadId: null }));355 const state = makeState(makeThread({ codexThreadId: null }));
279 const next = reducer(state, {356 const next = reducer(state, {
apps/web/src/store.ts+45-1
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8 useReducer,8 useReducer,
9} from "react";9} from "react";
1010
11import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";11import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
...@@ -43,6 +43,7 @@ type Action =...@@ -43,6 +43,7 @@ type Action =
43 event: ProviderEvent;43 event: ProviderEvent;
44 activeAssistantItemRef: { current: string | null };44 activeAssistantItemRef: { current: string | null };
45 }45 }
46 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
46 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }47 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
47 | {48 | {
48 type: "PUSH_USER_MESSAGE";49 type: "PUSH_USER_MESSAGE";
...@@ -149,6 +150,17 @@ function normalizeTerminalIds(terminalIds: string[]): string[] {...@@ -149,6 +150,17 @@ function normalizeTerminalIds(terminalIds: string[]): string[] {
149 return [DEFAULT_THREAD_TERMINAL_ID];150 return [DEFAULT_THREAD_TERMINAL_ID];
150}151}
151152
153function 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
152function normalizeTerminalGroupIds(terminalIds: string[]): string[] {164function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
153 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];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,6 +264,7 @@ function normalizeThreadTerminals(thread: Thread): Thread {
252 return {264 return {
253 ...thread,265 ...thread,
254 terminalIds,266 terminalIds,
267 runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds),
255 activeTerminalId,268 activeTerminalId,
256 terminalGroups,269 terminalGroups,
257 activeTerminalGroupId,270 activeTerminalGroupId,
...@@ -269,6 +282,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {...@@ -269,6 +282,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
269 ...thread,282 ...thread,
270 terminalOpen: false,283 terminalOpen: false,
271 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],284 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
285 runningTerminalIds: [],
272 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,286 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
273 terminalGroups: [287 terminalGroups: [
274 {288 {
...@@ -309,6 +323,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {...@@ -309,6 +323,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
309 return normalizeThreadTerminals({323 return normalizeThreadTerminals({
310 ...thread,324 ...thread,
311 terminalIds: remainingTerminalIds,325 terminalIds: remainingTerminalIds,
326 runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId),
312 activeTerminalId: nextActiveTerminalId,327 activeTerminalId: nextActiveTerminalId,
313 terminalGroups: nextTerminalGroups,328 terminalGroups: nextTerminalGroups,
314 });329 });
...@@ -650,6 +665,35 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -650,6 +665,35 @@ export function reducer(state: AppState, action: Action): AppState {
650 case "TOGGLE_DIFF":665 case "TOGGLE_DIFF":
651 return { ...state, diffOpen: !state.diffOpen };666 return { ...state, diffOpen: !state.diffOpen };
652667
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 case "APPLY_EVENT": {697 case "APPLY_EVENT": {
654 const { event, activeAssistantItemRef } = action;698 const { event, activeAssistantItemRef } = action;
655 const target = findThreadBySessionId(state.threads, event.sessionId);699 const target = findThreadBySessionId(state.threads, event.sessionId);
apps/web/src/types.ts+1
...@@ -48,6 +48,7 @@ export interface Thread {...@@ -48,6 +48,7 @@ export interface Thread {
48 terminalOpen: boolean;48 terminalOpen: boolean;
49 terminalHeight: number;49 terminalHeight: number;
50 terminalIds: string[];50 terminalIds: string[];
51 runningTerminalIds: string[];
51 activeTerminalId: string;52 activeTerminalId: string;
52 terminalGroups: ThreadTerminalGroup[];53 terminalGroups: ThreadTerminalGroup[];
53 activeTerminalGroupId: string;54 activeTerminalGroupId: string;