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() {
2727 });
2828 }, [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
3040 return null;
3141}
3242
......@@ -82,6 +92,7 @@ function AutoProjectBootstrap() {
8292 terminalOpen: false,
8393 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
8494 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
95 runningTerminalIds: [],
8596 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
8697 terminalGroups: [
8798 {
apps/web/src/components/Sidebar.tsx+27-1
......@@ -32,7 +32,7 @@ function inferProjectName(cwd: string): string {
3232}
3333
3434interface ThreadStatusPill {
35 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
35 label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Terminal";
3636 colorClass: string;
3737 dotClass: string;
3838 pulse: boolean;
......@@ -89,6 +89,18 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
8989 return null;
9090}
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
92104export default function Sidebar() {
93105 const { state, dispatch } = useStore();
94106 const api = useNativeApi();
......@@ -118,6 +130,7 @@ export default function Sidebar() {
118130 terminalOpen: false,
119131 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
120132 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
133 runningTerminalIds: [],
121134 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
122135 terminalGroups: [
123136 {
......@@ -388,6 +401,7 @@ export default function Sidebar() {
388401 thread,
389402 pendingApprovalByThreadId.get(thread.id) === true,
390403 );
404 const terminalStatus = terminalStatusPill(thread);
391405 return (
392406 <button
393407 key={thread.id}
......@@ -412,6 +426,18 @@ export default function Sidebar() {
412426 }}
413427 >
414428 <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 )}
415441 {threadStatus && (
416442 <span
417443 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
apps/web/src/persistenceSchema.test.ts+1
......@@ -278,6 +278,7 @@ describe("toPersistedState", () => {
278278 terminalOpen: true,
279279 terminalHeight: 320,
280280 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
281 runningTerminalIds: [],
281282 activeTerminalId: "term-2",
282283 terminalGroups: [
283284 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
apps/web/src/persistenceSchema.ts+1
......@@ -243,6 +243,7 @@ function hydrateThread(
243243 terminalOpen: thread.terminalOpen ?? false,
244244 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
245245 terminalIds: safeTerminalIds,
246 runningTerminalIds: [],
246247 activeTerminalId,
247248 terminalGroups: normalizedGroups,
248249 activeTerminalGroupId,
apps/web/src/store.test.ts+78-1
......@@ -1,4 +1,4 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
1import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { type AppState, reducer } from "./store";
......@@ -38,6 +38,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3838 terminalOpen: false,
3939 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
4040 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
41 runningTerminalIds: [],
4142 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
4243 terminalGroups: [
4344 {
......@@ -75,6 +76,27 @@ function makeState(thread: Thread): AppState {
7576 };
7677}
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
78100describe("store reducer thread continuity", () => {
79101 it("stores codexThreadId from UPDATE_SESSION", () => {
80102 const state = makeState(
......@@ -255,6 +277,7 @@ describe("store reducer thread continuity", () => {
255277 const state = makeState(
256278 makeThread({
257279 terminalOpen: true,
280 runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID],
258281 }),
259282 );
260283 const next = reducer(state, {
......@@ -265,6 +288,7 @@ describe("store reducer thread continuity", () => {
265288
266289 expect(next.threads[0]?.terminalOpen).toBe(false);
267290 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
291 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
268292 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
269293 expect(next.threads[0]?.terminalGroups).toEqual([
270294 {
......@@ -274,6 +298,59 @@ describe("store reducer thread continuity", () => {
274298 ]);
275299 });
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
277354 it("backfills codexThreadId from routed provider events", () => {
278355 const state = makeState(makeThread({ codexThreadId: null }));
279356 const next = reducer(state, {
apps/web/src/store.ts+45-1
......@@ -8,7 +8,7 @@ import {
88 useReducer,
99} from "react";
1010
11import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
11import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
......@@ -43,6 +43,7 @@ type Action =
4343 event: ProviderEvent;
4444 activeAssistantItemRef: { current: string | null };
4545 }
46 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
4647 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
4748 | {
4849 type: "PUSH_USER_MESSAGE";
......@@ -149,6 +150,17 @@ function normalizeTerminalIds(terminalIds: string[]): string[] {
149150 return [DEFAULT_THREAD_TERMINAL_ID];
150151}
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
152164function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
153165 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
154166}
......@@ -252,6 +264,7 @@ function normalizeThreadTerminals(thread: Thread): Thread {
252264 return {
253265 ...thread,
254266 terminalIds,
267 runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds),
255268 activeTerminalId,
256269 terminalGroups,
257270 activeTerminalGroupId,
......@@ -269,6 +282,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
269282 ...thread,
270283 terminalOpen: false,
271284 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
285 runningTerminalIds: [],
272286 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
273287 terminalGroups: [
274288 {
......@@ -309,6 +323,7 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
309323 return normalizeThreadTerminals({
310324 ...thread,
311325 terminalIds: remainingTerminalIds,
326 runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId),
312327 activeTerminalId: nextActiveTerminalId,
313328 terminalGroups: nextTerminalGroups,
314329 });
......@@ -650,6 +665,35 @@ export function reducer(state: AppState, action: Action): AppState {
650665 case "TOGGLE_DIFF":
651666 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
653697 case "APPLY_EVENT": {
654698 const { event, activeAssistantItemRef } = action;
655699 const target = findThreadBySessionId(state.threads, event.sessionId);
apps/web/src/types.ts+1
......@@ -48,6 +48,7 @@ export interface Thread {
4848 terminalOpen: boolean;
4949 terminalHeight: number;
5050 terminalIds: string[];
51 runningTerminalIds: string[];
5152 activeTerminalId: string;
5253 terminalGroups: ThreadTerminalGroup[];
5354 activeTerminalGroupId: string;