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";
1414import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
1515import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1616import { readNativeApi } from "../nativeApi";
17import { useComposerDraftStore } from "../composerDraftStore";
1718import { useStore } from "../store";
1819import { useTerminalStateStore } from "../terminalStateStore";
1920import { preferredTerminalEditor } from "../terminal-links";
2021import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
2122import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
2223import { providerQueryKeys } from "../lib/providerReactQuery";
24import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
2325
2426export const Route = createRootRouteWithContext<{
2527 queryClient: QueryClient;
......@@ -154,9 +156,13 @@ function EventRouter() {
154156 if (disposed) return;
155157 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
156158 syncServerReadModel(snapshot);
157 const activeThreadIds = new Set(
158 snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id),
159 );
159 const draftThreadIds = Object.keys(
160 useComposerDraftStore.getState().draftThreadsByThreadId,
161 ) as ThreadId[];
162 const activeThreadIds = collectActiveTerminalThreadIds({
163 snapshotThreads: snapshot.threads,
164 draftThreadIds,
165 });
160166 removeOrphanedTerminalStates(activeThreadIds);
161167 if (pending) {
162168 pending = false;