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 @@...@@ -1,8 +1,5 @@
1import {1import { ThreadId } from "@t3tools/contracts";
2 Outlet,2import { Outlet, createRootRouteWithContext, type ErrorComponentProps } from "@tanstack/react-router";
3 createRootRouteWithContext,
4 type ErrorComponentProps,
5} from "@tanstack/react-router";
6import { useEffect, useRef } from "react";3import { useEffect, useRef } from "react";
7import { QueryClient, useQueryClient } from "@tanstack/react-query";4import { QueryClient, useQueryClient } from "@tanstack/react-query";
85
...@@ -13,6 +10,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQue...@@ -13,6 +10,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQue
13import { readNativeApi } from "../nativeApi";10import { readNativeApi } from "../nativeApi";
14import { useStore } from "../store";11import { useStore } from "../store";
15import { preferredTerminalEditor } from "../terminal-links";12import { preferredTerminalEditor } from "../terminal-links";
13import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
16import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";14import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
17import { providerQueryKeys } from "../lib/providerReactQuery";15import { providerQueryKeys } from "../lib/providerReactQuery";
1816
...@@ -170,6 +168,18 @@ function EventRouter() {...@@ -170,6 +168,18 @@ function EventRouter() {
170 }168 }
171 void syncSnapshot();169 void syncSnapshot();
172 });170 });
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 });
173 const unsubWelcome = onServerWelcome(() => {183 const unsubWelcome = onServerWelcome(() => {
174 void syncSnapshot();184 void syncSnapshot();
175 });185 });
...@@ -218,6 +228,7 @@ function EventRouter() {...@@ -218,6 +228,7 @@ function EventRouter() {
218 return () => {228 return () => {
219 disposed = true;229 disposed = true;
220 unsubDomainEvent();230 unsubDomainEvent();
231 unsubTerminalEvent();
221 unsubWelcome();232 unsubWelcome();
222 unsubServerConfigUpdated();233 unsubServerConfigUpdated();
223 };234 };
apps/web/src/store.test.ts+59
...@@ -92,3 +92,62 @@ describe("store reducer", () => {...@@ -92,3 +92,62 @@ describe("store reducer", () => {
92 expect(next).toEqual(initialState);92 expect(next).toEqual(initialState);
93 });93 });
94});94});
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 =...@@ -35,6 +35,12 @@ type Action =
35 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }35 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
36 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }36 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
37 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }37 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
38 | {
39 type: "SET_THREAD_TERMINAL_ACTIVITY";
40 threadId: ThreadId;
41 terminalId: string;
42 hasRunningSubprocess: boolean;
43 }
38 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }44 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
39 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }45 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
40 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }46 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
...@@ -370,6 +376,27 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {...@@ -370,6 +376,27 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
370 });376 });
371}377}
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
373// ── Reducer ──────────────────────────────────────────────────────────400// ── Reducer ──────────────────────────────────────────────────────────
374401
375export function reducer(state: AppState, action: Action): AppState {402export function reducer(state: AppState, action: Action): AppState {
...@@ -536,6 +563,14 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -536,6 +563,14 @@ export function reducer(state: AppState, action: Action): AppState {
536 ),563 ),
537 };564 };
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
539 case "TOGGLE_THREAD_TERMINAL":574 case "TOGGLE_THREAD_TERMINAL":
540 return {575 return {
541 ...state,576 ...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}