authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 23:20:56-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:41:23-08:00
logb9e529e0b56b995728ff10cd1b643df63b436258
tree939724f709116797d44f4bf78c0d8d458cc88139
parent0f702fcd6271859212f83bc7f7100a339afe95da

Preserve draft terminal state during snapshot cleanup

- add `collectActiveTerminalThreadIds` to keep non-deleted server threads plus local draft threads - use helper in root event router before orphan terminal state removal - add unit tests for retained and deleted thread behavior

3 files changed, 66 insertions(+), 3 deletions(-)

apps/web/src/lib/terminalStateCleanup.test.ts created+32
...@@ -0,0 +1,32 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";
5
6const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id);
7
8describe("collectActiveTerminalThreadIds", () => {
9 it("retains non-deleted server threads", () => {
10 const activeThreadIds = collectActiveTerminalThreadIds({
11 snapshotThreads: [
12 { id: threadId("server-1"), deletedAt: null },
13 { id: threadId("server-2"), deletedAt: null },
14 ],
15 draftThreadIds: [],
16 });
17
18 expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")]));
19 });
20
21 it("ignores deleted server threads and keeps local draft threads", () => {
22 const activeThreadIds = collectActiveTerminalThreadIds({
23 snapshotThreads: [
24 { id: threadId("server-active"), deletedAt: null },
25 { id: threadId("server-deleted"), deletedAt: "2026-03-05T08:00:00.000Z" },
26 ],
27 draftThreadIds: [threadId("local-draft")],
28 });
29
30 expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")]));
31 });
32});
apps/web/src/lib/terminalStateCleanup.ts created+25
...@@ -0,0 +1,25 @@
1import type { ThreadId } from "@t3tools/contracts";
2
3interface TerminalRetentionThread {
4 id: ThreadId;
5 deletedAt: string | null;
6}
7
8interface CollectActiveTerminalThreadIdsInput {
9 snapshotThreads: readonly TerminalRetentionThread[];
10 draftThreadIds: Iterable<ThreadId>;
11}
12
13export function collectActiveTerminalThreadIds(
14 input: CollectActiveTerminalThreadIdsInput,
15): Set<ThreadId> {
16 const activeThreadIds = new Set<ThreadId>();
17 for (const thread of input.snapshotThreads) {
18 if (thread.deletedAt !== null) continue;
19 activeThreadIds.add(thread.id);
20 }
21 for (const draftThreadId of input.draftThreadIds) {
22 activeThreadIds.add(draftThreadId);
23 }
24 return activeThreadIds;
25}
apps/web/src/routes/__root.tsx+9-3
...@@ -14,12 +14,14 @@ import { Button } from "../components/ui/button";...@@ -14,12 +14,14 @@ import { Button } from "../components/ui/button";
14import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";14import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
15import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";15import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
16import { readNativeApi } from "../nativeApi";16import { readNativeApi } from "../nativeApi";
17import { useComposerDraftStore } from "../composerDraftStore";
17import { useStore } from "../store";18import { useStore } from "../store";
18import { useTerminalStateStore } from "../terminalStateStore";19import { useTerminalStateStore } from "../terminalStateStore";
19import { preferredTerminalEditor } from "../terminal-links";20import { preferredTerminalEditor } from "../terminal-links";
20import { terminalRunningSubprocessFromEvent } from "../terminalActivity";21import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
21import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";22import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
22import { providerQueryKeys } from "../lib/providerReactQuery";23import { providerQueryKeys } from "../lib/providerReactQuery";
24import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
2325
24export const Route = createRootRouteWithContext<{26export const Route = createRootRouteWithContext<{
25 queryClient: QueryClient;27 queryClient: QueryClient;
...@@ -154,9 +156,13 @@ function EventRouter() {...@@ -154,9 +156,13 @@ function EventRouter() {
154 if (disposed) return;156 if (disposed) return;
155 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);157 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
156 syncServerReadModel(snapshot);158 syncServerReadModel(snapshot);
157 const activeThreadIds = new Set(159 const draftThreadIds = Object.keys(
158 snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id),160 useComposerDraftStore.getState().draftThreadsByThreadId,
159 );161 ) as ThreadId[];
162 const activeThreadIds = collectActiveTerminalThreadIds({
163 snapshotThreads: snapshot.threads,
164 draftThreadIds,
165 });
160 removeOrphanedTerminalStates(activeThreadIds);166 removeOrphanedTerminalStates(activeThreadIds);
161 if (pending) {167 if (pending) {
162 pending = false;168 pending = false;