authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-30 12:51:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-30 12:51:49-07:00
log0ea639196a7bed94deb08f5c96d147418f4e9749
tree59062ffa89347de7fb4df33d480b20c29536bd6a
parent6f517257325f7c8bcac660595f2cbc53c1d32b35
signature Signed by PGP key B5690EEEBB952194

Refactor web orchestration sync to incremental events and isolated recovery (#1560)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

29 files changed, 3609 insertions(+), 668 deletions(-)

apps/web/src/components/ChatView.browser.tsx+88-15
......@@ -2,8 +2,11 @@
22import "../index.css";
33
44import {
5 EventId,
56 ORCHESTRATION_WS_METHODS,
7 ORCHESTRATION_WS_CHANNELS,
68 type MessageId,
9 type OrchestrationEvent,
710 type OrchestrationReadModel,
811 type ProjectId,
912 type ServerConfig,
......@@ -57,6 +60,8 @@ interface TestFixture {
5760let fixture: TestFixture;
5861const wsRequests: WsRequestEnvelope["body"][] = [];
5962let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
63let wsClient: { send: (message: string) => void } | null = null;
64let pushSequence = 1;
6065const wsLink = ws.link(/ws(s)?:\/\/.*/);
6166
6267interface ViewportSpec {
......@@ -336,6 +341,79 @@ function addThreadToSnapshot(
336341 };
337342}
338343
344function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent {
345 return {
346 sequence,
347 eventId: EventId.makeUnsafe(`event-thread-created-${sequence}`),
348 aggregateKind: "thread",
349 aggregateId: threadId,
350 occurredAt: NOW_ISO,
351 commandId: null,
352 causationEventId: null,
353 correlationId: null,
354 metadata: {},
355 type: "thread.created",
356 payload: {
357 threadId,
358 projectId: PROJECT_ID,
359 title: "New thread",
360 modelSelection: {
361 provider: "codex",
362 model: "gpt-5",
363 },
364 runtimeMode: "full-access",
365 interactionMode: "default",
366 branch: "main",
367 worktreePath: null,
368 createdAt: NOW_ISO,
369 updatedAt: NOW_ISO,
370 },
371 };
372}
373
374function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
375 if (!wsClient) {
376 throw new Error("WebSocket client not connected");
377 }
378 wsClient.send(
379 JSON.stringify({
380 type: "push",
381 sequence: pushSequence++,
382 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
383 data: event,
384 }),
385 );
386}
387
388async function waitForWsClient(): Promise<{ send: (message: string) => void }> {
389 let client: { send: (message: string) => void } | null = null;
390 await vi.waitFor(
391 () => {
392 client = wsClient;
393 expect(client).toBeTruthy();
394 },
395 { timeout: 8_000, interval: 16 },
396 );
397 if (!client) {
398 throw new Error("WebSocket client not connected");
399 }
400 return client;
401}
402
403async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
404 await waitForWsClient();
405 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
406 sendOrchestrationDomainEvent(
407 createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),
408 );
409 await vi.waitFor(
410 () => {
411 expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();
412 },
413 { timeout: 8_000, interval: 16 },
414 );
415}
416
339417function createDraftOnlySnapshot(): OrchestrationReadModel {
340418 const snapshot = createSnapshotForTargetUser({
341419 targetMessageId: "msg-user-draft-target" as MessageId,
......@@ -500,10 +578,12 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
500578
501579const worker = setupWorker(
502580 wsLink.addEventListener("connection", ({ client }) => {
581 wsClient = client;
582 pushSequence = 1;
503583 client.send(
504584 JSON.stringify({
505585 type: "push",
506 sequence: 1,
586 sequence: pushSequence++,
507587 channel: WS_CHANNELS.serverWelcome,
508588 data: fixture.welcome,
509589 }),
......@@ -875,7 +955,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
875955 useStore.setState({
876956 projects: [],
877957 threads: [],
878 threadsHydrated: false,
958 bootstrapComplete: false,
879959 });
880960 });
881961
......@@ -1881,21 +1961,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
18811961 // The composer editor should be present for the new draft thread.
18821962 await waitForComposerEditor();
18831963
1884 // Simulate the snapshot sync arriving from the server after the draft
1885 // thread has been promoted to a server thread (thread.create + turn.start
1886 // succeeded). The snapshot now includes the new thread, and the sync
1887 // should clear the draft without disrupting the route.
1888 const { syncServerReadModel } = useStore.getState();
1889 syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId));
1890
1891 // Clear the draft now that the server thread exists (mirrors EventRouter behavior).
1892 useComposerDraftStore.getState().clearDraftThread(newThreadId);
1964 // Simulate the steady-state promotion path: the server emits
1965 // `thread.created`, the client materializes the thread incrementally,
1966 // and the draft is cleared by live batch effects.
1967 await promoteDraftThreadViaDomainEvent(newThreadId);
18931968
18941969 // The route should still be on the new thread — not redirected away.
18951970 await waitForURL(
18961971 mounted.router,
18971972 (path) => path === newThreadPath,
1898 "New thread should remain selected after snapshot sync clears the draft.",
1973 "New thread should remain selected after server thread promotion clears the draft.",
18991974 );
19001975
19011976 // The empty thread view and composer should still be visible.
......@@ -2217,9 +2292,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
22172292 );
22182293 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;
22192294
2220 const { syncServerReadModel } = useStore.getState();
2221 syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
2222 useComposerDraftStore.getState().clearDraftThread(promotedThreadId);
2295 await promoteDraftThreadViaDomainEvent(promotedThreadId);
22232296
22242297 const freshThreadPath = await triggerChatNewShortcutUntilPath(
22252298 mounted.router,
apps/web/src/components/ChatView.logic.test.ts+297-3
......@@ -1,7 +1,14 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";
3import { useStore } from "../store";
34
4import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
5import {
6 buildExpiredTerminalContextToastCopy,
7 createLocalDispatchSnapshot,
8 deriveComposerSendState,
9 hasServerAcknowledgedLocalDispatch,
10 waitForStartedServerThread,
11} from "./ChatView.logic";
512
613describe("deriveComposerSendState", () => {
714 it("treats expired terminal pills as non-sendable content", () => {
......@@ -67,3 +74,290 @@ describe("buildExpiredTerminalContextToastCopy", () => {
6774 });
6875 });
6976});
77
78const makeThread = (input?: {
79 id?: ThreadId;
80 latestTurn?: {
81 turnId: TurnId;
82 state: "running" | "completed";
83 requestedAt: string;
84 startedAt: string | null;
85 completedAt: string | null;
86 } | null;
87}) => ({
88 id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
89 codexThreadId: null,
90 projectId: ProjectId.makeUnsafe("project-1"),
91 title: "Thread",
92 modelSelection: { provider: "codex" as const, model: "gpt-5.4" },
93 runtimeMode: "full-access" as const,
94 interactionMode: "default" as const,
95 session: null,
96 messages: [],
97 proposedPlans: [],
98 error: null,
99 createdAt: "2026-03-29T00:00:00.000Z",
100 archivedAt: null,
101 updatedAt: "2026-03-29T00:00:00.000Z",
102 latestTurn: input?.latestTurn
103 ? {
104 ...input.latestTurn,
105 assistantMessageId: null,
106 }
107 : null,
108 branch: null,
109 worktreePath: null,
110 turnDiffSummaries: [],
111 activities: [],
112});
113
114afterEach(() => {
115 vi.useRealTimers();
116 vi.restoreAllMocks();
117 useStore.setState((state) => ({
118 ...state,
119 projects: [],
120 threads: [],
121 bootstrapComplete: true,
122 }));
123});
124
125describe("waitForStartedServerThread", () => {
126 it("resolves immediately when the thread is already started", async () => {
127 const threadId = ThreadId.makeUnsafe("thread-started");
128 useStore.setState((state) => ({
129 ...state,
130 threads: [
131 makeThread({
132 id: threadId,
133 latestTurn: {
134 turnId: TurnId.makeUnsafe("turn-started"),
135 state: "running",
136 requestedAt: "2026-03-29T00:00:01.000Z",
137 startedAt: "2026-03-29T00:00:01.000Z",
138 completedAt: null,
139 },
140 }),
141 ],
142 }));
143
144 await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);
145 });
146
147 it("waits for the thread to start via subscription updates", async () => {
148 const threadId = ThreadId.makeUnsafe("thread-wait");
149 useStore.setState((state) => ({
150 ...state,
151 threads: [makeThread({ id: threadId })],
152 }));
153
154 const promise = waitForStartedServerThread(threadId, 500);
155
156 useStore.setState((state) => ({
157 ...state,
158 threads: [
159 makeThread({
160 id: threadId,
161 latestTurn: {
162 turnId: TurnId.makeUnsafe("turn-started"),
163 state: "running",
164 requestedAt: "2026-03-29T00:00:01.000Z",
165 startedAt: "2026-03-29T00:00:01.000Z",
166 completedAt: null,
167 },
168 }),
169 ],
170 }));
171
172 await expect(promise).resolves.toBe(true);
173 });
174
175 it("handles the thread starting between the initial read and subscription setup", async () => {
176 const threadId = ThreadId.makeUnsafe("thread-race");
177 useStore.setState((state) => ({
178 ...state,
179 threads: [makeThread({ id: threadId })],
180 }));
181
182 const originalSubscribe = useStore.subscribe.bind(useStore);
183 let raced = false;
184 vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {
185 if (!raced) {
186 raced = true;
187 useStore.setState((state) => ({
188 ...state,
189 threads: [
190 makeThread({
191 id: threadId,
192 latestTurn: {
193 turnId: TurnId.makeUnsafe("turn-race"),
194 state: "running",
195 requestedAt: "2026-03-29T00:00:01.000Z",
196 startedAt: "2026-03-29T00:00:01.000Z",
197 completedAt: null,
198 },
199 }),
200 ],
201 }));
202 }
203 return originalSubscribe(listener);
204 });
205
206 await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true);
207 });
208
209 it("returns false after the timeout when the thread never starts", async () => {
210 vi.useFakeTimers();
211
212 const threadId = ThreadId.makeUnsafe("thread-timeout");
213 useStore.setState((state) => ({
214 ...state,
215 threads: [makeThread({ id: threadId })],
216 }));
217 const promise = waitForStartedServerThread(threadId, 500);
218
219 await vi.advanceTimersByTimeAsync(500);
220
221 await expect(promise).resolves.toBe(false);
222 });
223});
224
225describe("hasServerAcknowledgedLocalDispatch", () => {
226 const projectId = ProjectId.makeUnsafe("project-1");
227 const previousLatestTurn = {
228 turnId: TurnId.makeUnsafe("turn-1"),
229 state: "completed" as const,
230 requestedAt: "2026-03-29T00:00:00.000Z",
231 startedAt: "2026-03-29T00:00:01.000Z",
232 completedAt: "2026-03-29T00:00:10.000Z",
233 assistantMessageId: null,
234 };
235
236 const previousSession = {
237 provider: "codex" as const,
238 status: "ready" as const,
239 createdAt: "2026-03-29T00:00:00.000Z",
240 updatedAt: "2026-03-29T00:00:10.000Z",
241 orchestrationStatus: "idle" as const,
242 };
243
244 it("does not clear local dispatch before server state changes", () => {
245 const localDispatch = createLocalDispatchSnapshot({
246 id: ThreadId.makeUnsafe("thread-1"),
247 codexThreadId: null,
248 projectId,
249 title: "Thread",
250 modelSelection: { provider: "codex", model: "gpt-5.4" },
251 runtimeMode: "full-access",
252 interactionMode: "default",
253 session: previousSession,
254 messages: [],
255 proposedPlans: [],
256 error: null,
257 createdAt: "2026-03-29T00:00:00.000Z",
258 archivedAt: null,
259 updatedAt: "2026-03-29T00:00:10.000Z",
260 latestTurn: previousLatestTurn,
261 branch: null,
262 worktreePath: null,
263 turnDiffSummaries: [],
264 activities: [],
265 });
266
267 expect(
268 hasServerAcknowledgedLocalDispatch({
269 localDispatch,
270 phase: "ready",
271 latestTurn: previousLatestTurn,
272 session: previousSession,
273 hasPendingApproval: false,
274 hasPendingUserInput: false,
275 threadError: null,
276 }),
277 ).toBe(false);
278 });
279
280 it("clears local dispatch when a new turn is already settled", () => {
281 const localDispatch = createLocalDispatchSnapshot({
282 id: ThreadId.makeUnsafe("thread-1"),
283 codexThreadId: null,
284 projectId,
285 title: "Thread",
286 modelSelection: { provider: "codex", model: "gpt-5.4" },
287 runtimeMode: "full-access",
288 interactionMode: "default",
289 session: previousSession,
290 messages: [],
291 proposedPlans: [],
292 error: null,
293 createdAt: "2026-03-29T00:00:00.000Z",
294 archivedAt: null,
295 updatedAt: "2026-03-29T00:00:10.000Z",
296 latestTurn: previousLatestTurn,
297 branch: null,
298 worktreePath: null,
299 turnDiffSummaries: [],
300 activities: [],
301 });
302
303 expect(
304 hasServerAcknowledgedLocalDispatch({
305 localDispatch,
306 phase: "ready",
307 latestTurn: {
308 ...previousLatestTurn,
309 turnId: TurnId.makeUnsafe("turn-2"),
310 requestedAt: "2026-03-29T00:01:00.000Z",
311 startedAt: "2026-03-29T00:01:01.000Z",
312 completedAt: "2026-03-29T00:01:30.000Z",
313 },
314 session: {
315 ...previousSession,
316 updatedAt: "2026-03-29T00:01:30.000Z",
317 },
318 hasPendingApproval: false,
319 hasPendingUserInput: false,
320 threadError: null,
321 }),
322 ).toBe(true);
323 });
324
325 it("clears local dispatch when the session changes without an observed running phase", () => {
326 const localDispatch = createLocalDispatchSnapshot({
327 id: ThreadId.makeUnsafe("thread-1"),
328 codexThreadId: null,
329 projectId,
330 title: "Thread",
331 modelSelection: { provider: "codex", model: "gpt-5.4" },
332 runtimeMode: "full-access",
333 interactionMode: "default",
334 session: previousSession,
335 messages: [],
336 proposedPlans: [],
337 error: null,
338 createdAt: "2026-03-29T00:00:00.000Z",
339 archivedAt: null,
340 updatedAt: "2026-03-29T00:00:10.000Z",
341 latestTurn: previousLatestTurn,
342 branch: null,
343 worktreePath: null,
344 turnDiffSummaries: [],
345 activities: [],
346 });
347
348 expect(
349 hasServerAcknowledgedLocalDispatch({
350 localDispatch,
351 phase: "ready",
352 latestTurn: previousLatestTurn,
353 session: {
354 ...previousSession,
355 updatedAt: "2026-03-29T00:00:11.000Z",
356 },
357 hasPendingApproval: false,
358 hasPendingUserInput: false,
359 threadError: null,
360 }),
361 ).toBe(true);
362 });
363});
apps/web/src/components/ChatView.logic.ts+116-5
......@@ -1,8 +1,9 @@
1import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts";
2import { type ChatMessage, type Thread } from "../types";
1import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts";
2import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
33import { randomUUID } from "~/lib/utils";
44import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
55import { Schema } from "effect";
6import { useStore } from "../store";
67import {
78 filterTerminalContextsWithText,
89 stripInlineTerminalContextPlaceholders,
......@@ -34,7 +35,6 @@ export function buildLocalDraftThread(
3435 createdAt: draftThread.createdAt,
3536 archivedAt: null,
3637 latestTurn: null,
37 lastVisitedAt: draftThread.createdAt,
3838 branch: draftThread.branch,
3939 worktreePath: draftThread.worktreePath,
4040 turnDiffSummaries: [],
......@@ -75,8 +75,6 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[
7575 return previewUrls;
7676}
7777
78export type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
79
8078export interface PullRequestDialogState {
8179 initialReference: string | null;
8280 key: number;
......@@ -161,3 +159,116 @@ export function buildExpiredTerminalContextToastCopy(
161159 description: "Re-add it if you want that terminal output included.",
162160 };
163161}
162
163export function threadHasStarted(thread: Thread | null | undefined): boolean {
164 return Boolean(
165 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
166 );
167}
168
169export async function waitForStartedServerThread(
170 threadId: ThreadId,
171 timeoutMs = 1_000,
172): Promise<boolean> {
173 const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId);
174 const thread = getThread();
175
176 if (threadHasStarted(thread)) {
177 return true;
178 }
179
180 return await new Promise<boolean>((resolve) => {
181 let settled = false;
182 let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
183 const finish = (result: boolean) => {
184 if (settled) {
185 return;
186 }
187 settled = true;
188 if (timeoutId !== null) {
189 globalThis.clearTimeout(timeoutId);
190 }
191 unsubscribe();
192 resolve(result);
193 };
194
195 const unsubscribe = useStore.subscribe((state) => {
196 if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) {
197 return;
198 }
199 finish(true);
200 });
201
202 if (threadHasStarted(getThread())) {
203 finish(true);
204 return;
205 }
206
207 timeoutId = globalThis.setTimeout(() => {
208 finish(false);
209 }, timeoutMs);
210 });
211}
212
213export interface LocalDispatchSnapshot {
214 startedAt: string;
215 preparingWorktree: boolean;
216 latestTurnTurnId: TurnId | null;
217 latestTurnRequestedAt: string | null;
218 latestTurnStartedAt: string | null;
219 latestTurnCompletedAt: string | null;
220 sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null;
221 sessionUpdatedAt: string | null;
222}
223
224export function createLocalDispatchSnapshot(
225 activeThread: Thread | undefined,
226 options?: { preparingWorktree?: boolean },
227): LocalDispatchSnapshot {
228 const latestTurn = activeThread?.latestTurn ?? null;
229 const session = activeThread?.session ?? null;
230 return {
231 startedAt: new Date().toISOString(),
232 preparingWorktree: Boolean(options?.preparingWorktree),
233 latestTurnTurnId: latestTurn?.turnId ?? null,
234 latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
235 latestTurnStartedAt: latestTurn?.startedAt ?? null,
236 latestTurnCompletedAt: latestTurn?.completedAt ?? null,
237 sessionOrchestrationStatus: session?.orchestrationStatus ?? null,
238 sessionUpdatedAt: session?.updatedAt ?? null,
239 };
240}
241
242export function hasServerAcknowledgedLocalDispatch(input: {
243 localDispatch: LocalDispatchSnapshot | null;
244 phase: SessionPhase;
245 latestTurn: Thread["latestTurn"] | null;
246 session: Thread["session"] | null;
247 hasPendingApproval: boolean;
248 hasPendingUserInput: boolean;
249 threadError: string | null | undefined;
250}): boolean {
251 if (!input.localDispatch) {
252 return false;
253 }
254 if (
255 input.phase === "running" ||
256 input.hasPendingApproval ||
257 input.hasPendingUserInput ||
258 Boolean(input.threadError)
259 ) {
260 return true;
261 }
262
263 const latestTurn = input.latestTurn ?? null;
264 const session = input.session ?? null;
265
266 return (
267 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
268 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
269 input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
270 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) ||
271 input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||
272 input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
273 );
274}
apps/web/src/components/ChatView.tsx+227-119
......@@ -20,6 +20,7 @@ import {
2020 OrchestrationThreadActivity,
2121 ProviderInteractionMode,
2222 RuntimeMode,
23 TerminalOpenInput,
2324} from "@t3tools/contracts";
2425import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
2526import { truncate } from "@t3tools/shared/String";
......@@ -42,6 +43,7 @@ import {
4243 replaceTextRange,
4344} from "../composer-logic";
4445import {
46 deriveCompletionDividerBeforeEntryId,
4547 derivePendingApprovals,
4648 derivePendingUserInputs,
4749 derivePhase,
......@@ -64,6 +66,8 @@ import {
6466 type PendingUserInputDraftAnswer,
6567} from "../pendingUserInput";
6668import { useStore } from "../store";
69import { useProjectById, useThreadById } from "../storeSelectors";
70import { useUiStateStore } from "../uiStateStore";
6771import {
6872 buildPlanImplementationThreadTitle,
6973 buildPlanImplementationPrompt,
......@@ -76,8 +80,12 @@ import {
7680 DEFAULT_THREAD_TERMINAL_ID,
7781 MAX_TERMINALS_PER_GROUP,
7882 type ChatMessage,
83 type SessionPhase,
84 type Thread,
7985 type TurnDiffSummary,
8086} from "../types";
87import { LRUCache } from "../lib/lruCache";
88
8189import { basenameOfPath } from "../vscode-icons";
8290import { useTheme } from "../hooks/useTheme";
8391import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
......@@ -168,14 +176,18 @@ import {
168176 buildTemporaryWorktreeBranchName,
169177 cloneComposerImageForRetry,
170178 collectUserMessageBlobPreviewUrls,
179 createLocalDispatchSnapshot,
171180 deriveComposerSendState,
181 hasServerAcknowledgedLocalDispatch,
172182 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
173183 LastInvokedScriptByProjectSchema,
184 type LocalDispatchSnapshot,
174185 PullRequestDialogState,
175186 readFileAsDataUrl,
176187 revokeBlobPreviewUrl,
177188 revokeUserMessagePreviewUrls,
178 SendPhase,
189 threadHasStarted,
190 waitForStartedServerThread,
179191} from "./ChatView.logic";
180192import { useLocalStorage } from "~/hooks/useLocalStorage";
181193
......@@ -190,6 +202,81 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
190202const EMPTY_PROVIDERS: ServerProvider[] = [];
191203const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
192204
205type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
206
207const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500;
208const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024;
209const threadPlanCatalogCache = new LRUCache<{
210 proposedPlans: Thread["proposedPlans"];
211 entry: ThreadPlanCatalogEntry;
212}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES);
213
214function estimateThreadPlanCatalogEntrySize(thread: Thread): number {
215 return Math.max(
216 64,
217 thread.id.length +
218 thread.proposedPlans.reduce(
219 (total, plan) =>
220 total +
221 plan.id.length +
222 plan.planMarkdown.length +
223 plan.updatedAt.length +
224 (plan.turnId?.length ?? 0),
225 0,
226 ),
227 );
228}
229
230function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry {
231 const cached = threadPlanCatalogCache.get(thread.id);
232 if (cached && cached.proposedPlans === thread.proposedPlans) {
233 return cached.entry;
234 }
235
236 const entry: ThreadPlanCatalogEntry = {
237 id: thread.id,
238 proposedPlans: thread.proposedPlans,
239 };
240 threadPlanCatalogCache.set(
241 thread.id,
242 {
243 proposedPlans: thread.proposedPlans,
244 entry,
245 },
246 estimateThreadPlanCatalogEntrySize(thread),
247 );
248 return entry;
249}
250
251function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
252 const selector = useMemo(() => {
253 let previousThreads: Array<Thread | undefined> | null = null;
254 let previousEntries: ThreadPlanCatalogEntry[] = [];
255
256 return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => {
257 const nextThreads = threadIds.map((threadId) =>
258 state.threads.find((thread) => thread.id === threadId),
259 );
260 const cachedThreads = previousThreads;
261 if (
262 cachedThreads &&
263 nextThreads.length === cachedThreads.length &&
264 nextThreads.every((thread, index) => thread === cachedThreads[index])
265 ) {
266 return previousEntries;
267 }
268
269 previousThreads = nextThreads;
270 previousEntries = nextThreads.flatMap((thread) =>
271 thread ? [toThreadPlanCatalogEntry(thread)] : [],
272 );
273 return previousEntries;
274 };
275 }, [threadIds]);
276
277 return useStore(selector);
278}
279
193280function formatOutgoingPrompt(params: {
194281 provider: ProviderKind;
195282 model: string | null;
......@@ -245,13 +332,81 @@ interface PendingPullRequestSetupRequest {
245332 scriptId: string;
246333}
247334
335function useLocalDispatchState(input: {
336 activeThread: Thread | undefined;
337 activeLatestTurn: Thread["latestTurn"] | null;
338 phase: SessionPhase;
339 activePendingApproval: ApprovalRequestId | null;
340 activePendingUserInput: ApprovalRequestId | null;
341 threadError: string | null | undefined;
342}) {
343 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
344
345 const beginLocalDispatch = useCallback(
346 (options?: { preparingWorktree?: boolean }) => {
347 const preparingWorktree = Boolean(options?.preparingWorktree);
348 setLocalDispatch((current) => {
349 if (current) {
350 return current.preparingWorktree === preparingWorktree
351 ? current
352 : { ...current, preparingWorktree };
353 }
354 return createLocalDispatchSnapshot(input.activeThread, options);
355 });
356 },
357 [input.activeThread],
358 );
359
360 const resetLocalDispatch = useCallback(() => {
361 setLocalDispatch(null);
362 }, []);
363
364 const serverAcknowledgedLocalDispatch = useMemo(
365 () =>
366 hasServerAcknowledgedLocalDispatch({
367 localDispatch,
368 phase: input.phase,
369 latestTurn: input.activeLatestTurn,
370 session: input.activeThread?.session ?? null,
371 hasPendingApproval: input.activePendingApproval !== null,
372 hasPendingUserInput: input.activePendingUserInput !== null,
373 threadError: input.threadError,
374 }),
375 [
376 input.activeLatestTurn,
377 input.activePendingApproval,
378 input.activePendingUserInput,
379 input.activeThread?.session,
380 input.phase,
381 input.threadError,
382 localDispatch,
383 ],
384 );
385
386 useEffect(() => {
387 if (!serverAcknowledgedLocalDispatch) {
388 return;
389 }
390 resetLocalDispatch();
391 }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]);
392
393 return {
394 beginLocalDispatch,
395 resetLocalDispatch,
396 localDispatchStartedAt: localDispatch?.startedAt ?? null,
397 isPreparingWorktree: localDispatch?.preparingWorktree ?? false,
398 isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch,
399 };
400}
401
248402export default function ChatView({ threadId }: ChatViewProps) {
249 const threads = useStore((store) => store.threads);
250 const projects = useStore((store) => store.projects);
251 const markThreadVisited = useStore((store) => store.markThreadVisited);
252 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
403 const serverThread = useThreadById(threadId);
253404 const setStoreThreadError = useStore((store) => store.setError);
254405 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
406 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
407 const activeThreadLastVisitedAt = useUiStateStore(
408 (store) => store.threadLastVisitedAtById[threadId],
409 );
255410 const settings = useSettings();
256411 const setStickyComposerModelSelection = useComposerDraftStore(
257412 (store) => store.setStickyModelSelection,
......@@ -330,8 +485,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
330485 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
331486 Record<ThreadId, string | null>
332487 >({});
333 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
334 const [sendStartedAt, setSendStartedAt] = useState<string | null>(null);
335488 const [isConnecting, _setIsConnecting] = useState(false);
336489 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
337490 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
......@@ -466,8 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
466619 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],
467620 );
468621
469 const serverThread = threads.find((t) => t.id === threadId);
470 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
622 const fallbackDraftProject = useProjectById(draftThread?.projectId);
471623 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
472624 const localDraftThread = useMemo(
473625 () =>
......@@ -495,12 +647,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
495647 const diffOpen = rawSearch.diff === "1";
496648 const activeThreadId = activeThread?.id ?? null;
497649 const activeLatestTurn = activeThread?.latestTurn ?? null;
650 const threadPlanCatalog = useThreadPlanCatalog(
651 useMemo(() => {
652 const threadIds: ThreadId[] = [];
653 if (activeThread?.id) {
654 threadIds.push(activeThread.id);
655 }
656 const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId;
657 if (sourceThreadId && sourceThreadId !== activeThread?.id) {
658 threadIds.push(sourceThreadId);
659 }
660 return threadIds;
661 }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]),
662 );
498663 const activeContextWindow = useMemo(
499664 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
500665 [activeThread?.activities],
501666 );
502667 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
503 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
668 const activeProject = useProjectById(activeThread?.projectId);
504669
505670 const openPullRequestDialog = useCallback(
506671 (reference?: string) => {
......@@ -595,33 +760,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
595760 );
596761
597762 useEffect(() => {
598 if (!activeThread?.id) return;
763 if (!serverThread?.id) return;
599764 if (!latestTurnSettled) return;
600765 if (!activeLatestTurn?.completedAt) return;
601766 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);
602767 if (Number.isNaN(turnCompletedAt)) return;
603 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
768 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
604769 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
605770
606 markThreadVisited(activeThread.id);
771 markThreadVisited(serverThread.id);
607772 }, [
608 activeThread?.id,
609 activeThread?.lastVisitedAt,
610773 activeLatestTurn?.completedAt,
774 activeThreadLastVisitedAt,
611775 latestTurnSettled,
612776 markThreadVisited,
777 serverThread?.id,
613778 ]);
614779
615780 const sessionProvider = activeThread?.session?.provider ?? null;
616781 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
617782 const threadProvider =
618783 activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null;
619 const hasThreadStarted = Boolean(
620 activeThread &&
621 (activeThread.latestTurn !== null ||
622 activeThread.messages.length > 0 ||
623 activeThread.session !== null),
624 );
784 const hasThreadStarted = threadHasStarted(activeThread);
625785 const lockedProvider: ProviderKind | null = hasThreadStarted
626786 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
627787 : null;
......@@ -664,15 +824,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
664824 );
665825 const selectedModelForPicker = selectedModel;
666826 const phase = derivePhase(activeThread?.session ?? null);
667 const isSendBusy = sendPhase !== "idle";
668 const isPreparingWorktree = sendPhase === "preparing-worktree";
669 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
670 const nowIso = new Date(nowTick).toISOString();
671 const activeWorkStartedAt = deriveActiveWorkStartedAt(
672 activeLatestTurn,
673 activeThread?.session ?? null,
674 sendStartedAt,
675 );
676827 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
677828 const workLogEntries = useMemo(
678829 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
......@@ -735,12 +886,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
735886 const sidebarProposedPlan = useMemo(
736887 () =>
737888 findSidebarProposedPlan({
738 threads,
889 threads: threadPlanCatalog,
739890 latestTurn: activeLatestTurn,
740891 latestTurnSettled,
741892 threadId: activeThread?.id ?? null,
742893 }),
743 [activeLatestTurn, activeThread?.id, latestTurnSettled, threads],
894 [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog],
744895 );
745896 const activePlan = useMemo(
746897 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
......@@ -752,6 +903,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
752903 latestTurnSettled &&
753904 hasActionableProposedPlan(activeProposedPlan);
754905 const activePendingApproval = pendingApprovals[0] ?? null;
906 const {
907 beginLocalDispatch,
908 resetLocalDispatch,
909 localDispatchStartedAt,
910 isPreparingWorktree,
911 isSendBusy,
912 } = useLocalDispatchState({
913 activeThread,
914 activeLatestTurn,
915 phase,
916 activePendingApproval: activePendingApproval?.requestId ?? null,
917 activePendingUserInput: activePendingUserInput?.requestId ?? null,
918 threadError: activeThread?.error,
919 });
920 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
921 const nowIso = new Date(nowTick).toISOString();
922 const activeWorkStartedAt = deriveActiveWorkStartedAt(
923 activeLatestTurn,
924 activeThread?.session ?? null,
925 localDispatchStartedAt,
926 );
755927 const isComposerApprovalState = activePendingApproval !== null;
756928 const hasComposerHeader =
757929 isComposerApprovalState ||
......@@ -980,35 +1152,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
9801152 ]);
9811153 const completionDividerBeforeEntryId = useMemo(() => {
9821154 if (!latestTurnSettled) return null;
983 if (!activeLatestTurn?.startedAt) return null;
984 if (!activeLatestTurn.completedAt) return null;
9851155 if (!completionSummary) return null;
986
987 const turnStartedAt = Date.parse(activeLatestTurn.startedAt);
988 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);
989 if (Number.isNaN(turnStartedAt)) return null;
990 if (Number.isNaN(turnCompletedAt)) return null;
991
992 let inRangeMatch: string | null = null;
993 let fallbackMatch: string | null = null;
994 for (const timelineEntry of timelineEntries) {
995 if (timelineEntry.kind !== "message") continue;
996 if (timelineEntry.message.role !== "assistant") continue;
997 const messageAt = Date.parse(timelineEntry.message.createdAt);
998 if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue;
999 fallbackMatch = timelineEntry.id;
1000 if (messageAt <= turnCompletedAt) {
1001 inRangeMatch = timelineEntry.id;
1002 }
1003 }
1004 return inRangeMatch ?? fallbackMatch;
1005 }, [
1006 activeLatestTurn?.completedAt,
1007 activeLatestTurn?.startedAt,
1008 completionSummary,
1009 latestTurnSettled,
1010 timelineEntries,
1011 ]);
1156 return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn);
1157 }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]);
10121158 const gitCwd = activeProject
10131159 ? projectScriptCwd({
10141160 project: { cwd: activeProject.cwd },
......@@ -1230,7 +1376,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12301376 const setThreadError = useCallback(
12311377 (targetThreadId: ThreadId | null, error: string | null) => {
12321378 if (!targetThreadId) return;
1233 if (threads.some((thread) => thread.id === targetThreadId)) {
1379 if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) {
12341380 setStoreThreadError(targetThreadId, error);
12351381 return;
12361382 }
......@@ -1244,7 +1390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12441390 };
12451391 });
12461392 },
1247 [setStoreThreadError, threads],
1393 [setStoreThreadError],
12481394 );
12491395
12501396 const focusComposer = useCallback(() => {
......@@ -1411,7 +1557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14111557 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
14121558 ...(options?.env ? { extraEnv: options.env } : {}),
14131559 });
1414 const openTerminalInput: Parameters<typeof api.terminal.open>[0] = shouldCreateNewTerminal
1560 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
14151561 ? {
14161562 threadId: activeThreadId,
14171563 terminalId: targetTerminalId,
......@@ -2020,15 +2166,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
20202166 }
20212167 return [];
20222168 });
2023 setSendPhase("idle");
2024 setSendStartedAt(null);
2169 resetLocalDispatch();
20252170 setComposerHighlightedItemId(null);
20262171 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
20272172 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
20282173 dragDepthRef.current = 0;
20292174 setIsDragOverComposer(false);
20302175 setExpandedImage(null);
2031 }, [threadId]);
2176 }, [resetLocalDispatch, threadId]);
20322177
20332178 useEffect(() => {
20342179 let cancelled = false;
......@@ -2161,37 +2306,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
21612306 };
21622307 }, [phase]);
21632308
2164 const beginSendPhase = useCallback((nextPhase: Exclude<SendPhase, "idle">) => {
2165 setSendStartedAt((current) => current ?? new Date().toISOString());
2166 setSendPhase(nextPhase);
2167 }, []);
2168
2169 const resetSendPhase = useCallback(() => {
2170 setSendPhase("idle");
2171 setSendStartedAt(null);
2172 }, []);
2173
2174 useEffect(() => {
2175 if (sendPhase === "idle") {
2176 return;
2177 }
2178 if (
2179 phase === "running" ||
2180 activePendingApproval !== null ||
2181 activePendingUserInput !== null ||
2182 activeThread?.error
2183 ) {
2184 resetSendPhase();
2185 }
2186 }, [
2187 activePendingApproval,
2188 activePendingUserInput,
2189 activeThread?.error,
2190 phase,
2191 resetSendPhase,
2192 sendPhase,
2193 ]);
2194
21952309 useEffect(() => {
21962310 if (!activeThreadId) return;
21972311 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
......@@ -2529,7 +2643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25292643 }
25302644
25312645 sendInFlightRef.current = true;
2532 beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");
2646 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
25332647
25342648 const composerImagesSnapshot = [...composerImages];
25352649 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
......@@ -2603,7 +2717,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
26032717 await (async () => {
26042718 // On first message: lock in branch + create worktree if needed.
26052719 if (baseBranchForWorktree) {
2606 beginSendPhase("preparing-worktree");
2720 beginLocalDispatch({ preparingWorktree: true });
26072721 const newBranch = buildTemporaryWorktreeBranchName();
26082722 const result = await createWorktreeMutation.mutateAsync({
26092723 cwd: activeProject.cwd,
......@@ -2715,7 +2829,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27152829 });
27162830 }
27172831
2718 beginSendPhase("sending-turn");
2832 beginLocalDispatch({ preparingWorktree: false });
27192833 const turnAttachments = await turnAttachmentsPromise;
27202834 await api.orchestration.dispatchCommand({
27212835 type: "thread.turn.start",
......@@ -2772,7 +2886,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27722886 });
27732887 sendInFlightRef.current = false;
27742888 if (!turnStartSucceeded) {
2775 resetSendPhase();
2889 resetLocalDispatch();
27762890 }
27772891 };
27782892
......@@ -2971,7 +3085,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29713085 });
29723086
29733087 sendInFlightRef.current = true;
2974 beginSendPhase("sending-turn");
3088 beginLocalDispatch({ preparingWorktree: false });
29753089 setThreadError(threadIdForSend, null);
29763090 setOptimisticUserMessages((existing) => [
29773091 ...existing,
......@@ -3040,19 +3154,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
30403154 err instanceof Error ? err.message : "Failed to send plan follow-up.",
30413155 );
30423156 sendInFlightRef.current = false;
3043 resetSendPhase();
3157 resetLocalDispatch();
30443158 }
30453159 },
30463160 [
30473161 activeThread,
30483162 activeProposedPlan,
3049 beginSendPhase,
3163 beginLocalDispatch,
30503164 forceStickToBottom,
30513165 isConnecting,
30523166 isSendBusy,
30533167 isServerThread,
30543168 persistThreadSettingsForNextTurn,
3055 resetSendPhase,
3169 resetLocalDispatch,
30563170 runtimeMode,
30573171 selectedPromptEffort,
30583172 selectedModelSelection,
......@@ -3094,10 +3208,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
30943208 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
30953209
30963210 sendInFlightRef.current = true;
3097 beginSendPhase("sending-turn");
3211 beginLocalDispatch({ preparingWorktree: false });
30983212 const finish = () => {
30993213 sendInFlightRef.current = false;
3100 resetSendPhase();
3214 resetLocalDispatch();
31013215 };
31023216
31033217 await api.orchestration
......@@ -3132,9 +3246,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
31323246 createdAt,
31333247 });
31343248 })
3135 .then(() => api.orchestration.getSnapshot())
3136 .then((snapshot) => {
3137 syncServerReadModel(snapshot);
3249 .then(() => {
3250 return waitForStartedServerThread(nextThreadId);
3251 })
3252 .then(() => {
31383253 // Signal that the plan sidebar should open on the new thread.
31393254 planSidebarOpenOnNextThreadRef.current = true;
31403255 return navigate({
......@@ -3150,12 +3265,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
31503265 threadId: nextThreadId,
31513266 })
31523267 .catch(() => undefined);
3153 await api.orchestration
3154 .getSnapshot()
3155 .then((snapshot) => {
3156 syncServerReadModel(snapshot);
3157 })
3158 .catch(() => undefined);
31593268 toastManager.add({
31603269 type: "error",
31613270 title: "Could not start implementation thread",
......@@ -3168,18 +3277,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
31683277 activeProject,
31693278 activeProposedPlan,
31703279 activeThread,
3171 beginSendPhase,
3280 beginLocalDispatch,
31723281 isConnecting,
31733282 isSendBusy,
31743283 isServerThread,
31753284 navigate,
3176 resetSendPhase,
3285 resetLocalDispatch,
31773286 runtimeMode,
31783287 selectedPromptEffort,
31793288 selectedModelSelection,
31803289 selectedProvider,
31813290 selectedProviderModels,
3182 syncServerReadModel,
31833291 selectedModel,
31843292 ]);
31853293
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
......@@ -323,7 +323,7 @@ describe("Keybindings update toast", () => {
323323 useStore.setState({
324324 projects: [],
325325 threads: [],
326 threadsHydrated: false,
326 bootstrapComplete: false,
327327 });
328328 });
329329
apps/web/src/components/Sidebar.logic.test.ts+47-3
......@@ -9,6 +9,7 @@ import {
99 getProjectSortTimestamp,
1010 hasUnseenCompletion,
1111 isContextMenuPointerDown,
12 orderItemsByPreferredIds,
1213 resolveProjectStatusIndicator,
1314 resolveSidebarNewThreadEnvMode,
1415 resolveThreadRowClassName,
......@@ -18,7 +19,7 @@ import {
1819 sortThreadsForSidebar,
1920 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2021} from "./Sidebar.logic";
21import { ProjectId, ThreadId } from "@t3tools/contracts";
22import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
2223import {
2324 DEFAULT_INTERACTION_MODE,
2425 DEFAULT_RUNTIME_MODE,
......@@ -29,7 +30,7 @@ import {
2930function makeLatestTurn(overrides?: {
3031 completedAt?: string | null;
3132 startedAt?: string | null;
32}): Parameters<typeof hasUnseenCompletion>[0]["latestTurn"] {
33}): OrchestrationLatestTurn {
3334 return {
3435 turnId: "turn-1" as never,
3536 state: "completed",
......@@ -163,6 +164,50 @@ describe("resolveSidebarNewThreadEnvMode", () => {
163164 });
164165});
165166
167describe("orderItemsByPreferredIds", () => {
168 it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
169 const ordered = orderItemsByPreferredIds({
170 items: [
171 { id: ProjectId.makeUnsafe("project-1"), name: "One" },
172 { id: ProjectId.makeUnsafe("project-2"), name: "Two" },
173 { id: ProjectId.makeUnsafe("project-3"), name: "Three" },
174 ],
175 preferredIds: [
176 ProjectId.makeUnsafe("project-3"),
177 ProjectId.makeUnsafe("project-missing"),
178 ProjectId.makeUnsafe("project-1"),
179 ],
180 getId: (project) => project.id,
181 });
182
183 expect(ordered.map((project) => project.id)).toEqual([
184 ProjectId.makeUnsafe("project-3"),
185 ProjectId.makeUnsafe("project-1"),
186 ProjectId.makeUnsafe("project-2"),
187 ]);
188 });
189
190 it("does not duplicate items when preferred ids repeat", () => {
191 const ordered = orderItemsByPreferredIds({
192 items: [
193 { id: ProjectId.makeUnsafe("project-1"), name: "One" },
194 { id: ProjectId.makeUnsafe("project-2"), name: "Two" },
195 ],
196 preferredIds: [
197 ProjectId.makeUnsafe("project-2"),
198 ProjectId.makeUnsafe("project-1"),
199 ProjectId.makeUnsafe("project-2"),
200 ],
201 getId: (project) => project.id,
202 });
203
204 expect(ordered.map((project) => project.id)).toEqual([
205 ProjectId.makeUnsafe("project-2"),
206 ProjectId.makeUnsafe("project-1"),
207 ]);
208 });
209});
210
166211describe("resolveAdjacentThreadId", () => {
167212 it("resolves adjacent thread ids in ordered sidebar traversal", () => {
168213 const threads = [
......@@ -552,7 +597,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
552597 model: "gpt-5.4",
553598 ...defaultModelSelection,
554599 },
555 expanded: true,
556600 createdAt: "2026-03-09T10:00:00.000Z",
557601 updatedAt: "2026-03-09T10:00:00.000Z",
558602 scripts: [],
apps/web/src/components/Sidebar.logic.ts+52-12
......@@ -17,7 +17,10 @@ type SidebarProject = {
1717 createdAt?: string | undefined;
1818 updatedAt?: string | undefined;
1919};
20type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;
20type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {
21 latestUserMessageAt?: string | null;
22 messages?: Pick<Thread["messages"][number], "createdAt" | "role">[];
23};
2124
2225export type ThreadTraversalDirection = "previous" | "next";
2326
......@@ -45,8 +48,10 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
4548
4649type ThreadStatusInput = Pick<
4750 Thread,
48 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
49>;
51 "interactionMode" | "latestTurn" | "proposedPlans" | "session"
52> & {
53 lastVisitedAt?: string | undefined;
54};
5055
5156export interface ThreadJumpHintVisibilityController {
5257 sync: (shouldShow: boolean) => void;
......@@ -156,6 +161,34 @@ export function resolveSidebarNewThreadEnvMode(input: {
156161 return input.requestedEnvMode ?? input.defaultEnvMode;
157162}
158163
164export function orderItemsByPreferredIds<TItem, TId>(input: {
165 items: readonly TItem[];
166 preferredIds: readonly TId[];
167 getId: (item: TItem) => TId;
168}): TItem[] {
169 const { getId, items, preferredIds } = input;
170 if (preferredIds.length === 0) {
171 return [...items];
172 }
173
174 const itemsById = new Map(items.map((item) => [getId(item), item] as const));
175 const preferredIdSet = new Set(preferredIds);
176 const emittedPreferredIds = new Set<TId>();
177 const ordered = preferredIds.flatMap((id) => {
178 if (emittedPreferredIds.has(id)) {
179 return [];
180 }
181 const item = itemsById.get(id);
182 if (!item) {
183 return [];
184 }
185 emittedPreferredIds.add(id);
186 return [item];
187 });
188 const remaining = items.filter((item) => !preferredIdSet.has(getId(item)));
189 return [...ordered, ...remaining];
190}
191
159192export function getVisibleSidebarThreadIds<TThreadId>(
160193 renderedProjects: readonly {
161194 shouldShowThreadPanel?: boolean;
......@@ -327,15 +360,15 @@ export function resolveProjectStatusIndicator(
327360 return highestPriorityStatus;
328361}
329362
330export function getVisibleThreadsForProject(input: {
331 threads: readonly Thread[];
332 activeThreadId: Thread["id"] | undefined;
363export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input: {
364 threads: readonly T[];
365 activeThreadId: T["id"] | undefined;
333366 isThreadListExpanded: boolean;
334367 previewLimit: number;
335368}): {
336369 hasHiddenThreads: boolean;
337 hiddenThreads: Thread[];
338 visibleThreads: Thread[];
370 visibleThreads: T[];
371 hiddenThreads: T[];
339372} {
340373 const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input;
341374 const hasHiddenThreads = threads.length > previewLimit;
......@@ -382,9 +415,13 @@ function toSortableTimestamp(iso: string | undefined): number | null {
382415}
383416
384417function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number {
418 if (thread.latestUserMessageAt) {
419 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
420 }
421
385422 let latestUserMessageTimestamp: number | null = null;
386423
387 for (const message of thread.messages) {
424 for (const message of thread.messages ?? []) {
388425 if (message.role !== "user") continue;
389426 const messageTimestamp = toSortableTimestamp(message.createdAt);
390427 if (messageTimestamp === null) continue;
......@@ -412,7 +449,7 @@ function getThreadSortTimestamp(
412449}
413450
414451export function sortThreadsForSidebar<
415 T extends Pick<Thread, "id" | "createdAt" | "updatedAt" | "messages">,
452 T extends Pick<Thread, "id" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
416453>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] {
417454 return threads.toSorted((left, right) => {
418455 const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
......@@ -425,7 +462,7 @@ export function sortThreadsForSidebar<
425462}
426463
427464export function getFallbackThreadIdAfterDelete<
428 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt" | "messages">,
465 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
429466>(input: {
430467 threads: readonly T[];
431468 deletedThreadId: T["id"];
......@@ -469,7 +506,10 @@ export function getProjectSortTimestamp(
469506 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
470507}
471508
472export function sortProjectsForSidebar<TProject extends SidebarProject, TThread extends Thread>(
509export function sortProjectsForSidebar<
510 TProject extends SidebarProject,
511 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
512>(
473513 projects: readonly TProject[],
474514 threads: readonly TThread[],
475515 sortOrder: SidebarProjectSortOrder,
apps/web/src/components/Sidebar.tsx+121-11
......@@ -21,6 +21,7 @@ import {
2121 type MouseEvent,
2222 type PointerEvent,
2323} from "react";
24import { useShallow } from "zustand/react/shallow";
2425import {
2526 DndContext,
2627 type DragCancelEvent,
......@@ -55,6 +56,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
5556import { isTerminalFocused } from "../lib/terminalFocus";
5657import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
5758import { useStore } from "../store";
59import { useUiStateStore } from "../uiStateStore";
5860import {
5961 resolveShortcutCommand,
6062 shortcutLabelForCommand,
......@@ -114,6 +116,7 @@ import {
114116 resolveSidebarNewThreadEnvMode,
115117 resolveThreadRowClassName,
116118 resolveThreadStatusPill,
119 orderItemsByPreferredIds,
117120 shouldClearThreadSelectionOnMouseDown,
118121 sortProjectsForSidebar,
119122 sortThreadsForSidebar,
......@@ -122,6 +125,7 @@ import {
122125import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
123126import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
124127import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
128import type { Project, Thread } from "../types";
125129
126130const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
127131const THREAD_PREVIEW_LIMIT = 6;
......@@ -138,6 +142,80 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
138142 duration: 180,
139143 easing: "ease-out",
140144} as const;
145
146type SidebarThreadSnapshot = Pick<
147 Thread,
148 | "activities"
149 | "archivedAt"
150 | "branch"
151 | "createdAt"
152 | "id"
153 | "interactionMode"
154 | "latestTurn"
155 | "projectId"
156 | "proposedPlans"
157 | "session"
158 | "title"
159 | "updatedAt"
160 | "worktreePath"
161> & {
162 lastVisitedAt?: string | undefined;
163 latestUserMessageAt: string | null;
164};
165
166type SidebarProjectSnapshot = Project & {
167 expanded: boolean;
168};
169
170const sidebarThreadSnapshotCache = new WeakMap<
171 Thread,
172 { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot }
173>();
174
175function getLatestUserMessageAt(thread: Thread): string | null {
176 let latestUserMessageAt: string | null = null;
177
178 for (const message of thread.messages) {
179 if (message.role !== "user") {
180 continue;
181 }
182 if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) {
183 latestUserMessageAt = message.createdAt;
184 }
185 }
186
187 return latestUserMessageAt;
188}
189
190function toSidebarThreadSnapshot(
191 thread: Thread,
192 lastVisitedAt: string | undefined,
193): SidebarThreadSnapshot {
194 const cached = sidebarThreadSnapshotCache.get(thread);
195 if (cached && cached.lastVisitedAt === lastVisitedAt) {
196 return cached.snapshot;
197 }
198
199 const snapshot: SidebarThreadSnapshot = {
200 id: thread.id,
201 projectId: thread.projectId,
202 title: thread.title,
203 interactionMode: thread.interactionMode,
204 session: thread.session,
205 createdAt: thread.createdAt,
206 updatedAt: thread.updatedAt,
207 archivedAt: thread.archivedAt,
208 latestTurn: thread.latestTurn,
209 lastVisitedAt,
210 branch: thread.branch,
211 worktreePath: thread.worktreePath,
212 activities: thread.activities,
213 proposedPlans: thread.proposedPlans,
214 latestUserMessageAt: getLatestUserMessageAt(thread),
215 };
216 sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot });
217 return snapshot;
218}
141219interface TerminalStatusIndicator {
142220 label: "Terminal process running";
143221 colorClass: string;
......@@ -361,10 +439,17 @@ function SortableProjectItem({
361439
362440export default function Sidebar() {
363441 const projects = useStore((store) => store.projects);
364 const threads = useStore((store) => store.threads);
365 const markThreadUnread = useStore((store) => store.markThreadUnread);
366 const toggleProject = useStore((store) => store.toggleProject);
367 const reorderProjects = useStore((store) => store.reorderProjects);
442 const serverThreads = useStore((store) => store.threads);
443 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
444 useShallow((store) => ({
445 projectExpandedById: store.projectExpandedById,
446 projectOrder: store.projectOrder,
447 threadLastVisitedAtById: store.threadLastVisitedAtById,
448 })),
449 );
450 const markThreadUnread = useUiStateStore((store) => store.markThreadUnread);
451 const toggleProject = useUiStateStore((store) => store.toggleProject);
452 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
368453 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
369454 const getDraftThreadByProjectId = useComposerDraftStore(
370455 (store) => store.getDraftThreadByProjectId,
......@@ -418,6 +503,28 @@ export default function Sidebar() {
418503 const platform = navigator.platform;
419504 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
420505 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
506 const orderedProjects = useMemo(() => {
507 return orderItemsByPreferredIds({
508 items: projects,
509 preferredIds: projectOrder,
510 getId: (project) => project.id,
511 });
512 }, [projectOrder, projects]);
513 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
514 () =>
515 orderedProjects.map((project) => ({
516 ...project,
517 expanded: projectExpandedById[project.id] ?? true,
518 })),
519 [orderedProjects, projectExpandedById],
520 );
521 const threads = useMemo(
522 () =>
523 serverThreads.map((thread) =>
524 toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]),
525 ),
526 [serverThreads, threadLastVisitedAtById],
527 );
421528 const projectCwdById = useMemo(
422529 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
423530 [projects],
......@@ -752,7 +859,7 @@ export default function Sidebar() {
752859 }
753860
754861 if (clicked === "mark-unread") {
755 markThreadUnread(threadId);
862 markThreadUnread(threadId, thread.latestTurn?.completedAt);
756863 return;
757864 }
758865 if (clicked === "copy-path") {
......@@ -814,7 +921,8 @@ export default function Sidebar() {
814921
815922 if (clicked === "mark-unread") {
816923 for (const id of ids) {
817 markThreadUnread(id);
924 const thread = threads.find((candidate) => candidate.id === id);
925 markThreadUnread(id, thread?.latestTurn?.completedAt);
818926 }
819927 clearSelection();
820928 return;
......@@ -845,6 +953,7 @@ export default function Sidebar() {
845953 markThreadUnread,
846954 removeFromSelection,
847955 selectedThreadIds,
956 threads,
848957 ],
849958 );
850959
......@@ -987,12 +1096,12 @@ export default function Sidebar() {
9871096 dragInProgressRef.current = false;
9881097 const { active, over } = event;
9891098 if (!over || active.id === over.id) return;
990 const activeProject = projects.find((project) => project.id === active.id);
991 const overProject = projects.find((project) => project.id === over.id);
1099 const activeProject = sidebarProjects.find((project) => project.id === active.id);
1100 const overProject = sidebarProjects.find((project) => project.id === over.id);
9921101 if (!activeProject || !overProject) return;
9931102 reorderProjects(activeProject.id, overProject.id);
9941103 },
995 [appSettings.sidebarProjectSortOrder, projects, reorderProjects],
1104 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],
9961105 );
9971106
9981107 const handleProjectDragStart = useCallback(
......@@ -1052,8 +1161,9 @@ export default function Sidebar() {
10521161 [threads],
10531162 );
10541163 const sortedProjects = useMemo(
1055 () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder),
1056 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],
1164 () =>
1165 sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder),
1166 [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads],
10571167 );
10581168 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
10591169 const renderedProjects = useMemo(
apps/web/src/composerDraftStore.test.ts+53
......@@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
99
1010import {
1111 COMPOSER_DRAFT_STORAGE_KEY,
12 clearPromotedDraftThread,
13 clearPromotedDraftThreads,
1214 type ComposerImageAttachment,
1315 useComposerDraftStore,
1416} from "./composerDraftStore";
......@@ -549,6 +551,57 @@ describe("composerDraftStore project draft thread mapping", () => {
549551 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
550552 });
551553
554 it("clears a promoted draft by thread id", () => {
555 const store = useComposerDraftStore.getState();
556 store.setProjectDraftThreadId(projectId, threadId);
557 store.setPrompt(threadId, "promote me");
558
559 clearPromotedDraftThread(threadId);
560
561 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
562 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
563 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
564 });
565
566 it("does not clear composer drafts for existing server threads during promotion cleanup", () => {
567 const store = useComposerDraftStore.getState();
568 store.setPrompt(threadId, "keep me");
569
570 clearPromotedDraftThread(threadId);
571
572 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
573 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
574 });
575
576 it("clears promoted drafts from an iterable of server thread ids", () => {
577 const store = useComposerDraftStore.getState();
578 store.setProjectDraftThreadId(projectId, threadId);
579 store.setPrompt(threadId, "promote me");
580 store.setProjectDraftThreadId(otherProjectId, otherThreadId);
581 store.setPrompt(otherThreadId, "keep me");
582
583 clearPromotedDraftThreads([threadId]);
584
585 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
586 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
587 expect(
588 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
589 ).toBe(otherThreadId);
590 expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe(
591 "keep me",
592 );
593 });
594
595 it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => {
596 const store = useComposerDraftStore.getState();
597 store.setPrompt(threadId, "keep me");
598
599 clearPromotedDraftThreads([threadId]);
600
601 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
602 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
603 });
604
552605 it("updates branch context on an existing draft thread", () => {
553606 const store = useComposerDraftStore.getState();
554607 store.setProjectDraftThreadId(projectId, threadId, {
apps/web/src/composerDraftStore.ts+14-11
......@@ -2195,18 +2195,21 @@ export function useEffectiveComposerModelState(input: {
21952195}
21962196
21972197/**
2198 * Clear draft threads that have been promoted to server threads.
2198 * Clear a draft thread once the server has materialized the same thread id.
21992199 *
2200 * Call this after a snapshot sync so the route guard in `_chat.$threadId`
2201 * sees the server thread before the draft is removed — avoids a redirect
2202 * to `/` caused by a gap where neither draft nor server thread exists.
2200 * Use the single-thread helper for live `thread.created` events and the
2201 * iterable helper for bootstrap/recovery paths that discover multiple server
2202 * threads at once.
22032203 */
2204export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet<ThreadId>): void {
2205 const store = useComposerDraftStore.getState();
2206 const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[];
2207 for (const draftId of draftThreadIds) {
2208 if (serverThreadIds.has(draftId)) {
2209 store.clearDraftThread(draftId);
2210 }
2204export function clearPromotedDraftThread(threadId: ThreadId): void {
2205 if (!useComposerDraftStore.getState().getDraftThread(threadId)) {
2206 return;
2207 }
2208 useComposerDraftStore.getState().clearDraftThread(threadId);
2209}
2210
2211export function clearPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
2212 for (const threadId of serverThreadIds) {
2213 clearPromotedDraftThread(threadId);
22112214 }
22122215}
apps/web/src/hooks/useHandleNewThread.ts+16-8
......@@ -1,29 +1,37 @@
11import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
22import { useNavigate, useParams } from "@tanstack/react-router";
3import { useCallback } from "react";
3import { useCallback, useMemo } from "react";
4import { useShallow } from "zustand/react/shallow";
45import {
56 type DraftThreadEnvMode,
67 type DraftThreadState,
78 useComposerDraftStore,
89} from "../composerDraftStore";
910import { newThreadId } from "../lib/utils";
11import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
1012import { useStore } from "../store";
13import { useThreadById } from "../storeSelectors";
14import { useUiStateStore } from "../uiStateStore";
1115
1216export function useHandleNewThread() {
13 const projects = useStore((store) => store.projects);
14 const threads = useStore((store) => store.threads);
17 const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id)));
18 const projectOrder = useUiStateStore((store) => store.projectOrder);
1519 const navigate = useNavigate();
1620 const routeThreadId = useParams({
1721 strict: false,
1822 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
1923 });
24 const activeThread = useThreadById(routeThreadId);
2025 const activeDraftThread = useComposerDraftStore((store) =>
2126 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
2227 );
23
24 const activeThread = routeThreadId
25 ? threads.find((thread) => thread.id === routeThreadId)
26 : undefined;
28 const orderedProjects = useMemo(() => {
29 return orderItemsByPreferredIds({
30 items: projectIds,
31 preferredIds: projectOrder,
32 getId: (projectId) => projectId,
33 });
34 }, [projectIds, projectOrder]);
2735
2836 const handleNewThread = useCallback(
2937 (
......@@ -111,8 +119,8 @@ export function useHandleNewThread() {
111119 return {
112120 activeDraftThread,
113121 activeThread,
122 defaultProjectId: orderedProjects[0] ?? null,
114123 handleNewThread,
115 projects,
116124 routeThreadId,
117125 };
118126}
apps/web/src/hooks/useThreadActions.ts+5-8
......@@ -16,8 +16,6 @@ import { toastManager } from "../components/ui/toast";
1616import { useSettings } from "./useSettings";
1717
1818export function useThreadActions() {
19 const threads = useStore((store) => store.threads);
20 const projects = useStore((store) => store.projects);
2119 const appSettings = useSettings();
2220 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
2321 const clearProjectDraftThreadById = useComposerDraftStore(
......@@ -37,7 +35,7 @@ export function useThreadActions() {
3735 async (threadId: ThreadId) => {
3836 const api = readNativeApi();
3937 if (!api) return;
40 const thread = threads.find((entry) => entry.id === threadId);
38 const thread = useStore.getState().threads.find((entry) => entry.id === threadId);
4139 if (!thread) return;
4240 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
4341 throw new Error("Cannot archive a running thread.");
......@@ -53,7 +51,7 @@ export function useThreadActions() {
5351 await handleNewThread(thread.projectId);
5452 }
5553 },
56 [handleNewThread, routeThreadId, threads],
54 [handleNewThread, routeThreadId],
5755 );
5856
5957 const unarchiveThread = useCallback(async (threadId: ThreadId) => {
......@@ -70,6 +68,7 @@ export function useThreadActions() {
7068 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
7169 const api = readNativeApi();
7270 if (!api) return;
71 const { projects, threads } = useStore.getState();
7372 const thread = threads.find((entry) => entry.id === threadId);
7473 if (!thread) return;
7574 const threadProject = projects.find((project) => project.id === thread.projectId);
......@@ -171,10 +170,8 @@ export function useThreadActions() {
171170 clearTerminalState,
172171 appSettings.sidebarThreadSortOrder,
173172 navigate,
174 projects,
175173 removeWorktreeMutation,
176174 routeThreadId,
177 threads,
178175 ],
179176 );
180177
......@@ -182,7 +179,7 @@ export function useThreadActions() {
182179 async (threadId: ThreadId) => {
183180 const api = readNativeApi();
184181 if (!api) return;
185 const thread = threads.find((entry) => entry.id === threadId);
182 const thread = useStore.getState().threads.find((entry) => entry.id === threadId);
186183 if (!thread) return;
187184
188185 if (appSettings.confirmThreadDelete) {
......@@ -199,7 +196,7 @@ export function useThreadActions() {
199196
200197 await deleteThread(threadId);
201198 },
202 [appSettings.confirmThreadDelete, deleteThread, threads],
199 [appSettings.confirmThreadDelete, deleteThread],
203200 );
204201
205202 return {
apps/web/src/orchestrationEventEffects.test.ts created+108
......@@ -0,0 +1,108 @@
1import {
2 CheckpointRef,
3 EventId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 TurnId,
8 type OrchestrationEvent,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "vitest";
11
12import { deriveOrchestrationBatchEffects } from "./orchestrationEventEffects";
13
14function makeEvent<T extends OrchestrationEvent["type"]>(
15 type: T,
16 payload: Extract<OrchestrationEvent, { type: T }>["payload"],
17 overrides: Partial<Extract<OrchestrationEvent, { type: T }>> = {},
18): Extract<OrchestrationEvent, { type: T }> {
19 const sequence = overrides.sequence ?? 1;
20 return {
21 sequence,
22 eventId: EventId.makeUnsafe(`event-${sequence}`),
23 aggregateKind: "thread",
24 aggregateId:
25 "threadId" in payload
26 ? payload.threadId
27 : "projectId" in payload
28 ? payload.projectId
29 : ProjectId.makeUnsafe("project-1"),
30 occurredAt: "2026-02-27T00:00:00.000Z",
31 commandId: null,
32 causationEventId: null,
33 correlationId: null,
34 metadata: {},
35 type,
36 payload,
37 ...overrides,
38 } as Extract<OrchestrationEvent, { type: T }>;
39}
40
41describe("deriveOrchestrationBatchEffects", () => {
42 it("targets draft promotion and terminal cleanup from thread lifecycle events", () => {
43 const createdThreadId = ThreadId.makeUnsafe("thread-created");
44 const deletedThreadId = ThreadId.makeUnsafe("thread-deleted");
45
46 const effects = deriveOrchestrationBatchEffects([
47 makeEvent("thread.created", {
48 threadId: createdThreadId,
49 projectId: ProjectId.makeUnsafe("project-1"),
50 title: "Created thread",
51 modelSelection: { provider: "codex", model: "gpt-5-codex" },
52 runtimeMode: "full-access",
53 interactionMode: "default",
54 branch: null,
55 worktreePath: null,
56 createdAt: "2026-02-27T00:00:00.000Z",
57 updatedAt: "2026-02-27T00:00:00.000Z",
58 }),
59 makeEvent("thread.deleted", {
60 threadId: deletedThreadId,
61 deletedAt: "2026-02-27T00:00:01.000Z",
62 }),
63 ]);
64
65 expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]);
66 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);
67 expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId]);
68 expect(effects.needsProviderInvalidation).toBe(false);
69 });
70
71 it("keeps only the final lifecycle outcome for a thread within one batch", () => {
72 const threadId = ThreadId.makeUnsafe("thread-1");
73
74 const effects = deriveOrchestrationBatchEffects([
75 makeEvent("thread.deleted", {
76 threadId,
77 deletedAt: "2026-02-27T00:00:01.000Z",
78 }),
79 makeEvent("thread.created", {
80 threadId,
81 projectId: ProjectId.makeUnsafe("project-1"),
82 title: "Recreated thread",
83 modelSelection: { provider: "codex", model: "gpt-5-codex" },
84 runtimeMode: "full-access",
85 interactionMode: "default",
86 branch: null,
87 worktreePath: null,
88 createdAt: "2026-02-27T00:00:02.000Z",
89 updatedAt: "2026-02-27T00:00:02.000Z",
90 }),
91 makeEvent("thread.turn-diff-completed", {
92 threadId,
93 turnId: TurnId.makeUnsafe("turn-1"),
94 checkpointTurnCount: 1,
95 checkpointRef: CheckpointRef.makeUnsafe("checkpoint-1"),
96 status: "ready",
97 files: [],
98 assistantMessageId: MessageId.makeUnsafe("assistant-1"),
99 completedAt: "2026-02-27T00:00:03.000Z",
100 }),
101 ]);
102
103 expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]);
104 expect(effects.clearDeletedThreadIds).toEqual([]);
105 expect(effects.removeTerminalStateThreadIds).toEqual([]);
106 expect(effects.needsProviderInvalidation).toBe(true);
107 });
108});
apps/web/src/orchestrationEventEffects.ts created+76
......@@ -0,0 +1,76 @@
1import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
2
3export interface OrchestrationBatchEffects {
4 clearPromotedDraftThreadIds: ThreadId[];
5 clearDeletedThreadIds: ThreadId[];
6 removeTerminalStateThreadIds: ThreadId[];
7 needsProviderInvalidation: boolean;
8}
9
10export function deriveOrchestrationBatchEffects(
11 events: readonly OrchestrationEvent[],
12): OrchestrationBatchEffects {
13 const threadLifecycleEffects = new Map<
14 ThreadId,
15 {
16 clearPromotedDraft: boolean;
17 clearDeletedThread: boolean;
18 removeTerminalState: boolean;
19 }
20 >();
21 let needsProviderInvalidation = false;
22
23 for (const event of events) {
24 switch (event.type) {
25 case "thread.turn-diff-completed":
26 case "thread.reverted": {
27 needsProviderInvalidation = true;
28 break;
29 }
30
31 case "thread.created": {
32 threadLifecycleEffects.set(event.payload.threadId, {
33 clearPromotedDraft: true,
34 clearDeletedThread: false,
35 removeTerminalState: false,
36 });
37 break;
38 }
39
40 case "thread.deleted": {
41 threadLifecycleEffects.set(event.payload.threadId, {
42 clearPromotedDraft: false,
43 clearDeletedThread: true,
44 removeTerminalState: true,
45 });
46 break;
47 }
48
49 default: {
50 break;
51 }
52 }
53 }
54
55 const clearPromotedDraftThreadIds: ThreadId[] = [];
56 const clearDeletedThreadIds: ThreadId[] = [];
57 const removeTerminalStateThreadIds: ThreadId[] = [];
58 for (const [threadId, effect] of threadLifecycleEffects) {
59 if (effect.clearPromotedDraft) {
60 clearPromotedDraftThreadIds.push(threadId);
61 }
62 if (effect.clearDeletedThread) {
63 clearDeletedThreadIds.push(threadId);
64 }
65 if (effect.removeTerminalState) {
66 removeTerminalStateThreadIds.push(threadId);
67 }
68 }
69
70 return {
71 clearPromotedDraftThreadIds,
72 clearDeletedThreadIds,
73 removeTerminalStateThreadIds,
74 needsProviderInvalidation,
75 };
76}
apps/web/src/orchestrationRecovery.test.ts created+100
......@@ -0,0 +1,100 @@
1import { describe, expect, it } from "vitest";
2
3import { createOrchestrationRecoveryCoordinator } from "./orchestrationRecovery";
4
5describe("createOrchestrationRecoveryCoordinator", () => {
6 it("defers live events until bootstrap completes and then requests replay", () => {
7 const coordinator = createOrchestrationRecoveryCoordinator();
8
9 expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true);
10 expect(coordinator.classifyDomainEvent(4)).toBe("defer");
11
12 expect(coordinator.completeSnapshotRecovery(2)).toBe(true);
13 expect(coordinator.getState()).toMatchObject({
14 latestSequence: 2,
15 highestObservedSequence: 4,
16 bootstrapped: true,
17 pendingReplay: false,
18 inFlight: null,
19 });
20 });
21
22 it("classifies sequence gaps as recovery-only replay work", () => {
23 const coordinator = createOrchestrationRecoveryCoordinator();
24
25 coordinator.beginSnapshotRecovery("bootstrap");
26 coordinator.completeSnapshotRecovery(3);
27
28 expect(coordinator.classifyDomainEvent(5)).toBe("recover");
29 expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true);
30 expect(coordinator.getState().inFlight).toEqual({
31 kind: "replay",
32 reason: "sequence-gap",
33 });
34 });
35
36 it("tracks live event batches without entering recovery", () => {
37 const coordinator = createOrchestrationRecoveryCoordinator();
38
39 coordinator.beginSnapshotRecovery("bootstrap");
40 coordinator.completeSnapshotRecovery(3);
41
42 expect(coordinator.classifyDomainEvent(4)).toBe("apply");
43 expect(coordinator.markEventBatchApplied([{ sequence: 4 }])).toEqual([{ sequence: 4 }]);
44 expect(coordinator.getState()).toMatchObject({
45 latestSequence: 4,
46 highestObservedSequence: 4,
47 bootstrapped: true,
48 inFlight: null,
49 });
50 });
51
52 it("requests another replay when deferred events arrive during replay recovery", () => {
53 const coordinator = createOrchestrationRecoveryCoordinator();
54
55 coordinator.beginSnapshotRecovery("bootstrap");
56 coordinator.completeSnapshotRecovery(3);
57 coordinator.classifyDomainEvent(5);
58 coordinator.beginReplayRecovery("sequence-gap");
59 coordinator.classifyDomainEvent(7);
60 coordinator.markEventBatchApplied([{ sequence: 4 }, { sequence: 5 }, { sequence: 6 }]);
61
62 expect(coordinator.completeReplayRecovery()).toBe(true);
63 });
64
65 it("does not immediately replay again when replay returns no new events", () => {
66 const coordinator = createOrchestrationRecoveryCoordinator();
67
68 coordinator.beginSnapshotRecovery("bootstrap");
69 coordinator.completeSnapshotRecovery(3);
70 coordinator.classifyDomainEvent(5);
71 coordinator.beginReplayRecovery("sequence-gap");
72
73 expect(coordinator.completeReplayRecovery()).toBe(false);
74 expect(coordinator.getState()).toMatchObject({
75 latestSequence: 3,
76 highestObservedSequence: 5,
77 pendingReplay: false,
78 inFlight: null,
79 });
80 });
81
82 it("marks replay failure as unbootstrapped so snapshot fallback is recovery-only", () => {
83 const coordinator = createOrchestrationRecoveryCoordinator();
84
85 coordinator.beginSnapshotRecovery("bootstrap");
86 coordinator.completeSnapshotRecovery(3);
87 coordinator.beginReplayRecovery("sequence-gap");
88 coordinator.failReplayRecovery();
89
90 expect(coordinator.getState()).toMatchObject({
91 bootstrapped: false,
92 inFlight: null,
93 });
94 expect(coordinator.beginSnapshotRecovery("replay-failed")).toBe(true);
95 expect(coordinator.getState().inFlight).toEqual({
96 kind: "snapshot",
97 reason: "replay-failed",
98 });
99 });
100});
apps/web/src/orchestrationRecovery.ts created+136
......@@ -0,0 +1,136 @@
1export type OrchestrationRecoveryReason = "bootstrap" | "sequence-gap" | "replay-failed";
2
3export interface OrchestrationRecoveryPhase {
4 kind: "snapshot" | "replay";
5 reason: OrchestrationRecoveryReason;
6}
7
8export interface OrchestrationRecoveryState {
9 latestSequence: number;
10 highestObservedSequence: number;
11 bootstrapped: boolean;
12 pendingReplay: boolean;
13 inFlight: OrchestrationRecoveryPhase | null;
14}
15
16type SequencedEvent = Readonly<{ sequence: number }>;
17
18export function createOrchestrationRecoveryCoordinator() {
19 let state: OrchestrationRecoveryState = {
20 latestSequence: 0,
21 highestObservedSequence: 0,
22 bootstrapped: false,
23 pendingReplay: false,
24 inFlight: null,
25 };
26 let replayStartSequence: number | null = null;
27
28 const snapshotState = (): OrchestrationRecoveryState => ({
29 ...state,
30 ...(state.inFlight ? { inFlight: { ...state.inFlight } } : {}),
31 });
32
33 const observeSequence = (sequence: number) => {
34 state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence);
35 };
36
37 const shouldReplayAfterRecovery = (): boolean => {
38 const shouldReplay =
39 state.pendingReplay || state.highestObservedSequence > state.latestSequence;
40 state.pendingReplay = false;
41 return shouldReplay;
42 };
43
44 return {
45 getState(): OrchestrationRecoveryState {
46 return snapshotState();
47 },
48
49 classifyDomainEvent(sequence: number): "ignore" | "defer" | "recover" | "apply" {
50 observeSequence(sequence);
51 if (sequence <= state.latestSequence) {
52 return "ignore";
53 }
54 if (!state.bootstrapped || state.inFlight) {
55 state.pendingReplay = true;
56 return "defer";
57 }
58 if (sequence !== state.latestSequence + 1) {
59 state.pendingReplay = true;
60 return "recover";
61 }
62 return "apply";
63 },
64
65 markEventBatchApplied<T extends SequencedEvent>(events: ReadonlyArray<T>): ReadonlyArray<T> {
66 const nextEvents = events
67 .filter((event) => event.sequence > state.latestSequence)
68 .toSorted((left, right) => left.sequence - right.sequence);
69 if (nextEvents.length === 0) {
70 return [];
71 }
72
73 state.latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence;
74 state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence);
75 return nextEvents;
76 },
77
78 beginSnapshotRecovery(reason: OrchestrationRecoveryReason): boolean {
79 if (state.inFlight?.kind === "snapshot") {
80 state.pendingReplay = true;
81 return false;
82 }
83 if (state.inFlight?.kind === "replay") {
84 state.pendingReplay = true;
85 return false;
86 }
87 state.inFlight = { kind: "snapshot", reason };
88 return true;
89 },
90
91 completeSnapshotRecovery(snapshotSequence: number): boolean {
92 state.latestSequence = Math.max(state.latestSequence, snapshotSequence);
93 state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence);
94 state.bootstrapped = true;
95 state.inFlight = null;
96 return shouldReplayAfterRecovery();
97 },
98
99 failSnapshotRecovery(): void {
100 state.inFlight = null;
101 },
102
103 beginReplayRecovery(reason: OrchestrationRecoveryReason): boolean {
104 if (!state.bootstrapped || state.inFlight?.kind === "snapshot") {
105 state.pendingReplay = true;
106 return false;
107 }
108 if (state.inFlight?.kind === "replay") {
109 state.pendingReplay = true;
110 return false;
111 }
112 state.pendingReplay = false;
113 replayStartSequence = state.latestSequence;
114 state.inFlight = { kind: "replay", reason };
115 return true;
116 },
117
118 completeReplayRecovery(): boolean {
119 const replayMadeProgress =
120 replayStartSequence !== null && state.latestSequence > replayStartSequence;
121 replayStartSequence = null;
122 state.inFlight = null;
123 if (!replayMadeProgress) {
124 state.pendingReplay = false;
125 return false;
126 }
127 return shouldReplayAfterRecovery();
128 },
129
130 failReplayRecovery(): void {
131 replayStartSequence = null;
132 state.bootstrapped = false;
133 state.inFlight = null;
134 },
135 };
136}
apps/web/src/router.ts+2-10
......@@ -1,11 +1,8 @@
11import { createElement } from "react";
22import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3import { createRouter } from "@tanstack/react-router";
3import { createRouter, RouterHistory } from "@tanstack/react-router";
44
55import { routeTree } from "./routeTree.gen";
6import { StoreProvider } from "./store";
7
8type RouterHistory = NonNullable<Parameters<typeof createRouter>[0]["history"]>;
96
107export function getRouter(history: RouterHistory) {
118 const queryClient = new QueryClient();
......@@ -16,12 +13,7 @@ export function getRouter(history: RouterHistory) {
1613 context: {
1714 queryClient,
1815 },
19 Wrap: ({ children }) =>
20 createElement(
21 QueryClientProvider,
22 { client: queryClient },
23 createElement(StoreProvider, null, children),
24 ),
16 Wrap: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children),
2517 });
2618}
2719
apps/web/src/routes/__root.tsx+146-47
......@@ -1,4 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
1import { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
22import {
33 Outlet,
44 createRootRouteWithContext,
......@@ -17,8 +17,13 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component
1717import { resolveAndPersistPreferredEditor } from "../editorPreferences";
1818import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1919import { readNativeApi } from "../nativeApi";
20import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
20import {
21 clearPromotedDraftThread,
22 clearPromotedDraftThreads,
23 useComposerDraftStore,
24} from "../composerDraftStore";
2125import { useStore } from "../store";
26import { useUiStateStore } from "../uiStateStore";
2227import { useTerminalStateStore } from "../terminalStateStore";
2328import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
2429import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
......@@ -26,6 +31,8 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings";
2631import { providerQueryKeys } from "../lib/providerReactQuery";
2732import { projectQueryKeys } from "../lib/projectReactQuery";
2833import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
34import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
35import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
2936
3037export const Route = createRootRouteWithContext<{
3138 queryClient: QueryClient;
......@@ -135,8 +142,13 @@ function errorDetails(error: unknown): string {
135142}
136143
137144function EventRouter() {
145 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
138146 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
139 const setProjectExpanded = useStore((store) => store.setProjectExpanded);
147 const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded);
148 const syncProjects = useUiStateStore((store) => store.syncProjects);
149 const syncThreads = useUiStateStore((store) => store.syncThreads);
150 const clearThreadUi = useUiStateStore((store) => store.clearThreadUi);
151 const removeTerminalState = useTerminalStateStore((store) => store.removeTerminalState);
140152 const removeOrphanedTerminalStates = useTerminalStateStore(
141153 (store) => store.removeOrphanedTerminalStates,
142154 );
......@@ -152,56 +164,40 @@ function EventRouter() {
152164 const api = readNativeApi();
153165 if (!api) return;
154166 let disposed = false;
155 let latestSequence = 0;
156 let syncing = false;
157 let pending = false;
167 const recovery = createOrchestrationRecoveryCoordinator();
158168 let needsProviderInvalidation = false;
159169
160 const flushSnapshotSync = async (): Promise<void> => {
161 const snapshot = await api.orchestration.getSnapshot();
162 if (disposed) return;
163 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
164 syncServerReadModel(snapshot);
165 clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id)));
170 const reconcileSnapshotDerivedState = () => {
171 const threads = useStore.getState().threads;
172 const projects = useStore.getState().projects;
173 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
174 syncThreads(
175 threads.map((thread) => ({
176 id: thread.id,
177 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
178 })),
179 );
180 clearPromotedDraftThreads(threads.map((thread) => thread.id));
166181 const draftThreadIds = Object.keys(
167182 useComposerDraftStore.getState().draftThreadsByThreadId,
168183 ) as ThreadId[];
169184 const activeThreadIds = collectActiveTerminalThreadIds({
170 snapshotThreads: snapshot.threads,
185 snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })),
171186 draftThreadIds,
172187 });
173188 removeOrphanedTerminalStates(activeThreadIds);
174 if (pending) {
175 pending = false;
176 await flushSnapshotSync();
177 }
178189 };
179190
180 const syncSnapshot = async () => {
181 if (syncing) {
182 pending = true;
183 return;
184 }
185 syncing = true;
186 pending = false;
187 try {
188 await flushSnapshotSync();
189 } catch {
190 // Keep prior state and wait for next domain event to trigger a resync.
191 }
192 syncing = false;
193 };
194
195 const domainEventFlushThrottler = new Throttler(
191 const queryInvalidationThrottler = new Throttler(
196192 () => {
197 if (needsProviderInvalidation) {
198 needsProviderInvalidation = false;
199 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
200 // Invalidate workspace entry queries so the @-mention file picker
201 // reflects files created, deleted, or restored during this turn.
202 void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all });
193 if (!needsProviderInvalidation) {
194 return;
203195 }
204 void syncSnapshot();
196 needsProviderInvalidation = false;
197 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
198 // Invalidate workspace entry queries so the @-mention file picker
199 // reflects files created, deleted, or restored during this turn.
200 void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all });
205201 },
206202 {
207203 wait: 100,
......@@ -210,15 +206,113 @@ function EventRouter() {
210206 },
211207 );
212208
213 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
214 if (event.sequence <= latestSequence) {
209 const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => {
210 const nextEvents = recovery.markEventBatchApplied(events);
211 if (nextEvents.length === 0) {
215212 return;
216213 }
217 latestSequence = event.sequence;
218 if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") {
214
215 const batchEffects = deriveOrchestrationBatchEffects(nextEvents);
216 const needsProjectUiSync = nextEvents.some(
217 (event) =>
218 event.type === "project.created" ||
219 event.type === "project.meta-updated" ||
220 event.type === "project.deleted",
221 );
222
223 if (batchEffects.needsProviderInvalidation) {
219224 needsProviderInvalidation = true;
225 void queryInvalidationThrottler.maybeExecute();
226 }
227
228 applyOrchestrationEvents(nextEvents);
229 if (needsProjectUiSync) {
230 const projects = useStore.getState().projects;
231 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
232 }
233 const needsThreadUiSync = nextEvents.some(
234 (event) => event.type === "thread.created" || event.type === "thread.deleted",
235 );
236 if (needsThreadUiSync) {
237 const threads = useStore.getState().threads;
238 syncThreads(
239 threads.map((thread) => ({
240 id: thread.id,
241 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
242 })),
243 );
244 }
245 const draftStore = useComposerDraftStore.getState();
246 for (const threadId of batchEffects.clearPromotedDraftThreadIds) {
247 clearPromotedDraftThread(threadId);
248 }
249 for (const threadId of batchEffects.clearDeletedThreadIds) {
250 draftStore.clearDraftThread(threadId);
251 clearThreadUi(threadId);
252 }
253 for (const threadId of batchEffects.removeTerminalStateThreadIds) {
254 removeTerminalState(threadId);
255 }
256 };
257
258 const recoverFromSequenceGap = async (): Promise<void> => {
259 if (!recovery.beginReplayRecovery("sequence-gap")) {
260 return;
261 }
262
263 try {
264 const events = await api.orchestration.replayEvents(recovery.getState().latestSequence);
265 if (!disposed) {
266 applyEventBatch(events);
267 }
268 } catch {
269 recovery.failReplayRecovery();
270 void fallbackToSnapshotRecovery();
271 return;
272 }
273
274 if (!disposed && recovery.completeReplayRecovery()) {
275 void recoverFromSequenceGap();
276 }
277 };
278
279 const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise<void> => {
280 if (!recovery.beginSnapshotRecovery(reason)) {
281 return;
282 }
283
284 try {
285 const snapshot = await api.orchestration.getSnapshot();
286 if (!disposed) {
287 syncServerReadModel(snapshot);
288 reconcileSnapshotDerivedState();
289 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
290 void recoverFromSequenceGap();
291 }
292 }
293 } catch {
294 // Keep prior state and wait for welcome or a later replay attempt.
295 recovery.failSnapshotRecovery();
296 }
297 };
298
299 const bootstrapFromSnapshot = async (): Promise<void> => {
300 await runSnapshotRecovery("bootstrap");
301 };
302
303 const fallbackToSnapshotRecovery = async (): Promise<void> => {
304 await runSnapshotRecovery("replay-failed");
305 };
306
307 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
308 const action = recovery.classifyDomainEvent(event.sequence);
309 if (action === "apply") {
310 applyEventBatch([event]);
311 return;
312 }
313 if (action === "recover") {
314 void recoverFromSequenceGap();
220315 }
221 domainEventFlushThrottler.maybeExecute();
222316 });
223317 const unsubTerminalEvent = api.terminal.onEvent((event) => {
224318 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
......@@ -237,7 +331,7 @@ function EventRouter() {
237331 // Migrate old localStorage settings to server on first connect
238332 migrateLocalSettingsToServer();
239333 void (async () => {
240 await syncSnapshot();
334 await bootstrapFromSnapshot();
241335 if (disposed) {
242336 return;
243337 }
......@@ -319,7 +413,7 @@ function EventRouter() {
319413 return () => {
320414 disposed = true;
321415 needsProviderInvalidation = false;
322 domainEventFlushThrottler.cancel();
416 queryInvalidationThrottler.cancel();
323417 unsubDomainEvent();
324418 unsubTerminalEvent();
325419 unsubWelcome();
......@@ -327,11 +421,16 @@ function EventRouter() {
327421 unsubProvidersUpdated();
328422 };
329423 }, [
424 applyOrchestrationEvents,
330425 navigate,
331426 queryClient,
427 removeTerminalState,
332428 removeOrphanedTerminalStates,
429 clearThreadUi,
333430 setProjectExpanded,
431 syncProjects,
334432 syncServerReadModel,
433 syncThreads,
335434 ]);
336435
337436 return null;
apps/web/src/routes/_chat.$threadId.tsx+4-4
......@@ -161,7 +161,7 @@ const DiffPanelInlineSidebar = (props: {
161161};
162162
163163function ChatThreadRouteView() {
164 const threadsHydrated = useStore((store) => store.threadsHydrated);
164 const bootstrapComplete = useStore((store) => store.bootstrapComplete);
165165 const navigate = useNavigate();
166166 const threadId = Route.useParams({
167167 select: (params) => ThreadId.makeUnsafe(params.threadId),
......@@ -202,7 +202,7 @@ function ChatThreadRouteView() {
202202 }, [diffOpen]);
203203
204204 useEffect(() => {
205 if (!threadsHydrated) {
205 if (!bootstrapComplete) {
206206 return;
207207 }
208208
......@@ -210,9 +210,9 @@ function ChatThreadRouteView() {
210210 void navigate({ to: "/", replace: true });
211211 return;
212212 }
213 }, [navigate, routeThreadExists, threadsHydrated, threadId]);
213 }, [bootstrapComplete, navigate, routeThreadExists, threadId]);
214214
215 if (!threadsHydrated || !routeThreadExists) {
215 if (!bootstrapComplete || !routeThreadExists) {
216216 return null;
217217 }
218218
apps/web/src/routes/_chat.tsx+14-11
......@@ -17,7 +17,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
1717function ChatRouteGlobalShortcuts() {
1818 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
1919 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
20 const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } =
20 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
2121 useHandleNewThread();
2222 const serverConfigQuery = useQuery(serverConfigQueryOptions());
2323 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
......@@ -38,7 +38,7 @@ function ChatRouteGlobalShortcuts() {
3838 return;
3939 }
4040
41 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
41 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId;
4242 if (!projectId) return;
4343
4444 const command = resolveShortcutCommand(event, keybindings, {
......@@ -59,14 +59,17 @@ function ChatRouteGlobalShortcuts() {
5959 return;
6060 }
6161
62 if (command !== "chat.new") return;
63 event.preventDefault();
64 event.stopPropagation();
65 void handleNewThread(projectId, {
66 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
67 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
68 envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
69 });
62 if (command === "chat.new") {
63 event.preventDefault();
64 event.stopPropagation();
65 void handleNewThread(projectId, {
66 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
67 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
68 envMode:
69 activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
70 });
71 return;
72 }
7073 };
7174
7275 window.addEventListener("keydown", onWindowKeyDown);
......@@ -79,7 +82,7 @@ function ChatRouteGlobalShortcuts() {
7982 clearSelection,
8083 handleNewThread,
8184 keybindings,
82 projects,
85 defaultProjectId,
8386 selectedThreadIdsSize,
8487 terminalOpen,
8588 appSettings.defaultThreadEnvMode,
apps/web/src/session-logic.test.ts+32
......@@ -8,6 +8,7 @@ import {
88import { describe, expect, it } from "vitest";
99
1010import {
11 deriveCompletionDividerBeforeEntryId,
1112 deriveActiveWorkStartedAt,
1213 deriveActivePlanState,
1314 PROVIDER_OPTIONS,
......@@ -964,6 +965,37 @@ describe("deriveTimelineEntries", () => {
964965 },
965966 });
966967 });
968
969 it("anchors the completion divider to latestTurn.assistantMessageId before timestamp fallback", () => {
970 const entries = deriveTimelineEntries(
971 [
972 {
973 id: MessageId.makeUnsafe("assistant-earlier"),
974 role: "assistant",
975 text: "progress update",
976 createdAt: "2026-02-23T00:00:01.000Z",
977 streaming: false,
978 },
979 {
980 id: MessageId.makeUnsafe("assistant-final"),
981 role: "assistant",
982 text: "final answer",
983 createdAt: "2026-02-23T00:00:01.000Z",
984 streaming: false,
985 },
986 ],
987 [],
988 [],
989 );
990
991 expect(
992 deriveCompletionDividerBeforeEntryId(entries, {
993 assistantMessageId: MessageId.makeUnsafe("assistant-final"),
994 startedAt: "2026-02-23T00:00:00.000Z",
995 completedAt: "2026-02-23T00:00:02.000Z",
996 }),
997 ).toBe("assistant-final");
998 });
967999});
9681000
9691001describe("deriveWorkLogEntries context window handling", () => {
apps/web/src/session-logic.ts+47
......@@ -852,6 +852,53 @@ export function deriveTimelineEntries(
852852 );
853853}
854854
855export function deriveCompletionDividerBeforeEntryId(
856 timelineEntries: ReadonlyArray<TimelineEntry>,
857 latestTurn: Pick<
858 OrchestrationLatestTurn,
859 "assistantMessageId" | "startedAt" | "completedAt"
860 > | null,
861): string | null {
862 if (!latestTurn?.startedAt || !latestTurn.completedAt) {
863 return null;
864 }
865
866 if (latestTurn.assistantMessageId) {
867 const exactMatch = timelineEntries.find(
868 (timelineEntry) =>
869 timelineEntry.kind === "message" &&
870 timelineEntry.message.role === "assistant" &&
871 timelineEntry.message.id === latestTurn.assistantMessageId,
872 );
873 if (exactMatch) {
874 return exactMatch.id;
875 }
876 }
877
878 const turnStartedAt = Date.parse(latestTurn.startedAt);
879 const turnCompletedAt = Date.parse(latestTurn.completedAt);
880 if (Number.isNaN(turnStartedAt) || Number.isNaN(turnCompletedAt)) {
881 return null;
882 }
883
884 let inRangeMatch: string | null = null;
885 let fallbackMatch: string | null = null;
886 for (const timelineEntry of timelineEntries) {
887 if (timelineEntry.kind !== "message" || timelineEntry.message.role !== "assistant") {
888 continue;
889 }
890 const messageAt = Date.parse(timelineEntry.message.createdAt);
891 if (Number.isNaN(messageAt) || messageAt < turnStartedAt) {
892 continue;
893 }
894 fallbackMatch = timelineEntry.id;
895 if (messageAt <= turnCompletedAt) {
896 inRangeMatch = timelineEntry.id;
897 }
898 }
899 return inRangeMatch ?? fallbackMatch;
900}
901
855902export function inferCheckpointTurnCountByTurnId(
856903 summaries: TurnDiffSummary[],
857904): Record<TurnId, number> {
apps/web/src/store.test.ts+488-94
......@@ -1,13 +1,22 @@
11import {
2 CheckpointRef,
23 DEFAULT_MODEL_BY_PROVIDER,
4 EventId,
5 MessageId,
36 ProjectId,
47 ThreadId,
58 TurnId,
9 type OrchestrationEvent,
610 type OrchestrationReadModel,
711} from "@t3tools/contracts";
812import { describe, expect, it } from "vitest";
913
10import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store";
14import {
15 applyOrchestrationEvent,
16 applyOrchestrationEvents,
17 syncServerReadModel,
18 type AppState,
19} from "./store";
1120import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
1221
1322function makeThread(overrides: Partial<Thread> = {}): Thread {
......@@ -48,15 +57,41 @@ function makeState(thread: Thread): AppState {
4857 provider: "codex",
4958 model: "gpt-5-codex",
5059 },
51 expanded: true,
5260 scripts: [],
5361 },
5462 ],
5563 threads: [thread],
56 threadsHydrated: true,
64 bootstrapComplete: true,
5765 };
5866}
5967
68function makeEvent<T extends OrchestrationEvent["type"]>(
69 type: T,
70 payload: Extract<OrchestrationEvent, { type: T }>["payload"],
71 overrides: Partial<Extract<OrchestrationEvent, { type: T }>> = {},
72): Extract<OrchestrationEvent, { type: T }> {
73 const sequence = overrides.sequence ?? 1;
74 return {
75 sequence,
76 eventId: EventId.makeUnsafe(`event-${sequence}`),
77 aggregateKind: "thread",
78 aggregateId:
79 "threadId" in payload
80 ? payload.threadId
81 : "projectId" in payload
82 ? payload.projectId
83 : ProjectId.makeUnsafe("project-1"),
84 occurredAt: "2026-02-27T00:00:00.000Z",
85 commandId: null,
86 causationEventId: null,
87 correlationId: null,
88 metadata: {},
89 type,
90 payload,
91 ...overrides,
92 } as Extract<OrchestrationEvent, { type: T }>;
93}
94
6095function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
6196 return {
6297 id: ThreadId.makeUnsafe("thread-1"),
......@@ -126,97 +161,18 @@ function makeReadModelProject(
126161 };
127162}
128163
129describe("store pure functions", () => {
130 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
131 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
132 const initialState = makeState(
133 makeThread({
134 latestTurn: {
135 turnId: TurnId.makeUnsafe("turn-1"),
136 state: "completed",
137 requestedAt: "2026-02-25T12:28:00.000Z",
138 startedAt: "2026-02-25T12:28:30.000Z",
139 completedAt: latestTurnCompletedAt,
140 assistantMessageId: null,
141 },
142 lastVisitedAt: "2026-02-25T12:35:00.000Z",
143 }),
144 );
145
146 const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1"));
147
148 const updatedThread = next.threads[0];
149 expect(updatedThread).toBeDefined();
150 expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z");
151 expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan(
152 Date.parse(latestTurnCompletedAt),
153 );
154 });
155
156 it("markThreadUnread does not change a thread without a completed turn", () => {
157 const initialState = makeState(
158 makeThread({
159 latestTurn: null,
160 lastVisitedAt: "2026-02-25T12:35:00.000Z",
161 }),
162 );
163
164 const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1"));
165
166 expect(next).toEqual(initialState);
167 });
168
169 it("reorderProjects moves a project to a target index", () => {
170 const project1 = ProjectId.makeUnsafe("project-1");
171 const project2 = ProjectId.makeUnsafe("project-2");
172 const project3 = ProjectId.makeUnsafe("project-3");
173 const state: AppState = {
174 projects: [
175 {
176 id: project1,
177 name: "Project 1",
178 cwd: "/tmp/project-1",
179 defaultModelSelection: {
180 provider: "codex",
181 model: DEFAULT_MODEL_BY_PROVIDER.codex,
182 },
183 expanded: true,
184 scripts: [],
185 },
186 {
187 id: project2,
188 name: "Project 2",
189 cwd: "/tmp/project-2",
190 defaultModelSelection: {
191 provider: "codex",
192 model: DEFAULT_MODEL_BY_PROVIDER.codex,
193 },
194 expanded: true,
195 scripts: [],
196 },
197 {
198 id: project3,
199 name: "Project 3",
200 cwd: "/tmp/project-3",
201 defaultModelSelection: {
202 provider: "codex",
203 model: DEFAULT_MODEL_BY_PROVIDER.codex,
204 },
205 expanded: true,
206 scripts: [],
207 },
208 ],
209 threads: [],
210 threadsHydrated: true,
164describe("store read model sync", () => {
165 it("marks bootstrap complete after snapshot sync", () => {
166 const initialState: AppState = {
167 ...makeState(makeThread()),
168 bootstrapComplete: false,
211169 };
212170
213 const next = reorderProjects(state, project1, project3);
171 const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({})));
214172
215 expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]);
173 expect(next.bootstrapComplete).toBe(true);
216174 });
217});
218175
219describe("store read model sync", () => {
220176 it("preserves claude model slugs without an active session", () => {
221177 const initialState = makeState(makeThread());
222178 const readModel = makeReadModel(
......@@ -287,7 +243,7 @@ describe("store read model sync", () => {
287243 expect(next.threads[0]?.archivedAt).toBe(archivedAt);
288244 });
289245
290 it("preserves the current project order when syncing incoming read model updates", () => {
246 it("replaces projects using snapshot order during recovery", () => {
291247 const project1 = ProjectId.makeUnsafe("project-1");
292248 const project2 = ProjectId.makeUnsafe("project-2");
293249 const project3 = ProjectId.makeUnsafe("project-3");
......@@ -301,7 +257,6 @@ describe("store read model sync", () => {
301257 provider: "codex",
302258 model: DEFAULT_MODEL_BY_PROVIDER.codex,
303259 },
304 expanded: true,
305260 scripts: [],
306261 },
307262 {
......@@ -312,12 +267,11 @@ describe("store read model sync", () => {
312267 provider: "codex",
313268 model: DEFAULT_MODEL_BY_PROVIDER.codex,
314269 },
315 expanded: true,
316270 scripts: [],
317271 },
318272 ],
319273 threads: [],
320 threadsHydrated: true,
274 bootstrapComplete: true,
321275 };
322276 const readModel: OrchestrationReadModel = {
323277 snapshotSequence: 2,
......@@ -344,6 +298,446 @@ describe("store read model sync", () => {
344298
345299 const next = syncServerReadModel(initialState, readModel);
346300
347 expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]);
301 expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]);
302 });
303});
304
305describe("incremental orchestration updates", () => {
306 it("does not mark bootstrap complete for incremental events", () => {
307 const state: AppState = {
308 ...makeState(makeThread()),
309 bootstrapComplete: false,
310 };
311
312 const next = applyOrchestrationEvent(
313 state,
314 makeEvent("thread.meta-updated", {
315 threadId: ThreadId.makeUnsafe("thread-1"),
316 title: "Updated title",
317 updatedAt: "2026-02-27T00:00:01.000Z",
318 }),
319 );
320
321 expect(next.bootstrapComplete).toBe(false);
322 });
323
324 it("preserves state identity for no-op project and thread deletes", () => {
325 const thread = makeThread();
326 const state = makeState(thread);
327
328 const nextAfterProjectDelete = applyOrchestrationEvent(
329 state,
330 makeEvent("project.deleted", {
331 projectId: ProjectId.makeUnsafe("project-missing"),
332 deletedAt: "2026-02-27T00:00:01.000Z",
333 }),
334 );
335 const nextAfterThreadDelete = applyOrchestrationEvent(
336 state,
337 makeEvent("thread.deleted", {
338 threadId: ThreadId.makeUnsafe("thread-missing"),
339 deletedAt: "2026-02-27T00:00:01.000Z",
340 }),
341 );
342
343 expect(nextAfterProjectDelete).toBe(state);
344 expect(nextAfterThreadDelete).toBe(state);
345 });
346
347 it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => {
348 const originalProjectId = ProjectId.makeUnsafe("project-1");
349 const recreatedProjectId = ProjectId.makeUnsafe("project-2");
350 const state: AppState = {
351 projects: [
352 {
353 id: originalProjectId,
354 name: "Project",
355 cwd: "/tmp/project",
356 defaultModelSelection: {
357 provider: "codex",
358 model: DEFAULT_MODEL_BY_PROVIDER.codex,
359 },
360 scripts: [],
361 },
362 ],
363 threads: [],
364 bootstrapComplete: true,
365 };
366
367 const next = applyOrchestrationEvent(
368 state,
369 makeEvent("project.created", {
370 projectId: recreatedProjectId,
371 title: "Project Recreated",
372 workspaceRoot: "/tmp/project",
373 defaultModelSelection: {
374 provider: "codex",
375 model: DEFAULT_MODEL_BY_PROVIDER.codex,
376 },
377 scripts: [],
378 createdAt: "2026-02-27T00:00:01.000Z",
379 updatedAt: "2026-02-27T00:00:01.000Z",
380 }),
381 );
382
383 expect(next.projects).toHaveLength(1);
384 expect(next.projects[0]?.id).toBe(recreatedProjectId);
385 expect(next.projects[0]?.cwd).toBe("/tmp/project");
386 expect(next.projects[0]?.name).toBe("Project Recreated");
387 });
388
389 it("updates only the affected thread for message events", () => {
390 const thread1 = makeThread({
391 id: ThreadId.makeUnsafe("thread-1"),
392 messages: [
393 {
394 id: MessageId.makeUnsafe("message-1"),
395 role: "assistant",
396 text: "hello",
397 turnId: TurnId.makeUnsafe("turn-1"),
398 createdAt: "2026-02-27T00:00:00.000Z",
399 completedAt: "2026-02-27T00:00:00.000Z",
400 streaming: false,
401 },
402 ],
403 });
404 const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") });
405 const state: AppState = {
406 ...makeState(thread1),
407 threads: [thread1, thread2],
408 };
409
410 const next = applyOrchestrationEvent(
411 state,
412 makeEvent("thread.message-sent", {
413 threadId: thread1.id,
414 messageId: MessageId.makeUnsafe("message-1"),
415 role: "assistant",
416 text: " world",
417 turnId: TurnId.makeUnsafe("turn-1"),
418 streaming: true,
419 createdAt: "2026-02-27T00:00:01.000Z",
420 updatedAt: "2026-02-27T00:00:01.000Z",
421 }),
422 );
423
424 expect(next.threads[0]?.messages[0]?.text).toBe("hello world");
425 expect(next.threads[0]?.latestTurn?.state).toBe("running");
426 expect(next.threads[1]).toBe(thread2);
427 });
428
429 it("applies replay batches in sequence and updates session state", () => {
430 const thread = makeThread({
431 latestTurn: {
432 turnId: TurnId.makeUnsafe("turn-1"),
433 state: "running",
434 requestedAt: "2026-02-27T00:00:00.000Z",
435 startedAt: "2026-02-27T00:00:00.000Z",
436 completedAt: null,
437 assistantMessageId: null,
438 },
439 });
440 const state = makeState(thread);
441
442 const next = applyOrchestrationEvents(state, [
443 makeEvent(
444 "thread.session-set",
445 {
446 threadId: thread.id,
447 session: {
448 threadId: thread.id,
449 status: "running",
450 providerName: "codex",
451 runtimeMode: "full-access",
452 activeTurnId: TurnId.makeUnsafe("turn-1"),
453 lastError: null,
454 updatedAt: "2026-02-27T00:00:02.000Z",
455 },
456 },
457 { sequence: 2 },
458 ),
459 makeEvent(
460 "thread.message-sent",
461 {
462 threadId: thread.id,
463 messageId: MessageId.makeUnsafe("assistant-1"),
464 role: "assistant",
465 text: "done",
466 turnId: TurnId.makeUnsafe("turn-1"),
467 streaming: false,
468 createdAt: "2026-02-27T00:00:03.000Z",
469 updatedAt: "2026-02-27T00:00:03.000Z",
470 },
471 { sequence: 3 },
472 ),
473 ]);
474
475 expect(next.threads[0]?.session?.status).toBe("running");
476 expect(next.threads[0]?.latestTurn?.state).toBe("completed");
477 expect(next.threads[0]?.messages).toHaveLength(1);
478 });
479
480 it("does not regress latestTurn when an older turn diff completes late", () => {
481 const state = makeState(
482 makeThread({
483 latestTurn: {
484 turnId: TurnId.makeUnsafe("turn-2"),
485 state: "running",
486 requestedAt: "2026-02-27T00:00:02.000Z",
487 startedAt: "2026-02-27T00:00:03.000Z",
488 completedAt: null,
489 assistantMessageId: null,
490 },
491 }),
492 );
493
494 const next = applyOrchestrationEvent(
495 state,
496 makeEvent("thread.turn-diff-completed", {
497 threadId: ThreadId.makeUnsafe("thread-1"),
498 turnId: TurnId.makeUnsafe("turn-1"),
499 checkpointTurnCount: 1,
500 checkpointRef: CheckpointRef.makeUnsafe("checkpoint-1"),
501 status: "ready",
502 files: [],
503 assistantMessageId: MessageId.makeUnsafe("assistant-1"),
504 completedAt: "2026-02-27T00:00:04.000Z",
505 }),
506 );
507
508 expect(next.threads[0]?.turnDiffSummaries).toHaveLength(1);
509 expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn);
510 });
511
512 it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => {
513 const turnId = TurnId.makeUnsafe("turn-1");
514 const state = makeState(
515 makeThread({
516 latestTurn: {
517 turnId,
518 state: "completed",
519 requestedAt: "2026-02-27T00:00:00.000Z",
520 startedAt: "2026-02-27T00:00:00.000Z",
521 completedAt: "2026-02-27T00:00:02.000Z",
522 assistantMessageId: MessageId.makeUnsafe("assistant:turn-1"),
523 },
524 turnDiffSummaries: [
525 {
526 turnId,
527 completedAt: "2026-02-27T00:00:02.000Z",
528 status: "ready",
529 checkpointTurnCount: 1,
530 checkpointRef: CheckpointRef.makeUnsafe("checkpoint-1"),
531 assistantMessageId: MessageId.makeUnsafe("assistant:turn-1"),
532 files: [{ path: "src/app.ts", additions: 1, deletions: 0 }],
533 },
534 ],
535 }),
536 );
537
538 const next = applyOrchestrationEvent(
539 state,
540 makeEvent("thread.message-sent", {
541 threadId: ThreadId.makeUnsafe("thread-1"),
542 messageId: MessageId.makeUnsafe("assistant-real"),
543 role: "assistant",
544 text: "final answer",
545 turnId,
546 streaming: false,
547 createdAt: "2026-02-27T00:00:03.000Z",
548 updatedAt: "2026-02-27T00:00:03.000Z",
549 }),
550 );
551
552 expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(
553 MessageId.makeUnsafe("assistant-real"),
554 );
555 expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe(
556 MessageId.makeUnsafe("assistant-real"),
557 );
558 });
559
560 it("reverts messages, plans, activities, and checkpoints by retained turns", () => {
561 const state = makeState(
562 makeThread({
563 messages: [
564 {
565 id: MessageId.makeUnsafe("user-1"),
566 role: "user",
567 text: "first",
568 turnId: TurnId.makeUnsafe("turn-1"),
569 createdAt: "2026-02-27T00:00:00.000Z",
570 completedAt: "2026-02-27T00:00:00.000Z",
571 streaming: false,
572 },
573 {
574 id: MessageId.makeUnsafe("assistant-1"),
575 role: "assistant",
576 text: "first reply",
577 turnId: TurnId.makeUnsafe("turn-1"),
578 createdAt: "2026-02-27T00:00:01.000Z",
579 completedAt: "2026-02-27T00:00:01.000Z",
580 streaming: false,
581 },
582 {
583 id: MessageId.makeUnsafe("user-2"),
584 role: "user",
585 text: "second",
586 turnId: TurnId.makeUnsafe("turn-2"),
587 createdAt: "2026-02-27T00:00:02.000Z",
588 completedAt: "2026-02-27T00:00:02.000Z",
589 streaming: false,
590 },
591 ],
592 proposedPlans: [
593 {
594 id: "plan-1",
595 turnId: TurnId.makeUnsafe("turn-1"),
596 planMarkdown: "plan 1",
597 implementedAt: null,
598 implementationThreadId: null,
599 createdAt: "2026-02-27T00:00:00.000Z",
600 updatedAt: "2026-02-27T00:00:00.000Z",
601 },
602 {
603 id: "plan-2",
604 turnId: TurnId.makeUnsafe("turn-2"),
605 planMarkdown: "plan 2",
606 implementedAt: null,
607 implementationThreadId: null,
608 createdAt: "2026-02-27T00:00:02.000Z",
609 updatedAt: "2026-02-27T00:00:02.000Z",
610 },
611 ],
612 activities: [
613 {
614 id: EventId.makeUnsafe("activity-1"),
615 tone: "info",
616 kind: "step",
617 summary: "one",
618 payload: {},
619 turnId: TurnId.makeUnsafe("turn-1"),
620 createdAt: "2026-02-27T00:00:00.000Z",
621 },
622 {
623 id: EventId.makeUnsafe("activity-2"),
624 tone: "info",
625 kind: "step",
626 summary: "two",
627 payload: {},
628 turnId: TurnId.makeUnsafe("turn-2"),
629 createdAt: "2026-02-27T00:00:02.000Z",
630 },
631 ],
632 turnDiffSummaries: [
633 {
634 turnId: TurnId.makeUnsafe("turn-1"),
635 completedAt: "2026-02-27T00:00:01.000Z",
636 status: "ready",
637 checkpointTurnCount: 1,
638 checkpointRef: CheckpointRef.makeUnsafe("ref-1"),
639 files: [],
640 },
641 {
642 turnId: TurnId.makeUnsafe("turn-2"),
643 completedAt: "2026-02-27T00:00:03.000Z",
644 status: "ready",
645 checkpointTurnCount: 2,
646 checkpointRef: CheckpointRef.makeUnsafe("ref-2"),
647 files: [],
648 },
649 ],
650 }),
651 );
652
653 const next = applyOrchestrationEvent(
654 state,
655 makeEvent("thread.reverted", {
656 threadId: ThreadId.makeUnsafe("thread-1"),
657 turnCount: 1,
658 }),
659 );
660
661 expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([
662 "user-1",
663 "assistant-1",
664 ]);
665 expect(next.threads[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]);
666 expect(next.threads[0]?.activities.map((activity) => activity.id)).toEqual([
667 EventId.makeUnsafe("activity-1"),
668 ]);
669 expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
670 TurnId.makeUnsafe("turn-1"),
671 ]);
672 });
673
674 it("clears pending source proposed plans after revert before a new session-set event", () => {
675 const thread = makeThread({
676 latestTurn: {
677 turnId: TurnId.makeUnsafe("turn-2"),
678 state: "completed",
679 requestedAt: "2026-02-27T00:00:02.000Z",
680 startedAt: "2026-02-27T00:00:02.000Z",
681 completedAt: "2026-02-27T00:00:03.000Z",
682 assistantMessageId: MessageId.makeUnsafe("assistant-2"),
683 sourceProposedPlan: {
684 threadId: ThreadId.makeUnsafe("thread-source"),
685 planId: "plan-2" as never,
686 },
687 },
688 pendingSourceProposedPlan: {
689 threadId: ThreadId.makeUnsafe("thread-source"),
690 planId: "plan-2" as never,
691 },
692 turnDiffSummaries: [
693 {
694 turnId: TurnId.makeUnsafe("turn-1"),
695 completedAt: "2026-02-27T00:00:01.000Z",
696 status: "ready",
697 checkpointTurnCount: 1,
698 checkpointRef: CheckpointRef.makeUnsafe("ref-1"),
699 files: [],
700 },
701 {
702 turnId: TurnId.makeUnsafe("turn-2"),
703 completedAt: "2026-02-27T00:00:03.000Z",
704 status: "ready",
705 checkpointTurnCount: 2,
706 checkpointRef: CheckpointRef.makeUnsafe("ref-2"),
707 files: [],
708 },
709 ],
710 });
711 const reverted = applyOrchestrationEvent(
712 makeState(thread),
713 makeEvent("thread.reverted", {
714 threadId: thread.id,
715 turnCount: 1,
716 }),
717 );
718
719 expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined();
720
721 const next = applyOrchestrationEvent(
722 reverted,
723 makeEvent("thread.session-set", {
724 threadId: thread.id,
725 session: {
726 threadId: thread.id,
727 status: "running",
728 providerName: "codex",
729 runtimeMode: "full-access",
730 activeTurnId: TurnId.makeUnsafe("turn-3"),
731 lastError: null,
732 updatedAt: "2026-02-27T00:00:04.000Z",
733 },
734 }),
735 );
736
737 expect(next.threads[0]?.latestTurn).toMatchObject({
738 turnId: TurnId.makeUnsafe("turn-3"),
739 state: "running",
740 });
741 expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined();
348742 });
349743});
apps/web/src/store.ts+784-304
......@@ -1,98 +1,36 @@
1import { Fragment, type ReactNode, createElement, useEffect } from "react";
21import {
2 type OrchestrationEvent,
3 type OrchestrationMessage,
4 type OrchestrationProposedPlan,
35 type ProviderKind,
46 ThreadId,
57 type OrchestrationReadModel,
8 type OrchestrationSession,
9 type OrchestrationCheckpointSummary,
10 type OrchestrationThread,
611 type OrchestrationSessionStatus,
712} from "@t3tools/contracts";
813import { resolveModelSlugForProvider } from "@t3tools/shared/model";
914import { create } from "zustand";
1015import { type ChatMessage, type Project, type Thread } from "./types";
11import { Debouncer } from "@tanstack/react-pacer";
1216
1317// ── State ────────────────────────────────────────────────────────────
1418
1519export interface AppState {
1620 projects: Project[];
1721 threads: Thread[];
18 threadsHydrated: boolean;
22 bootstrapComplete: boolean;
1923}
2024
21const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
22const LEGACY_PERSISTED_STATE_KEYS = [
23 "t3code:renderer-state:v7",
24 "t3code:renderer-state:v6",
25 "t3code:renderer-state:v5",
26 "t3code:renderer-state:v4",
27 "t3code:renderer-state:v3",
28 "codething:renderer-state:v4",
29 "codething:renderer-state:v3",
30 "codething:renderer-state:v2",
31 "codething:renderer-state:v1",
32] as const;
33
3425const initialState: AppState = {
3526 projects: [],
3627 threads: [],
37 threadsHydrated: false,
28 bootstrapComplete: false,
3829};
39const persistedExpandedProjectCwds = new Set<string>();
40const persistedProjectOrderCwds: string[] = [];
41
42// ── Persist helpers ──────────────────────────────────────────────────
43
44function readPersistedState(): AppState {
45 if (typeof window === "undefined") return initialState;
46 try {
47 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
48 if (!raw) return initialState;
49 const parsed = JSON.parse(raw) as {
50 expandedProjectCwds?: string[];
51 projectOrderCwds?: string[];
52 };
53 persistedExpandedProjectCwds.clear();
54 persistedProjectOrderCwds.length = 0;
55 for (const cwd of parsed.expandedProjectCwds ?? []) {
56 if (typeof cwd === "string" && cwd.length > 0) {
57 persistedExpandedProjectCwds.add(cwd);
58 }
59 }
60 for (const cwd of parsed.projectOrderCwds ?? []) {
61 if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {
62 persistedProjectOrderCwds.push(cwd);
63 }
64 }
65 return { ...initialState };
66 } catch {
67 return initialState;
68 }
69}
70
71let legacyKeysCleanedUp = false;
72
73function persistState(state: AppState): void {
74 if (typeof window === "undefined") return;
75 try {
76 window.localStorage.setItem(
77 PERSISTED_STATE_KEY,
78 JSON.stringify({
79 expandedProjectCwds: state.projects
80 .filter((project) => project.expanded)
81 .map((project) => project.cwd),
82 projectOrderCwds: state.projects.map((project) => project.cwd),
83 }),
84 );
85 if (!legacyKeysCleanedUp) {
86 legacyKeysCleanedUp = true;
87 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
88 window.localStorage.removeItem(legacyKey);
89 }
90 }
91 } catch {
92 // Ignore quota/storage errors to avoid breaking chat UX.
93 }
94}
95const debouncedPersistState = new Debouncer(persistState, { wait: 500 });
30const MAX_THREAD_MESSAGES = 2_000;
31const MAX_THREAD_CHECKPOINTS = 500;
32const MAX_THREAD_PROPOSED_PLANS = 200;
33const MAX_THREAD_ACTIVITIES = 500;
9634
9735// ── Pure helpers ──────────────────────────────────────────────────────
9836
......@@ -111,66 +49,295 @@ function updateThread(
11149 return changed ? next : threads;
11250}
11351
114function mapProjectsFromReadModel(
115 incoming: OrchestrationReadModel["projects"],
116 previous: Project[],
52function updateProject(
53 projects: Project[],
54 projectId: Project["id"],
55 updater: (project: Project) => Project,
11756): Project[] {
118 const previousById = new Map(previous.map((project) => [project.id, project] as const));
119 const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const));
120 const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));
121 const previousOrderByCwd = new Map(
122 previous.map((project, index) => [project.cwd, index] as const),
123 );
124 const persistedOrderByCwd = new Map(
125 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
126 );
127 const usePersistedOrder = previous.length === 0;
57 let changed = false;
58 const next = projects.map((project) => {
59 if (project.id !== projectId) {
60 return project;
61 }
62 const updated = updater(project);
63 if (updated !== project) {
64 changed = true;
65 }
66 return updated;
67 });
68 return changed ? next : projects;
69}
70
71function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>(
72 selection: T,
73): T {
74 return {
75 ...selection,
76 model: resolveModelSlugForProvider(selection.provider, selection.model),
77 };
78}
12879
129 const mappedProjects = incoming.map((project) => {
130 const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot);
80function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): Project["scripts"] {
81 return scripts.map((script) => ({ ...script }));
82}
83
84function mapSession(session: OrchestrationSession): Thread["session"] {
85 return {
86 provider: toLegacyProvider(session.providerName),
87 status: toLegacySessionStatus(session.status),
88 orchestrationStatus: session.status,
89 activeTurnId: session.activeTurnId ?? undefined,
90 createdAt: session.updatedAt,
91 updatedAt: session.updatedAt,
92 ...(session.lastError ? { lastError: session.lastError } : {}),
93 };
94}
95
96function mapMessage(message: OrchestrationMessage): ChatMessage {
97 const attachments = message.attachments?.map((attachment) => ({
98 type: "image" as const,
99 id: attachment.id,
100 name: attachment.name,
101 mimeType: attachment.mimeType,
102 sizeBytes: attachment.sizeBytes,
103 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
104 }));
105
106 return {
107 id: message.id,
108 role: message.role,
109 text: message.text,
110 turnId: message.turnId,
111 createdAt: message.createdAt,
112 streaming: message.streaming,
113 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
114 ...(attachments && attachments.length > 0 ? { attachments } : {}),
115 };
116}
117
118function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] {
119 return {
120 id: proposedPlan.id,
121 turnId: proposedPlan.turnId,
122 planMarkdown: proposedPlan.planMarkdown,
123 implementedAt: proposedPlan.implementedAt,
124 implementationThreadId: proposedPlan.implementationThreadId,
125 createdAt: proposedPlan.createdAt,
126 updatedAt: proposedPlan.updatedAt,
127 };
128}
129
130function mapTurnDiffSummary(
131 checkpoint: OrchestrationCheckpointSummary,
132): Thread["turnDiffSummaries"][number] {
133 return {
134 turnId: checkpoint.turnId,
135 completedAt: checkpoint.completedAt,
136 status: checkpoint.status,
137 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
138 checkpointTurnCount: checkpoint.checkpointTurnCount,
139 checkpointRef: checkpoint.checkpointRef,
140 files: checkpoint.files.map((file) => ({ ...file })),
141 };
142}
143
144function mapThread(thread: OrchestrationThread): Thread {
145 return {
146 id: thread.id,
147 codexThreadId: null,
148 projectId: thread.projectId,
149 title: thread.title,
150 modelSelection: normalizeModelSelection(thread.modelSelection),
151 runtimeMode: thread.runtimeMode,
152 interactionMode: thread.interactionMode,
153 session: thread.session ? mapSession(thread.session) : null,
154 messages: thread.messages.map(mapMessage),
155 proposedPlans: thread.proposedPlans.map(mapProposedPlan),
156 error: thread.session?.lastError ?? null,
157 createdAt: thread.createdAt,
158 archivedAt: thread.archivedAt,
159 updatedAt: thread.updatedAt,
160 latestTurn: thread.latestTurn,
161 pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan,
162 branch: thread.branch,
163 worktreePath: thread.worktreePath,
164 turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary),
165 activities: thread.activities.map((activity) => ({ ...activity })),
166 };
167}
168
169function mapProject(project: OrchestrationReadModel["projects"][number]): Project {
170 return {
171 id: project.id,
172 name: project.title,
173 cwd: project.workspaceRoot,
174 defaultModelSelection: project.defaultModelSelection
175 ? normalizeModelSelection(project.defaultModelSelection)
176 : null,
177 createdAt: project.createdAt,
178 updatedAt: project.updatedAt,
179 scripts: mapProjectScripts(project.scripts),
180 };
181}
182
183function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {
184 if (status === "error") {
185 return "error" as const;
186 }
187 if (status === "missing") {
188 return "interrupted" as const;
189 }
190 return "completed" as const;
191}
192
193function compareActivities(
194 left: Thread["activities"][number],
195 right: Thread["activities"][number],
196): number {
197 if (left.sequence !== undefined && right.sequence !== undefined) {
198 if (left.sequence !== right.sequence) {
199 return left.sequence - right.sequence;
200 }
201 } else if (left.sequence !== undefined) {
202 return 1;
203 } else if (right.sequence !== undefined) {
204 return -1;
205 }
206
207 return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
208}
209
210function buildLatestTurn(params: {
211 previous: Thread["latestTurn"];
212 turnId: NonNullable<Thread["latestTurn"]>["turnId"];
213 state: NonNullable<Thread["latestTurn"]>["state"];
214 requestedAt: string;
215 startedAt: string | null;
216 completedAt: string | null;
217 assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"];
218 sourceProposedPlan?: Thread["pendingSourceProposedPlan"];
219}): NonNullable<Thread["latestTurn"]> {
220 const resolvedPlan =
221 params.previous?.turnId === params.turnId
222 ? params.previous.sourceProposedPlan
223 : params.sourceProposedPlan;
224 return {
225 turnId: params.turnId,
226 state: params.state,
227 requestedAt: params.requestedAt,
228 startedAt: params.startedAt,
229 completedAt: params.completedAt,
230 assistantMessageId: params.assistantMessageId,
231 ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}),
232 };
233}
234
235function rebindTurnDiffSummariesForAssistantMessage(
236 turnDiffSummaries: ReadonlyArray<Thread["turnDiffSummaries"][number]>,
237 turnId: Thread["turnDiffSummaries"][number]["turnId"],
238 assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"],
239): Thread["turnDiffSummaries"] {
240 let changed = false;
241 const nextSummaries = turnDiffSummaries.map((summary) => {
242 if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) {
243 return summary;
244 }
245 changed = true;
131246 return {
132 id: project.id,
133 name: project.title,
134 cwd: project.workspaceRoot,
135 defaultModelSelection:
136 existing?.defaultModelSelection ??
137 (project.defaultModelSelection
138 ? {
139 ...project.defaultModelSelection,
140 model: resolveModelSlugForProvider(
141 project.defaultModelSelection.provider,
142 project.defaultModelSelection.model,
143 ),
144 }
145 : null),
146 expanded:
147 existing?.expanded ??
148 (persistedExpandedProjectCwds.size > 0
149 ? persistedExpandedProjectCwds.has(project.workspaceRoot)
150 : true),
151 createdAt: project.createdAt,
152 updatedAt: project.updatedAt,
153 scripts: project.scripts.map((script) => ({ ...script })),
154 } satisfies Project;
247 ...summary,
248 assistantMessageId: assistantMessageId ?? undefined,
249 };
155250 });
251 return changed ? nextSummaries : [...turnDiffSummaries];
252}
253
254function retainThreadMessagesAfterRevert(
255 messages: ReadonlyArray<ChatMessage>,
256 retainedTurnIds: ReadonlySet<string>,
257 turnCount: number,
258): ChatMessage[] {
259 const retainedMessageIds = new Set<string>();
260 for (const message of messages) {
261 if (message.role === "system") {
262 retainedMessageIds.add(message.id);
263 continue;
264 }
265 if (
266 message.turnId !== undefined &&
267 message.turnId !== null &&
268 retainedTurnIds.has(message.turnId)
269 ) {
270 retainedMessageIds.add(message.id);
271 }
272 }
273
274 const retainedUserCount = messages.filter(
275 (message) => message.role === "user" && retainedMessageIds.has(message.id),
276 ).length;
277 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
278 if (missingUserCount > 0) {
279 const fallbackUserMessages = messages
280 .filter(
281 (message) =>
282 message.role === "user" &&
283 !retainedMessageIds.has(message.id) &&
284 (message.turnId === undefined ||
285 message.turnId === null ||
286 retainedTurnIds.has(message.turnId)),
287 )
288 .toSorted(
289 (left, right) =>
290 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
291 )
292 .slice(0, missingUserCount);
293 for (const message of fallbackUserMessages) {
294 retainedMessageIds.add(message.id);
295 }
296 }
297
298 const retainedAssistantCount = messages.filter(
299 (message) => message.role === "assistant" && retainedMessageIds.has(message.id),
300 ).length;
301 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
302 if (missingAssistantCount > 0) {
303 const fallbackAssistantMessages = messages
304 .filter(
305 (message) =>
306 message.role === "assistant" &&
307 !retainedMessageIds.has(message.id) &&
308 (message.turnId === undefined ||
309 message.turnId === null ||
310 retainedTurnIds.has(message.turnId)),
311 )
312 .toSorted(
313 (left, right) =>
314 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
315 )
316 .slice(0, missingAssistantCount);
317 for (const message of fallbackAssistantMessages) {
318 retainedMessageIds.add(message.id);
319 }
320 }
321
322 return messages.filter((message) => retainedMessageIds.has(message.id));
323}
324
325function retainThreadActivitiesAfterRevert(
326 activities: ReadonlyArray<Thread["activities"][number]>,
327 retainedTurnIds: ReadonlySet<string>,
328): Thread["activities"] {
329 return activities.filter(
330 (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
331 );
332}
156333
157 return mappedProjects
158 .map((project, incomingIndex) => {
159 const previousIndex =
160 previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);
161 const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined;
162 const orderIndex =
163 previousIndex ??
164 persistedIndex ??
165 (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex;
166 return { project, incomingIndex, orderIndex };
167 })
168 .toSorted((a, b) => {
169 const byOrder = a.orderIndex - b.orderIndex;
170 if (byOrder !== 0) return byOrder;
171 return a.incomingIndex - b.incomingIndex;
172 })
173 .map((entry) => entry.project);
334function retainThreadProposedPlansAfterRevert(
335 proposedPlans: ReadonlyArray<Thread["proposedPlans"][number]>,
336 retainedTurnIds: ReadonlySet<string>,
337): Thread["proposedPlans"] {
338 return proposedPlans.filter(
339 (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId),
340 );
174341}
175342
176343function toLegacySessionStatus(
......@@ -234,167 +401,506 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
234401// ── Pure state transition functions ────────────────────────────────────
235402
236403export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
237 const projects = mapProjectsFromReadModel(
238 readModel.projects.filter((project) => project.deletedAt === null),
239 state.projects,
240 );
241 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));
242 const threads = readModel.threads
243 .filter((thread) => thread.deletedAt === null)
244 .map((thread) => {
245 const existing = existingThreadById.get(thread.id);
246 return {
247 id: thread.id,
248 codexThreadId: null,
249 projectId: thread.projectId,
250 title: thread.title,
251 modelSelection: {
252 ...thread.modelSelection,
253 model: resolveModelSlugForProvider(
254 thread.modelSelection.provider,
255 thread.modelSelection.model,
256 ),
257 },
258 runtimeMode: thread.runtimeMode,
259 interactionMode: thread.interactionMode,
260 session: thread.session
261 ? {
262 provider: toLegacyProvider(thread.session.providerName),
263 status: toLegacySessionStatus(thread.session.status),
264 orchestrationStatus: thread.session.status,
265 activeTurnId: thread.session.activeTurnId ?? undefined,
266 createdAt: thread.session.updatedAt,
267 updatedAt: thread.session.updatedAt,
268 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
269 }
270 : null,
271 messages: thread.messages.map((message) => {
272 const attachments = message.attachments?.map((attachment) => ({
273 type: "image" as const,
274 id: attachment.id,
275 name: attachment.name,
276 mimeType: attachment.mimeType,
277 sizeBytes: attachment.sizeBytes,
278 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
279 }));
280 const normalizedMessage: ChatMessage = {
281 id: message.id,
282 role: message.role,
283 text: message.text,
284 createdAt: message.createdAt,
285 streaming: message.streaming,
286 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
287 ...(attachments && attachments.length > 0 ? { attachments } : {}),
288 };
289 return normalizedMessage;
290 }),
291 proposedPlans: thread.proposedPlans.map((proposedPlan) => ({
292 id: proposedPlan.id,
293 turnId: proposedPlan.turnId,
294 planMarkdown: proposedPlan.planMarkdown,
295 implementedAt: proposedPlan.implementedAt,
296 implementationThreadId: proposedPlan.implementationThreadId,
297 createdAt: proposedPlan.createdAt,
298 updatedAt: proposedPlan.updatedAt,
299 })),
300 error: thread.session?.lastError ?? null,
301 createdAt: thread.createdAt,
302 archivedAt: thread.archivedAt,
303 updatedAt: thread.updatedAt,
304 latestTurn: thread.latestTurn,
305 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
306 branch: thread.branch,
307 worktreePath: thread.worktreePath,
308 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
309 turnId: checkpoint.turnId,
310 completedAt: checkpoint.completedAt,
311 status: checkpoint.status,
312 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
313 checkpointTurnCount: checkpoint.checkpointTurnCount,
314 checkpointRef: checkpoint.checkpointRef,
315 files: checkpoint.files.map((file) => ({ ...file })),
316 })),
317 activities: thread.activities.map((activity) => ({ ...activity })),
318 };
319 });
404 const projects = readModel.projects
405 .filter((project) => project.deletedAt === null)
406 .map(mapProject);
407 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
320408 return {
321409 ...state,
322410 projects,
323411 threads,
324 threadsHydrated: true,
412 bootstrapComplete: true,
325413 };
326414}
327415
328export function markThreadVisited(
329 state: AppState,
330 threadId: ThreadId,
331 visitedAt?: string,
332): AppState {
333 const at = visitedAt ?? new Date().toISOString();
334 const visitedAtMs = Date.parse(at);
335 const threads = updateThread(state.threads, threadId, (thread) => {
336 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
337 if (
338 Number.isFinite(previousVisitedAtMs) &&
339 Number.isFinite(visitedAtMs) &&
340 previousVisitedAtMs >= visitedAtMs
341 ) {
342 return thread;
416export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState {
417 switch (event.type) {
418 case "project.created": {
419 const existingIndex = state.projects.findIndex(
420 (project) =>
421 project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot,
422 );
423 const nextProject = mapProject({
424 id: event.payload.projectId,
425 title: event.payload.title,
426 workspaceRoot: event.payload.workspaceRoot,
427 defaultModelSelection: event.payload.defaultModelSelection,
428 scripts: event.payload.scripts,
429 createdAt: event.payload.createdAt,
430 updatedAt: event.payload.updatedAt,
431 deletedAt: null,
432 });
433 const projects =
434 existingIndex >= 0
435 ? state.projects.map((project, index) =>
436 index === existingIndex ? nextProject : project,
437 )
438 : [...state.projects, nextProject];
439 return { ...state, projects };
343440 }
344 return { ...thread, lastVisitedAt: at };
345 });
346 return threads === state.threads ? state : { ...state, threads };
347}
348441
349export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {
350 const threads = updateThread(state.threads, threadId, (thread) => {
351 if (!thread.latestTurn?.completedAt) return thread;
352 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
353 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
354 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
355 if (thread.lastVisitedAt === unreadVisitedAt) return thread;
356 return { ...thread, lastVisitedAt: unreadVisitedAt };
357 });
358 return threads === state.threads ? state : { ...state, threads };
359}
442 case "project.meta-updated": {
443 const projects = updateProject(state.projects, event.payload.projectId, (project) => ({
444 ...project,
445 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),
446 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),
447 ...(event.payload.defaultModelSelection !== undefined
448 ? {
449 defaultModelSelection: event.payload.defaultModelSelection
450 ? normalizeModelSelection(event.payload.defaultModelSelection)
451 : null,
452 }
453 : {}),
454 ...(event.payload.scripts !== undefined
455 ? { scripts: mapProjectScripts(event.payload.scripts) }
456 : {}),
457 updatedAt: event.payload.updatedAt,
458 }));
459 return projects === state.projects ? state : { ...state, projects };
460 }
360461
361export function toggleProject(state: AppState, projectId: Project["id"]): AppState {
362 return {
363 ...state,
364 projects: state.projects.map((p) => (p.id === projectId ? { ...p, expanded: !p.expanded } : p)),
365 };
366}
462 case "project.deleted": {
463 const projects = state.projects.filter((project) => project.id !== event.payload.projectId);
464 return projects.length === state.projects.length ? state : { ...state, projects };
465 }
367466
368export function setProjectExpanded(
369 state: AppState,
370 projectId: Project["id"],
371 expanded: boolean,
372): AppState {
373 let changed = false;
374 const projects = state.projects.map((p) => {
375 if (p.id !== projectId || p.expanded === expanded) return p;
376 changed = true;
377 return { ...p, expanded };
378 });
379 return changed ? { ...state, projects } : state;
467 case "thread.created": {
468 const existing = state.threads.find((thread) => thread.id === event.payload.threadId);
469 const nextThread = mapThread({
470 id: event.payload.threadId,
471 projectId: event.payload.projectId,
472 title: event.payload.title,
473 modelSelection: event.payload.modelSelection,
474 runtimeMode: event.payload.runtimeMode,
475 interactionMode: event.payload.interactionMode,
476 branch: event.payload.branch,
477 worktreePath: event.payload.worktreePath,
478 latestTurn: null,
479 createdAt: event.payload.createdAt,
480 updatedAt: event.payload.updatedAt,
481 archivedAt: null,
482 deletedAt: null,
483 messages: [],
484 proposedPlans: [],
485 activities: [],
486 checkpoints: [],
487 session: null,
488 });
489 const threads = existing
490 ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread))
491 : [...state.threads, nextThread];
492 return { ...state, threads };
493 }
494
495 case "thread.deleted": {
496 const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId);
497 return threads.length === state.threads.length ? state : { ...state, threads };
498 }
499
500 case "thread.archived": {
501 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
502 ...thread,
503 archivedAt: event.payload.archivedAt,
504 updatedAt: event.payload.updatedAt,
505 }));
506 return threads === state.threads ? state : { ...state, threads };
507 }
508
509 case "thread.unarchived": {
510 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
511 ...thread,
512 archivedAt: null,
513 updatedAt: event.payload.updatedAt,
514 }));
515 return threads === state.threads ? state : { ...state, threads };
516 }
517
518 case "thread.meta-updated": {
519 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
520 ...thread,
521 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
522 ...(event.payload.modelSelection !== undefined
523 ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) }
524 : {}),
525 ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
526 ...(event.payload.worktreePath !== undefined
527 ? { worktreePath: event.payload.worktreePath }
528 : {}),
529 updatedAt: event.payload.updatedAt,
530 }));
531 return threads === state.threads ? state : { ...state, threads };
532 }
533
534 case "thread.runtime-mode-set": {
535 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
536 ...thread,
537 runtimeMode: event.payload.runtimeMode,
538 updatedAt: event.payload.updatedAt,
539 }));
540 return threads === state.threads ? state : { ...state, threads };
541 }
542
543 case "thread.interaction-mode-set": {
544 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
545 ...thread,
546 interactionMode: event.payload.interactionMode,
547 updatedAt: event.payload.updatedAt,
548 }));
549 return threads === state.threads ? state : { ...state, threads };
550 }
551
552 case "thread.turn-start-requested": {
553 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
554 ...thread,
555 ...(event.payload.modelSelection !== undefined
556 ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) }
557 : {}),
558 runtimeMode: event.payload.runtimeMode,
559 interactionMode: event.payload.interactionMode,
560 pendingSourceProposedPlan: event.payload.sourceProposedPlan,
561 updatedAt: event.occurredAt,
562 }));
563 return threads === state.threads ? state : { ...state, threads };
564 }
565
566 case "thread.turn-interrupt-requested": {
567 if (event.payload.turnId === undefined) {
568 return state;
569 }
570 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
571 const latestTurn = thread.latestTurn;
572 if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) {
573 return thread;
574 }
575 return {
576 ...thread,
577 latestTurn: buildLatestTurn({
578 previous: latestTurn,
579 turnId: event.payload.turnId,
580 state: "interrupted",
581 requestedAt: latestTurn.requestedAt,
582 startedAt: latestTurn.startedAt ?? event.payload.createdAt,
583 completedAt: latestTurn.completedAt ?? event.payload.createdAt,
584 assistantMessageId: latestTurn.assistantMessageId,
585 }),
586 updatedAt: event.occurredAt,
587 };
588 });
589 return threads === state.threads ? state : { ...state, threads };
590 }
591
592 case "thread.message-sent": {
593 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
594 const message = mapMessage({
595 id: event.payload.messageId,
596 role: event.payload.role,
597 text: event.payload.text,
598 ...(event.payload.attachments !== undefined
599 ? { attachments: event.payload.attachments }
600 : {}),
601 turnId: event.payload.turnId,
602 streaming: event.payload.streaming,
603 createdAt: event.payload.createdAt,
604 updatedAt: event.payload.updatedAt,
605 });
606 const existingMessage = thread.messages.find((entry) => entry.id === message.id);
607 const messages = existingMessage
608 ? thread.messages.map((entry) =>
609 entry.id !== message.id
610 ? entry
611 : {
612 ...entry,
613 text: message.streaming
614 ? `${entry.text}${message.text}`
615 : message.text.length > 0
616 ? message.text
617 : entry.text,
618 streaming: message.streaming,
619 ...(message.turnId !== undefined ? { turnId: message.turnId } : {}),
620 ...(message.streaming
621 ? entry.completedAt !== undefined
622 ? { completedAt: entry.completedAt }
623 : {}
624 : message.completedAt !== undefined
625 ? { completedAt: message.completedAt }
626 : {}),
627 ...(message.attachments !== undefined
628 ? { attachments: message.attachments }
629 : {}),
630 },
631 )
632 : [...thread.messages, message];
633 const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES);
634 const turnDiffSummaries =
635 event.payload.role === "assistant" && event.payload.turnId !== null
636 ? rebindTurnDiffSummariesForAssistantMessage(
637 thread.turnDiffSummaries,
638 event.payload.turnId,
639 event.payload.messageId,
640 )
641 : thread.turnDiffSummaries;
642 const latestTurn: Thread["latestTurn"] =
643 event.payload.role === "assistant" &&
644 event.payload.turnId !== null &&
645 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
646 ? buildLatestTurn({
647 previous: thread.latestTurn,
648 turnId: event.payload.turnId,
649 state: event.payload.streaming
650 ? "running"
651 : thread.latestTurn?.state === "interrupted"
652 ? "interrupted"
653 : thread.latestTurn?.state === "error"
654 ? "error"
655 : "completed",
656 requestedAt:
657 thread.latestTurn?.turnId === event.payload.turnId
658 ? thread.latestTurn.requestedAt
659 : event.payload.createdAt,
660 startedAt:
661 thread.latestTurn?.turnId === event.payload.turnId
662 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
663 : event.payload.createdAt,
664 sourceProposedPlan: thread.pendingSourceProposedPlan,
665 completedAt: event.payload.streaming
666 ? thread.latestTurn?.turnId === event.payload.turnId
667 ? (thread.latestTurn.completedAt ?? null)
668 : null
669 : event.payload.updatedAt,
670 assistantMessageId: event.payload.messageId,
671 })
672 : thread.latestTurn;
673 return {
674 ...thread,
675 messages: cappedMessages,
676 turnDiffSummaries,
677 latestTurn,
678 updatedAt: event.occurredAt,
679 };
680 });
681 return threads === state.threads ? state : { ...state, threads };
682 }
683
684 case "thread.session-set": {
685 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
686 ...thread,
687 session: mapSession(event.payload.session),
688 error: event.payload.session.lastError ?? null,
689 latestTurn:
690 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
691 ? buildLatestTurn({
692 previous: thread.latestTurn,
693 turnId: event.payload.session.activeTurnId,
694 state: "running",
695 requestedAt:
696 thread.latestTurn?.turnId === event.payload.session.activeTurnId
697 ? thread.latestTurn.requestedAt
698 : event.payload.session.updatedAt,
699 startedAt:
700 thread.latestTurn?.turnId === event.payload.session.activeTurnId
701 ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt)
702 : event.payload.session.updatedAt,
703 completedAt: null,
704 assistantMessageId:
705 thread.latestTurn?.turnId === event.payload.session.activeTurnId
706 ? thread.latestTurn.assistantMessageId
707 : null,
708 sourceProposedPlan: thread.pendingSourceProposedPlan,
709 })
710 : thread.latestTurn,
711 updatedAt: event.occurredAt,
712 }));
713 return threads === state.threads ? state : { ...state, threads };
714 }
715
716 case "thread.session-stop-requested": {
717 const threads = updateThread(state.threads, event.payload.threadId, (thread) =>
718 thread.session === null
719 ? thread
720 : {
721 ...thread,
722 session: {
723 ...thread.session,
724 status: "closed",
725 orchestrationStatus: "stopped",
726 activeTurnId: undefined,
727 updatedAt: event.payload.createdAt,
728 },
729 updatedAt: event.occurredAt,
730 },
731 );
732 return threads === state.threads ? state : { ...state, threads };
733 }
734
735 case "thread.proposed-plan-upserted": {
736 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
737 const proposedPlan = mapProposedPlan(event.payload.proposedPlan);
738 const proposedPlans = [
739 ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id),
740 proposedPlan,
741 ]
742 .toSorted(
743 (left, right) =>
744 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
745 )
746 .slice(-MAX_THREAD_PROPOSED_PLANS);
747 return {
748 ...thread,
749 proposedPlans,
750 updatedAt: event.occurredAt,
751 };
752 });
753 return threads === state.threads ? state : { ...state, threads };
754 }
755
756 case "thread.turn-diff-completed": {
757 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
758 const checkpoint = mapTurnDiffSummary({
759 turnId: event.payload.turnId,
760 checkpointTurnCount: event.payload.checkpointTurnCount,
761 checkpointRef: event.payload.checkpointRef,
762 status: event.payload.status,
763 files: event.payload.files,
764 assistantMessageId: event.payload.assistantMessageId,
765 completedAt: event.payload.completedAt,
766 });
767 const existing = thread.turnDiffSummaries.find(
768 (entry) => entry.turnId === checkpoint.turnId,
769 );
770 if (existing && existing.status !== "missing" && checkpoint.status === "missing") {
771 return thread;
772 }
773 const turnDiffSummaries = [
774 ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId),
775 checkpoint,
776 ]
777 .toSorted(
778 (left, right) =>
779 (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) -
780 (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),
781 )
782 .slice(-MAX_THREAD_CHECKPOINTS);
783 const latestTurn =
784 thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId
785 ? buildLatestTurn({
786 previous: thread.latestTurn,
787 turnId: event.payload.turnId,
788 state: checkpointStatusToLatestTurnState(event.payload.status),
789 requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt,
790 startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
791 completedAt: event.payload.completedAt,
792 assistantMessageId: event.payload.assistantMessageId,
793 sourceProposedPlan: thread.pendingSourceProposedPlan,
794 })
795 : thread.latestTurn;
796 return {
797 ...thread,
798 turnDiffSummaries,
799 latestTurn,
800 updatedAt: event.occurredAt,
801 };
802 });
803 return threads === state.threads ? state : { ...state, threads };
804 }
805
806 case "thread.reverted": {
807 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
808 const turnDiffSummaries = thread.turnDiffSummaries
809 .filter(
810 (entry) =>
811 entry.checkpointTurnCount !== undefined &&
812 entry.checkpointTurnCount <= event.payload.turnCount,
813 )
814 .toSorted(
815 (left, right) =>
816 (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) -
817 (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),
818 )
819 .slice(-MAX_THREAD_CHECKPOINTS);
820 const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId));
821 const messages = retainThreadMessagesAfterRevert(
822 thread.messages,
823 retainedTurnIds,
824 event.payload.turnCount,
825 ).slice(-MAX_THREAD_MESSAGES);
826 const proposedPlans = retainThreadProposedPlansAfterRevert(
827 thread.proposedPlans,
828 retainedTurnIds,
829 ).slice(-MAX_THREAD_PROPOSED_PLANS);
830 const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds);
831 const latestCheckpoint = turnDiffSummaries.at(-1) ?? null;
832
833 return {
834 ...thread,
835 turnDiffSummaries,
836 messages,
837 proposedPlans,
838 activities,
839 pendingSourceProposedPlan: undefined,
840 latestTurn:
841 latestCheckpoint === null
842 ? null
843 : {
844 turnId: latestCheckpoint.turnId,
845 state: checkpointStatusToLatestTurnState(
846 (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error",
847 ),
848 requestedAt: latestCheckpoint.completedAt,
849 startedAt: latestCheckpoint.completedAt,
850 completedAt: latestCheckpoint.completedAt,
851 assistantMessageId: latestCheckpoint.assistantMessageId ?? null,
852 },
853 updatedAt: event.occurredAt,
854 };
855 });
856 return threads === state.threads ? state : { ...state, threads };
857 }
858
859 case "thread.activity-appended": {
860 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
861 const activities = [
862 ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id),
863 { ...event.payload.activity },
864 ]
865 .toSorted(compareActivities)
866 .slice(-MAX_THREAD_ACTIVITIES);
867 return {
868 ...thread,
869 activities,
870 updatedAt: event.occurredAt,
871 };
872 });
873 return threads === state.threads ? state : { ...state, threads };
874 }
875
876 case "thread.approval-response-requested":
877 case "thread.user-input-response-requested":
878 return state;
879 }
880
881 return state;
380882}
381883
382export function reorderProjects(
884export function applyOrchestrationEvents(
383885 state: AppState,
384 draggedProjectId: Project["id"],
385 targetProjectId: Project["id"],
886 events: ReadonlyArray<OrchestrationEvent>,
386887): AppState {
387 if (draggedProjectId === targetProjectId) return state;
388 const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId);
389 const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId);
390 if (draggedIndex < 0 || targetIndex < 0) return state;
391 const projects = [...state.projects];
392 const [draggedProject] = projects.splice(draggedIndex, 1);
393 if (!draggedProject) return state;
394 projects.splice(targetIndex, 0, draggedProject);
395 return { ...state, projects };
888 if (events.length === 0) {
889 return state;
890 }
891 return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);
396892}
397893
894export const selectProjectById =
895 (projectId: Project["id"] | null | undefined) =>
896 (state: AppState): Project | undefined =>
897 projectId ? state.projects.find((project) => project.id === projectId) : undefined;
898
899export const selectThreadById =
900 (threadId: ThreadId | null | undefined) =>
901 (state: AppState): Thread | undefined =>
902 threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
903
398904export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
399905 const threads = updateThread(state.threads, threadId, (t) => {
400906 if (t.error === error) return t;
......@@ -426,44 +932,18 @@ export function setThreadBranch(
426932
427933interface AppStore extends AppState {
428934 syncServerReadModel: (readModel: OrchestrationReadModel) => void;
429 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
430 markThreadUnread: (threadId: ThreadId) => void;
431 toggleProject: (projectId: Project["id"]) => void;
432 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
433 reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void;
935 applyOrchestrationEvent: (event: OrchestrationEvent) => void;
936 applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void;
434937 setError: (threadId: ThreadId, error: string | null) => void;
435938 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
436939}
437940
438941export const useStore = create<AppStore>((set) => ({
439 ...readPersistedState(),
942 ...initialState,
440943 syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),
441 markThreadVisited: (threadId, visitedAt) =>
442 set((state) => markThreadVisited(state, threadId, visitedAt)),
443 markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)),
444 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
445 setProjectExpanded: (projectId, expanded) =>
446 set((state) => setProjectExpanded(state, projectId, expanded)),
447 reorderProjects: (draggedProjectId, targetProjectId) =>
448 set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
944 applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)),
945 applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)),
449946 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
450947 setThreadBranch: (threadId, branch, worktreePath) =>
451948 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
452949}));
453
454// Persist state changes with debouncing to avoid localStorage thrashing
455useStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
456
457// Flush pending writes synchronously before page unload to prevent data loss.
458if (typeof window !== "undefined") {
459 window.addEventListener("beforeunload", () => {
460 debouncedPersistState.flush();
461 });
462}
463
464export function StoreProvider({ children }: { children: ReactNode }) {
465 useEffect(() => {
466 persistState(useStore.getState());
467 }, []);
468 return createElement(Fragment, null, children);
469}
apps/web/src/storeSelectors.ts created+14
......@@ -0,0 +1,14 @@
1import { type ThreadId } from "@t3tools/contracts";
2import { useMemo } from "react";
3import { selectProjectById, selectThreadById, useStore } from "./store";
4import { type Project, type Thread } from "./types";
5
6export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined {
7 const selector = useMemo(() => selectProjectById(projectId), [projectId]);
8 return useStore(selector);
9}
10
11export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined {
12 const selector = useMemo(() => selectThreadById(threadId), [threadId]);
13 return useStore(selector);
14}
apps/web/src/terminalStateStore.ts+10
......@@ -485,6 +485,7 @@ interface TerminalStateStoreState {
485485 hasRunningSubprocess: boolean,
486486 ) => void;
487487 clearTerminalState: (threadId: ThreadId) => void;
488 removeTerminalState: (threadId: ThreadId) => void;
488489 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
489490}
490491
......@@ -530,6 +531,15 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
530531 ),
531532 clearTerminalState: (threadId) =>
532533 updateTerminal(threadId, () => createDefaultThreadTerminalState()),
534 removeTerminalState: (threadId) =>
535 set((state) => {
536 if (state.terminalStateByThreadId[threadId] === undefined) {
537 return state;
538 }
539 const next = { ...state.terminalStateByThreadId };
540 delete next[threadId];
541 return { terminalStateByThreadId: next };
542 }),
533543 removeOrphanedTerminalStates: (activeThreadIds) =>
534544 set((state) => {
535545 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
apps/web/src/types.ts+2-2
......@@ -45,6 +45,7 @@ export interface ChatMessage {
4545 role: "user" | "assistant" | "system";
4646 text: string;
4747 attachments?: ChatAttachment[];
48 turnId?: TurnId | null;
4849 createdAt: string;
4950 completedAt?: string | undefined;
5051 streaming: boolean;
......@@ -82,7 +83,6 @@ export interface Project {
8283 name: string;
8384 cwd: string;
8485 defaultModelSelection: ModelSelection | null;
85 expanded: boolean;
8686 createdAt?: string | undefined;
8787 updatedAt?: string | undefined;
8888 scripts: ProjectScript[];
......@@ -104,7 +104,7 @@ export interface Thread {
104104 archivedAt: string | null;
105105 updatedAt?: string | undefined;
106106 latestTurn: OrchestrationLatestTurn | null;
107 lastVisitedAt?: string | undefined;
107 pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"];
108108 branch: string | null;
109109 worktreePath: string | null;
110110 turnDiffSummaries: TurnDiffSummary[];
apps/web/src/uiStateStore.test.ts created+192
......@@ -0,0 +1,192 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import {
5 clearThreadUi,
6 markThreadUnread,
7 reorderProjects,
8 setProjectExpanded,
9 syncProjects,
10 syncThreads,
11 type UiState,
12} from "./uiStateStore";
13
14function makeUiState(overrides: Partial<UiState> = {}): UiState {
15 return {
16 projectExpandedById: {},
17 projectOrder: [],
18 threadLastVisitedAtById: {},
19 ...overrides,
20 };
21}
22
23describe("uiStateStore pure functions", () => {
24 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
25 const threadId = ThreadId.makeUnsafe("thread-1");
26 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
27 const initialState = makeUiState({
28 threadLastVisitedAtById: {
29 [threadId]: "2026-02-25T12:35:00.000Z",
30 },
31 });
32
33 const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt);
34
35 expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");
36 });
37
38 it("markThreadUnread does not change a thread without a completed turn", () => {
39 const threadId = ThreadId.makeUnsafe("thread-1");
40 const initialState = makeUiState({
41 threadLastVisitedAtById: {
42 [threadId]: "2026-02-25T12:35:00.000Z",
43 },
44 });
45
46 const next = markThreadUnread(initialState, threadId, null);
47
48 expect(next).toBe(initialState);
49 });
50
51 it("reorderProjects moves a project to a target index", () => {
52 const project1 = ProjectId.makeUnsafe("project-1");
53 const project2 = ProjectId.makeUnsafe("project-2");
54 const project3 = ProjectId.makeUnsafe("project-3");
55 const initialState = makeUiState({
56 projectOrder: [project1, project2, project3],
57 });
58
59 const next = reorderProjects(initialState, project1, project3);
60
61 expect(next.projectOrder).toEqual([project2, project3, project1]);
62 });
63
64 it("syncProjects preserves current project order during snapshot recovery", () => {
65 const project1 = ProjectId.makeUnsafe("project-1");
66 const project2 = ProjectId.makeUnsafe("project-2");
67 const project3 = ProjectId.makeUnsafe("project-3");
68 const initialState = makeUiState({
69 projectExpandedById: {
70 [project1]: true,
71 [project2]: false,
72 },
73 projectOrder: [project2, project1],
74 });
75
76 const next = syncProjects(initialState, [
77 { id: project1, cwd: "/tmp/project-1" },
78 { id: project2, cwd: "/tmp/project-2" },
79 { id: project3, cwd: "/tmp/project-3" },
80 ]);
81
82 expect(next.projectOrder).toEqual([project2, project1, project3]);
83 expect(next.projectExpandedById[project2]).toBe(false);
84 });
85
86 it("syncProjects preserves manual order when a project is recreated with the same cwd", () => {
87 const oldProject1 = ProjectId.makeUnsafe("project-1");
88 const oldProject2 = ProjectId.makeUnsafe("project-2");
89 const recreatedProject2 = ProjectId.makeUnsafe("project-2b");
90 const initialState = syncProjects(
91 makeUiState({
92 projectExpandedById: {
93 [oldProject1]: true,
94 [oldProject2]: false,
95 },
96 projectOrder: [oldProject2, oldProject1],
97 }),
98 [
99 { id: oldProject1, cwd: "/tmp/project-1" },
100 { id: oldProject2, cwd: "/tmp/project-2" },
101 ],
102 );
103
104 const next = syncProjects(initialState, [
105 { id: oldProject1, cwd: "/tmp/project-1" },
106 { id: recreatedProject2, cwd: "/tmp/project-2" },
107 ]);
108
109 expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);
110 expect(next.projectExpandedById[recreatedProject2]).toBe(false);
111 });
112
113 it("syncProjects returns a new state when only project cwd changes", () => {
114 const project1 = ProjectId.makeUnsafe("project-1");
115 const initialState = syncProjects(
116 makeUiState({
117 projectExpandedById: {
118 [project1]: false,
119 },
120 projectOrder: [project1],
121 }),
122 [{ id: project1, cwd: "/tmp/project-1" }],
123 );
124
125 const next = syncProjects(initialState, [{ id: project1, cwd: "/tmp/project-1-renamed" }]);
126
127 expect(next).not.toBe(initialState);
128 expect(next.projectOrder).toEqual([project1]);
129 expect(next.projectExpandedById[project1]).toBe(false);
130 });
131
132 it("syncThreads prunes missing thread UI state", () => {
133 const thread1 = ThreadId.makeUnsafe("thread-1");
134 const thread2 = ThreadId.makeUnsafe("thread-2");
135 const initialState = makeUiState({
136 threadLastVisitedAtById: {
137 [thread1]: "2026-02-25T12:35:00.000Z",
138 [thread2]: "2026-02-25T12:36:00.000Z",
139 },
140 });
141
142 const next = syncThreads(initialState, [{ id: thread1 }]);
143
144 expect(next.threadLastVisitedAtById).toEqual({
145 [thread1]: "2026-02-25T12:35:00.000Z",
146 });
147 });
148
149 it("syncThreads seeds visit state for unseen snapshot threads", () => {
150 const thread1 = ThreadId.makeUnsafe("thread-1");
151 const initialState = makeUiState();
152
153 const next = syncThreads(initialState, [
154 {
155 id: thread1,
156 seedVisitedAt: "2026-02-25T12:35:00.000Z",
157 },
158 ]);
159
160 expect(next.threadLastVisitedAtById).toEqual({
161 [thread1]: "2026-02-25T12:35:00.000Z",
162 });
163 });
164
165 it("setProjectExpanded updates expansion without touching order", () => {
166 const project1 = ProjectId.makeUnsafe("project-1");
167 const initialState = makeUiState({
168 projectExpandedById: {
169 [project1]: true,
170 },
171 projectOrder: [project1],
172 });
173
174 const next = setProjectExpanded(initialState, project1, false);
175
176 expect(next.projectExpandedById[project1]).toBe(false);
177 expect(next.projectOrder).toEqual([project1]);
178 });
179
180 it("clearThreadUi removes visit state for deleted threads", () => {
181 const thread1 = ThreadId.makeUnsafe("thread-1");
182 const initialState = makeUiState({
183 threadLastVisitedAtById: {
184 [thread1]: "2026-02-25T12:35:00.000Z",
185 },
186 });
187
188 const next = clearThreadUi(initialState, thread1);
189
190 expect(next.threadLastVisitedAtById).toEqual({});
191 });
192});
apps/web/src/uiStateStore.ts created+417
......@@ -0,0 +1,417 @@
1import { Debouncer } from "@tanstack/react-pacer";
2import { type ProjectId, type ThreadId } from "@t3tools/contracts";
3import { create } from "zustand";
4
5const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
6const LEGACY_PERSISTED_STATE_KEYS = [
7 "t3code:renderer-state:v8",
8 "t3code:renderer-state:v7",
9 "t3code:renderer-state:v6",
10 "t3code:renderer-state:v5",
11 "t3code:renderer-state:v4",
12 "t3code:renderer-state:v3",
13 "codething:renderer-state:v4",
14 "codething:renderer-state:v3",
15 "codething:renderer-state:v2",
16 "codething:renderer-state:v1",
17] as const;
18
19interface PersistedUiState {
20 expandedProjectCwds?: string[];
21 projectOrderCwds?: string[];
22}
23
24export interface UiProjectState {
25 projectExpandedById: Record<string, boolean>;
26 projectOrder: ProjectId[];
27}
28
29export interface UiThreadState {
30 threadLastVisitedAtById: Record<string, string>;
31}
32
33export interface UiState extends UiProjectState, UiThreadState {}
34
35export interface SyncProjectInput {
36 id: ProjectId;
37 cwd: string;
38}
39
40export interface SyncThreadInput {
41 id: ThreadId;
42 seedVisitedAt?: string | undefined;
43}
44
45const initialState: UiState = {
46 projectExpandedById: {},
47 projectOrder: [],
48 threadLastVisitedAtById: {},
49};
50
51const persistedExpandedProjectCwds = new Set<string>();
52const persistedProjectOrderCwds: string[] = [];
53const currentProjectCwdById = new Map<ProjectId, string>();
54let legacyKeysCleanedUp = false;
55
56function readPersistedState(): UiState {
57 if (typeof window === "undefined") {
58 return initialState;
59 }
60 try {
61 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
62 if (!raw) {
63 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
64 const legacyRaw = window.localStorage.getItem(legacyKey);
65 if (!legacyRaw) {
66 continue;
67 }
68 hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState);
69 return initialState;
70 }
71 return initialState;
72 }
73 hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState);
74 return initialState;
75 } catch {
76 return initialState;
77 }
78}
79
80function hydratePersistedProjectState(parsed: PersistedUiState): void {
81 persistedExpandedProjectCwds.clear();
82 persistedProjectOrderCwds.length = 0;
83 for (const cwd of parsed.expandedProjectCwds ?? []) {
84 if (typeof cwd === "string" && cwd.length > 0) {
85 persistedExpandedProjectCwds.add(cwd);
86 }
87 }
88 for (const cwd of parsed.projectOrderCwds ?? []) {
89 if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {
90 persistedProjectOrderCwds.push(cwd);
91 }
92 }
93}
94
95function persistState(state: UiState): void {
96 if (typeof window === "undefined") {
97 return;
98 }
99 try {
100 const expandedProjectCwds = Object.entries(state.projectExpandedById)
101 .filter(([, expanded]) => expanded)
102 .flatMap(([projectId]) => {
103 const cwd = currentProjectCwdById.get(projectId as ProjectId);
104 return cwd ? [cwd] : [];
105 });
106 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
107 const cwd = currentProjectCwdById.get(projectId);
108 return cwd ? [cwd] : [];
109 });
110 window.localStorage.setItem(
111 PERSISTED_STATE_KEY,
112 JSON.stringify({
113 expandedProjectCwds,
114 projectOrderCwds,
115 } satisfies PersistedUiState),
116 );
117 if (!legacyKeysCleanedUp) {
118 legacyKeysCleanedUp = true;
119 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
120 window.localStorage.removeItem(legacyKey);
121 }
122 }
123 } catch {
124 // Ignore quota/storage errors to avoid breaking chat UX.
125 }
126}
127
128const debouncedPersistState = new Debouncer(persistState, { wait: 500 });
129
130function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boolean {
131 const leftEntries = Object.entries(left);
132 const rightEntries = Object.entries(right);
133 if (leftEntries.length !== rightEntries.length) {
134 return false;
135 }
136 for (const [key, value] of leftEntries) {
137 if (right[key] !== value) {
138 return false;
139 }
140 }
141 return true;
142}
143
144function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean {
145 return (
146 left.length === right.length && left.every((projectId, index) => projectId === right[index])
147 );
148}
149
150export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
151 const previousProjectCwdById = new Map(currentProjectCwdById);
152 const previousProjectIdByCwd = new Map(
153 [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const),
154 );
155 currentProjectCwdById.clear();
156 for (const project of projects) {
157 currentProjectCwdById.set(project.id, project.cwd);
158 }
159 const cwdMappingChanged =
160 previousProjectCwdById.size !== currentProjectCwdById.size ||
161 projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd);
162
163 const nextExpandedById: Record<string, boolean> = {};
164 const previousExpandedById = state.projectExpandedById;
165 const persistedOrderByCwd = new Map(
166 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
167 );
168 const mappedProjects = projects.map((project, index) => {
169 const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);
170 const expanded =
171 previousExpandedById[project.id] ??
172 (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??
173 (persistedExpandedProjectCwds.size > 0
174 ? persistedExpandedProjectCwds.has(project.cwd)
175 : true);
176 nextExpandedById[project.id] = expanded;
177 return {
178 id: project.id,
179 cwd: project.cwd,
180 incomingIndex: index,
181 };
182 });
183
184 const nextProjectOrder =
185 state.projectOrder.length > 0
186 ? (() => {
187 const nextProjectIdByCwd = new Map(
188 mappedProjects.map((project) => [project.cwd, project.id] as const),
189 );
190 const usedProjectIds = new Set<ProjectId>();
191 const orderedProjectIds: ProjectId[] = [];
192
193 for (const projectId of state.projectOrder) {
194 const matchedProjectId =
195 (projectId in nextExpandedById ? projectId : undefined) ??
196 (() => {
197 const previousCwd = previousProjectCwdById.get(projectId);
198 return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;
199 })();
200 if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) {
201 continue;
202 }
203 usedProjectIds.add(matchedProjectId);
204 orderedProjectIds.push(matchedProjectId);
205 }
206
207 for (const project of mappedProjects) {
208 if (usedProjectIds.has(project.id)) {
209 continue;
210 }
211 orderedProjectIds.push(project.id);
212 }
213
214 return orderedProjectIds;
215 })()
216 : mappedProjects
217 .map((project) => ({
218 id: project.id,
219 incomingIndex: project.incomingIndex,
220 orderIndex:
221 persistedOrderByCwd.get(project.cwd) ??
222 persistedProjectOrderCwds.length + project.incomingIndex,
223 }))
224 .toSorted((left, right) => {
225 const byOrder = left.orderIndex - right.orderIndex;
226 if (byOrder !== 0) {
227 return byOrder;
228 }
229 return left.incomingIndex - right.incomingIndex;
230 })
231 .map((project) => project.id);
232
233 if (
234 recordsEqual(state.projectExpandedById, nextExpandedById) &&
235 projectOrdersEqual(state.projectOrder, nextProjectOrder) &&
236 !cwdMappingChanged
237 ) {
238 return state;
239 }
240
241 return {
242 ...state,
243 projectExpandedById: nextExpandedById,
244 projectOrder: nextProjectOrder,
245 };
246}
247
248export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState {
249 const retainedThreadIds = new Set(threads.map((thread) => thread.id));
250 const nextThreadLastVisitedAtById = Object.fromEntries(
251 Object.entries(state.threadLastVisitedAtById).filter(([threadId]) =>
252 retainedThreadIds.has(threadId as ThreadId),
253 ),
254 );
255 for (const thread of threads) {
256 if (
257 nextThreadLastVisitedAtById[thread.id] === undefined &&
258 thread.seedVisitedAt !== undefined &&
259 thread.seedVisitedAt.length > 0
260 ) {
261 nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt;
262 }
263 }
264 if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {
265 return state;
266 }
267 return {
268 ...state,
269 threadLastVisitedAtById: nextThreadLastVisitedAtById,
270 };
271}
272
273export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState {
274 const at = visitedAt ?? new Date().toISOString();
275 const visitedAtMs = Date.parse(at);
276 const previousVisitedAt = state.threadLastVisitedAtById[threadId];
277 const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN;
278 if (
279 Number.isFinite(previousVisitedAtMs) &&
280 Number.isFinite(visitedAtMs) &&
281 previousVisitedAtMs >= visitedAtMs
282 ) {
283 return state;
284 }
285 return {
286 ...state,
287 threadLastVisitedAtById: {
288 ...state.threadLastVisitedAtById,
289 [threadId]: at,
290 },
291 };
292}
293
294export function markThreadUnread(
295 state: UiState,
296 threadId: ThreadId,
297 latestTurnCompletedAt: string | null | undefined,
298): UiState {
299 if (!latestTurnCompletedAt) {
300 return state;
301 }
302 const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt);
303 if (Number.isNaN(latestTurnCompletedAtMs)) {
304 return state;
305 }
306 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
307 if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) {
308 return state;
309 }
310 return {
311 ...state,
312 threadLastVisitedAtById: {
313 ...state.threadLastVisitedAtById,
314 [threadId]: unreadVisitedAt,
315 },
316 };
317}
318
319export function clearThreadUi(state: UiState, threadId: ThreadId): UiState {
320 if (!(threadId in state.threadLastVisitedAtById)) {
321 return state;
322 }
323 const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };
324 delete nextThreadLastVisitedAtById[threadId];
325 return {
326 ...state,
327 threadLastVisitedAtById: nextThreadLastVisitedAtById,
328 };
329}
330
331export function toggleProject(state: UiState, projectId: ProjectId): UiState {
332 const expanded = state.projectExpandedById[projectId] ?? true;
333 return {
334 ...state,
335 projectExpandedById: {
336 ...state.projectExpandedById,
337 [projectId]: !expanded,
338 },
339 };
340}
341
342export function setProjectExpanded(
343 state: UiState,
344 projectId: ProjectId,
345 expanded: boolean,
346): UiState {
347 if ((state.projectExpandedById[projectId] ?? true) === expanded) {
348 return state;
349 }
350 return {
351 ...state,
352 projectExpandedById: {
353 ...state.projectExpandedById,
354 [projectId]: expanded,
355 },
356 };
357}
358
359export function reorderProjects(
360 state: UiState,
361 draggedProjectId: ProjectId,
362 targetProjectId: ProjectId,
363): UiState {
364 if (draggedProjectId === targetProjectId) {
365 return state;
366 }
367 const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId);
368 const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId);
369 if (draggedIndex < 0 || targetIndex < 0) {
370 return state;
371 }
372 const projectOrder = [...state.projectOrder];
373 const [draggedProject] = projectOrder.splice(draggedIndex, 1);
374 if (!draggedProject) {
375 return state;
376 }
377 projectOrder.splice(targetIndex, 0, draggedProject);
378 return {
379 ...state,
380 projectOrder,
381 };
382}
383
384interface UiStateStore extends UiState {
385 syncProjects: (projects: readonly SyncProjectInput[]) => void;
386 syncThreads: (threads: readonly SyncThreadInput[]) => void;
387 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
388 markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void;
389 clearThreadUi: (threadId: ThreadId) => void;
390 toggleProject: (projectId: ProjectId) => void;
391 setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void;
392 reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void;
393}
394
395export const useUiStateStore = create<UiStateStore>((set) => ({
396 ...readPersistedState(),
397 syncProjects: (projects) => set((state) => syncProjects(state, projects)),
398 syncThreads: (threads) => set((state) => syncThreads(state, threads)),
399 markThreadVisited: (threadId, visitedAt) =>
400 set((state) => markThreadVisited(state, threadId, visitedAt)),
401 markThreadUnread: (threadId, latestTurnCompletedAt) =>
402 set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
403 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),
404 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
405 setProjectExpanded: (projectId, expanded) =>
406 set((state) => setProjectExpanded(state, projectId, expanded)),
407 reorderProjects: (draggedProjectId, targetProjectId) =>
408 set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
409}));
410
411useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
412
413if (typeof window !== "undefined") {
414 window.addEventListener("beforeunload", () => {
415 debouncedPersistState.flush();
416 });
417}