authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 19:13:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 19:39:00-08:00
log68585d4638b594cc79423ad74dff0ea0608fcb46
tree994c4abcb6d580fffbeff027db0891f4007039c2
parent23d2a8affcd0b4321d00f5c0434a41e439e7eadd

Track per-terminal subprocess activity in thread state

- subscribe to terminal events in root route and dispatch terminal activity updates - add reducer support to maintain `runningTerminalIds` per thread/terminal - add unit tests for terminal event mapping and store activity state transitions

5 files changed, 195 insertions(+), 5 deletions(-)

apps/web/src/routes/__root.tsx+16-5
......@@ -1,8 +1,5 @@
1import {
2 Outlet,
3 createRootRouteWithContext,
4 type ErrorComponentProps,
5} from "@tanstack/react-router";
1import { ThreadId } from "@t3tools/contracts";
2import { Outlet, createRootRouteWithContext, type ErrorComponentProps } from "@tanstack/react-router";
63import { useEffect, useRef } from "react";
74import { QueryClient, useQueryClient } from "@tanstack/react-query";
85
......@@ -13,6 +10,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQue
1310import { readNativeApi } from "../nativeApi";
1411import { useStore } from "../store";
1512import { preferredTerminalEditor } from "../terminal-links";
13import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
1614import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
1715import { providerQueryKeys } from "../lib/providerReactQuery";
1816
......@@ -170,6 +168,18 @@ function EventRouter() {
170168 }
171169 void syncSnapshot();
172170 });
171 const unsubTerminalEvent = api.terminal.onEvent((event) => {
172 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
173 if (hasRunningSubprocess === null) {
174 return;
175 }
176 dispatch({
177 type: "SET_THREAD_TERMINAL_ACTIVITY",
178 threadId: ThreadId.makeUnsafe(event.threadId),
179 terminalId: event.terminalId,
180 hasRunningSubprocess,
181 });
182 });
173183 const unsubWelcome = onServerWelcome(() => {
174184 void syncSnapshot();
175185 });
......@@ -218,6 +228,7 @@ function EventRouter() {
218228 return () => {
219229 disposed = true;
220230 unsubDomainEvent();
231 unsubTerminalEvent();
221232 unsubWelcome();
222233 unsubServerConfigUpdated();
223234 };
apps/web/src/store.test.ts+59
......@@ -92,3 +92,62 @@ describe("store reducer", () => {
9292 expect(next).toEqual(initialState);
9393 });
9494});
95
96describe("store terminal activity reducer", () => {
97 it("adds a terminal to runningTerminalIds when subprocess activity starts", () => {
98 const state = makeState(
99 makeThread({
100 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
101 terminalGroups: [
102 {
103 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
104 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
105 },
106 ],
107 }),
108 );
109 const next = reducer(state, {
110 type: "SET_THREAD_TERMINAL_ACTIVITY",
111 threadId: ThreadId.makeUnsafe("thread-1"),
112 terminalId: "alt",
113 hasRunningSubprocess: true,
114 });
115
116 expect(next.threads[0]?.runningTerminalIds).toEqual(["alt"]);
117 });
118
119 it("removes a terminal from runningTerminalIds when subprocess activity stops", () => {
120 const state = makeState(
121 makeThread({
122 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
123 terminalGroups: [
124 {
125 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
126 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
127 },
128 ],
129 runningTerminalIds: ["alt"],
130 }),
131 );
132 const next = reducer(state, {
133 type: "SET_THREAD_TERMINAL_ACTIVITY",
134 threadId: ThreadId.makeUnsafe("thread-1"),
135 terminalId: "alt",
136 hasRunningSubprocess: false,
137 });
138
139 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
140 });
141
142 it("ignores activity events for unknown terminal ids", () => {
143 const state = makeState(makeThread());
144 const next = reducer(state, {
145 type: "SET_THREAD_TERMINAL_ACTIVITY",
146 threadId: ThreadId.makeUnsafe("thread-1"),
147 terminalId: "missing",
148 hasRunningSubprocess: true,
149 });
150
151 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
152 });
153});
apps/web/src/store.ts+35
......@@ -35,6 +35,12 @@ type Action =
3535 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
3636 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
3737 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
38 | {
39 type: "SET_THREAD_TERMINAL_ACTIVITY";
40 threadId: ThreadId;
41 terminalId: string;
42 hasRunningSubprocess: boolean;
43 }
3844 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
3945 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
4046 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
......@@ -370,6 +376,27 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
370376 });
371377}
372378
379function setThreadTerminalActivity(
380 thread: Thread,
381 terminalId: string,
382 hasRunningSubprocess: boolean,
383): Thread {
384 const normalizedThread = normalizeThreadTerminals(thread);
385 if (!normalizedThread.terminalIds.includes(terminalId)) {
386 return normalizedThread;
387 }
388 const runningTerminalIds = new Set(normalizedThread.runningTerminalIds);
389 if (hasRunningSubprocess) {
390 runningTerminalIds.add(terminalId);
391 } else {
392 runningTerminalIds.delete(terminalId);
393 }
394 return normalizeThreadTerminals({
395 ...normalizedThread,
396 runningTerminalIds: [...runningTerminalIds],
397 });
398}
399
373400// ── Reducer ──────────────────────────────────────────────────────────
374401
375402export function reducer(state: AppState, action: Action): AppState {
......@@ -536,6 +563,14 @@ export function reducer(state: AppState, action: Action): AppState {
536563 ),
537564 };
538565
566 case "SET_THREAD_TERMINAL_ACTIVITY":
567 return {
568 ...state,
569 threads: updateThread(state.threads, action.threadId, (thread) =>
570 setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess),
571 ),
572 };
573
539574 case "TOGGLE_THREAD_TERMINAL":
540575 return {
541576 ...state,
apps/web/src/terminalActivity.test.ts created+71
......@@ -0,0 +1,71 @@
1import { describe, expect, it } from "vitest";
2
3import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts";
4import { terminalRunningSubprocessFromEvent } from "./terminalActivity";
5
6const snapshot: TerminalSessionSnapshot = {
7 threadId: "thread-1",
8 terminalId: "default",
9 cwd: "/tmp",
10 status: "running",
11 pid: 1234,
12 history: "",
13 exitCode: null,
14 exitSignal: null,
15 updatedAt: "2026-01-01T00:00:00.000Z",
16};
17
18function eventBase() {
19 return {
20 threadId: "thread-1",
21 terminalId: "default",
22 createdAt: "2026-01-01T00:00:00.000Z",
23 };
24}
25
26describe("terminalRunningSubprocessFromEvent", () => {
27 it("returns the subprocess flag for terminal activity events", () => {
28 const active = terminalRunningSubprocessFromEvent({
29 ...eventBase(),
30 type: "activity",
31 hasRunningSubprocess: true,
32 });
33 const idle = terminalRunningSubprocessFromEvent({
34 ...eventBase(),
35 type: "activity",
36 hasRunningSubprocess: false,
37 });
38
39 expect(active).toBe(true);
40 expect(idle).toBe(false);
41 });
42
43 it("clears running state when a terminal session starts/restarts/exits", () => {
44 const events: TerminalEvent[] = [
45 { ...eventBase(), type: "started", snapshot },
46 { ...eventBase(), type: "restarted", snapshot },
47 { ...eventBase(), type: "exited", exitCode: 0, exitSignal: null },
48 ];
49
50 for (const event of events) {
51 expect(terminalRunningSubprocessFromEvent(event)).toBe(false);
52 }
53 });
54
55 it("ignores non-activity terminal events", () => {
56 expect(
57 terminalRunningSubprocessFromEvent({
58 ...eventBase(),
59 type: "output",
60 data: "hello",
61 }),
62 ).toBeNull();
63 expect(
64 terminalRunningSubprocessFromEvent({
65 ...eventBase(),
66 type: "error",
67 message: "oops",
68 }),
69 ).toBeNull();
70 });
71});
apps/web/src/terminalActivity.ts created+14
......@@ -0,0 +1,14 @@
1import type { TerminalEvent } from "@t3tools/contracts";
2
3export function terminalRunningSubprocessFromEvent(event: TerminalEvent): boolean | null {
4 switch (event.type) {
5 case "activity":
6 return event.hasRunningSubprocess;
7 case "started":
8 case "restarted":
9 case "exited":
10 return false;
11 default:
12 return null;
13 }
14}