From 68585d4638b594cc79423ad74dff0ea0608fcb46 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Feb 2026 19:13:49 -0800 Subject: [PATCH] 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 --- apps/web/src/routes/__root.tsx | 21 ++++++-- apps/web/src/store.test.ts | 59 ++++++++++++++++++++++ apps/web/src/store.ts | 35 +++++++++++++ apps/web/src/terminalActivity.test.ts | 71 +++++++++++++++++++++++++++ apps/web/src/terminalActivity.ts | 14 ++++++ 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/terminalActivity.test.ts create mode 100644 apps/web/src/terminalActivity.ts diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 51b3a9b9249633f7e3725bbcbf04d42a6e3d39d7..45bb836049e6bce2e61c7160a45b05c374b34bbe 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,8 +1,5 @@ -import { - Outlet, - createRootRouteWithContext, - type ErrorComponentProps, -} from "@tanstack/react-router"; +import { ThreadId } from "@t3tools/contracts"; +import { Outlet, createRootRouteWithContext, type ErrorComponentProps } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; @@ -13,6 +10,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQue import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { preferredTerminalEditor } from "../terminal-links"; +import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; @@ -170,6 +168,18 @@ function EventRouter() { } void syncSnapshot(); }); + const unsubTerminalEvent = api.terminal.onEvent((event) => { + const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); + if (hasRunningSubprocess === null) { + return; + } + dispatch({ + type: "SET_THREAD_TERMINAL_ACTIVITY", + threadId: ThreadId.makeUnsafe(event.threadId), + terminalId: event.terminalId, + hasRunningSubprocess, + }); + }); const unsubWelcome = onServerWelcome(() => { void syncSnapshot(); }); @@ -218,6 +228,7 @@ function EventRouter() { return () => { disposed = true; unsubDomainEvent(); + unsubTerminalEvent(); unsubWelcome(); unsubServerConfigUpdated(); }; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 0de9bfc2660e692cb5f29d18493ec37a85c6f9c8..4a3a73a3c00437ccfd1707c4227c466c282eacb6 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -92,3 +92,62 @@ describe("store reducer", () => { expect(next).toEqual(initialState); }); }); + +describe("store terminal activity reducer", () => { + it("adds a terminal to runningTerminalIds when subprocess activity starts", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], + }, + ], + }), + ); + const next = reducer(state, { + type: "SET_THREAD_TERMINAL_ACTIVITY", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "alt", + hasRunningSubprocess: true, + }); + + expect(next.threads[0]?.runningTerminalIds).toEqual(["alt"]); + }); + + it("removes a terminal from runningTerminalIds when subprocess activity stops", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], + }, + ], + runningTerminalIds: ["alt"], + }), + ); + const next = reducer(state, { + type: "SET_THREAD_TERMINAL_ACTIVITY", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "alt", + hasRunningSubprocess: false, + }); + + expect(next.threads[0]?.runningTerminalIds).toEqual([]); + }); + + it("ignores activity events for unknown terminal ids", () => { + const state = makeState(makeThread()); + const next = reducer(state, { + type: "SET_THREAD_TERMINAL_ACTIVITY", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "missing", + hasRunningSubprocess: true, + }); + + expect(next.threads[0]?.runningTerminalIds).toEqual([]); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9dcfc446992073a334f6196b18c73d0ee49eeef3..89b0106037366d644bf52aab0e32ca31ead3fb84 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -35,6 +35,12 @@ type Action = | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } + | { + type: "SET_THREAD_TERMINAL_ACTIVITY"; + threadId: ThreadId; + terminalId: string; + hasRunningSubprocess: boolean; + } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } @@ -370,6 +376,27 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { }); } +function setThreadTerminalActivity( + thread: Thread, + terminalId: string, + hasRunningSubprocess: boolean, +): Thread { + const normalizedThread = normalizeThreadTerminals(thread); + if (!normalizedThread.terminalIds.includes(terminalId)) { + return normalizedThread; + } + const runningTerminalIds = new Set(normalizedThread.runningTerminalIds); + if (hasRunningSubprocess) { + runningTerminalIds.add(terminalId); + } else { + runningTerminalIds.delete(terminalId); + } + return normalizeThreadTerminals({ + ...normalizedThread, + runningTerminalIds: [...runningTerminalIds], + }); +} + // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { @@ -536,6 +563,14 @@ export function reducer(state: AppState, action: Action): AppState { ), }; + case "SET_THREAD_TERMINAL_ACTIVITY": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => + setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess), + ), + }; + case "TOGGLE_THREAD_TERMINAL": return { ...state, diff --git a/apps/web/src/terminalActivity.test.ts b/apps/web/src/terminalActivity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fda32626641dea1efd3bc29ba0e42582ea0b0e7 --- /dev/null +++ b/apps/web/src/terminalActivity.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts"; +import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; + +const snapshot: TerminalSessionSnapshot = { + threadId: "thread-1", + terminalId: "default", + cwd: "/tmp", + status: "running", + pid: 1234, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +function eventBase() { + return { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("terminalRunningSubprocessFromEvent", () => { + it("returns the subprocess flag for terminal activity events", () => { + const active = terminalRunningSubprocessFromEvent({ + ...eventBase(), + type: "activity", + hasRunningSubprocess: true, + }); + const idle = terminalRunningSubprocessFromEvent({ + ...eventBase(), + type: "activity", + hasRunningSubprocess: false, + }); + + expect(active).toBe(true); + expect(idle).toBe(false); + }); + + it("clears running state when a terminal session starts/restarts/exits", () => { + const events: TerminalEvent[] = [ + { ...eventBase(), type: "started", snapshot }, + { ...eventBase(), type: "restarted", snapshot }, + { ...eventBase(), type: "exited", exitCode: 0, exitSignal: null }, + ]; + + for (const event of events) { + expect(terminalRunningSubprocessFromEvent(event)).toBe(false); + } + }); + + it("ignores non-activity terminal events", () => { + expect( + terminalRunningSubprocessFromEvent({ + ...eventBase(), + type: "output", + data: "hello", + }), + ).toBeNull(); + expect( + terminalRunningSubprocessFromEvent({ + ...eventBase(), + type: "error", + message: "oops", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/terminalActivity.ts b/apps/web/src/terminalActivity.ts new file mode 100644 index 0000000000000000000000000000000000000000..24f4984ff53eb22b5cbaab6133eab636a9c4dd9b --- /dev/null +++ b/apps/web/src/terminalActivity.ts @@ -0,0 +1,14 @@ +import type { TerminalEvent } from "@t3tools/contracts"; + +export function terminalRunningSubprocessFromEvent(event: TerminalEvent): boolean | null { + switch (event.type) { + case "activity": + return event.hasRunningSubprocess; + case "started": + case "restarted": + case "exited": + return false; + default: + return null; + } +} -- 2.54.0