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 @@...@@ -2,8 +2,11 @@
2import "../index.css";2import "../index.css";
33
4import {4import {
5 EventId,
5 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
7 ORCHESTRATION_WS_CHANNELS,
6 type MessageId,8 type MessageId,
9 type OrchestrationEvent,
7 type OrchestrationReadModel,10 type OrchestrationReadModel,
8 type ProjectId,11 type ProjectId,
9 type ServerConfig,12 type ServerConfig,
...@@ -57,6 +60,8 @@ interface TestFixture {...@@ -57,6 +60,8 @@ interface TestFixture {
57let fixture: TestFixture;60let fixture: TestFixture;
58const wsRequests: WsRequestEnvelope["body"][] = [];61const wsRequests: WsRequestEnvelope["body"][] = [];
59let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;62let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
63let wsClient: { send: (message: string) => void } | null = null;
64let pushSequence = 1;
60const wsLink = ws.link(/ws(s)?:\/\/.*/);65const wsLink = ws.link(/ws(s)?:\/\/.*/);
6166
62interface ViewportSpec {67interface ViewportSpec {
...@@ -336,6 +341,79 @@ function addThreadToSnapshot(...@@ -336,6 +341,79 @@ function addThreadToSnapshot(
336 };341 };
337}342}
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
339function createDraftOnlySnapshot(): OrchestrationReadModel {417function createDraftOnlySnapshot(): OrchestrationReadModel {
340 const snapshot = createSnapshotForTargetUser({418 const snapshot = createSnapshotForTargetUser({
341 targetMessageId: "msg-user-draft-target" as MessageId,419 targetMessageId: "msg-user-draft-target" as MessageId,
...@@ -500,10 +578,12 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {...@@ -500,10 +578,12 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
500578
501const worker = setupWorker(579const worker = setupWorker(
502 wsLink.addEventListener("connection", ({ client }) => {580 wsLink.addEventListener("connection", ({ client }) => {
581 wsClient = client;
582 pushSequence = 1;
503 client.send(583 client.send(
504 JSON.stringify({584 JSON.stringify({
505 type: "push",585 type: "push",
506 sequence: 1,586 sequence: pushSequence++,
507 channel: WS_CHANNELS.serverWelcome,587 channel: WS_CHANNELS.serverWelcome,
508 data: fixture.welcome,588 data: fixture.welcome,
509 }),589 }),
...@@ -875,7 +955,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -875,7 +955,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
875 useStore.setState({955 useStore.setState({
876 projects: [],956 projects: [],
877 threads: [],957 threads: [],
878 threadsHydrated: false,958 bootstrapComplete: false,
879 });959 });
880 });960 });
881961
...@@ -1881,21 +1961,16 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1881,21 +1961,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
1881 // The composer editor should be present for the new draft thread.1961 // The composer editor should be present for the new draft thread.
1882 await waitForComposerEditor();1962 await waitForComposerEditor();
18831963
1884 // Simulate the snapshot sync arriving from the server after the draft1964 // Simulate the steady-state promotion path: the server emits
1885 // thread has been promoted to a server thread (thread.create + turn.start1965 // `thread.created`, the client materializes the thread incrementally,
1886 // succeeded). The snapshot now includes the new thread, and the sync1966 // and the draft is cleared by live batch effects.
1887 // should clear the draft without disrupting the route.1967 await promoteDraftThreadViaDomainEvent(newThreadId);
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);
18931968
1894 // The route should still be on the new thread — not redirected away.1969 // The route should still be on the new thread — not redirected away.
1895 await waitForURL(1970 await waitForURL(
1896 mounted.router,1971 mounted.router,
1897 (path) => path === newThreadPath,1972 (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.",
1899 );1974 );
19001975
1901 // The empty thread view and composer should still be visible.1976 // The empty thread view and composer should still be visible.
...@@ -2217,9 +2292,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2217,9 +2292,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2217 );2292 );
2218 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;2293 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;
22192294
2220 const { syncServerReadModel } = useStore.getState();2295 await promoteDraftThreadViaDomainEvent(promotedThreadId);
2221 syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
2222 useComposerDraftStore.getState().clearDraftThread(promotedThreadId);
22232296
2224 const freshThreadPath = await triggerChatNewShortcutUntilPath(2297 const freshThreadPath = await triggerChatNewShortcutUntilPath(
2225 mounted.router,2298 mounted.router,
apps/web/src/components/ChatView.logic.test.ts+297-3
...@@ -1,7 +1,14 @@...@@ -1,7 +1,14 @@
1import { ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";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
6describe("deriveComposerSendState", () => {13describe("deriveComposerSendState", () => {
7 it("treats expired terminal pills as non-sendable content", () => {14 it("treats expired terminal pills as non-sendable content", () => {
...@@ -67,3 +74,290 @@ describe("buildExpiredTerminalContextToastCopy", () => {...@@ -67,3 +74,290 @@ describe("buildExpiredTerminalContextToastCopy", () => {
67 });74 });
68 });75 });
69});76});
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 @@...@@ -1,8 +1,9 @@
1import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts";1import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts";
2import { type ChatMessage, type Thread } from "../types";2import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
3import { randomUUID } from "~/lib/utils";3import { randomUUID } from "~/lib/utils";
4import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";4import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
5import { Schema } from "effect";5import { Schema } from "effect";
6import { useStore } from "../store";
6import {7import {
7 filterTerminalContextsWithText,8 filterTerminalContextsWithText,
8 stripInlineTerminalContextPlaceholders,9 stripInlineTerminalContextPlaceholders,
...@@ -34,7 +35,6 @@ export function buildLocalDraftThread(...@@ -34,7 +35,6 @@ export function buildLocalDraftThread(
34 createdAt: draftThread.createdAt,35 createdAt: draftThread.createdAt,
35 archivedAt: null,36 archivedAt: null,
36 latestTurn: null,37 latestTurn: null,
37 lastVisitedAt: draftThread.createdAt,
38 branch: draftThread.branch,38 branch: draftThread.branch,
39 worktreePath: draftThread.worktreePath,39 worktreePath: draftThread.worktreePath,
40 turnDiffSummaries: [],40 turnDiffSummaries: [],
...@@ -75,8 +75,6 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[...@@ -75,8 +75,6 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[
75 return previewUrls;75 return previewUrls;
76}76}
7777
78export type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
79
80export interface PullRequestDialogState {78export interface PullRequestDialogState {
81 initialReference: string | null;79 initialReference: string | null;
82 key: number;80 key: number;
...@@ -161,3 +159,116 @@ export function buildExpiredTerminalContextToastCopy(...@@ -161,3 +159,116 @@ export function buildExpiredTerminalContextToastCopy(
161 description: "Re-add it if you want that terminal output included.",159 description: "Re-add it if you want that terminal output included.",
162 };160 };
163}161}
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 {...@@ -20,6 +20,7 @@ import {
20 OrchestrationThreadActivity,20 OrchestrationThreadActivity,
21 ProviderInteractionMode,21 ProviderInteractionMode,
22 RuntimeMode,22 RuntimeMode,
23 TerminalOpenInput,
23} from "@t3tools/contracts";24} from "@t3tools/contracts";
24import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";25import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
25import { truncate } from "@t3tools/shared/String";26import { truncate } from "@t3tools/shared/String";
...@@ -42,6 +43,7 @@ import {...@@ -42,6 +43,7 @@ import {
42 replaceTextRange,43 replaceTextRange,
43} from "../composer-logic";44} from "../composer-logic";
44import {45import {
46 deriveCompletionDividerBeforeEntryId,
45 derivePendingApprovals,47 derivePendingApprovals,
46 derivePendingUserInputs,48 derivePendingUserInputs,
47 derivePhase,49 derivePhase,
...@@ -64,6 +66,8 @@ import {...@@ -64,6 +66,8 @@ import {
64 type PendingUserInputDraftAnswer,66 type PendingUserInputDraftAnswer,
65} from "../pendingUserInput";67} from "../pendingUserInput";
66import { useStore } from "../store";68import { useStore } from "../store";
69import { useProjectById, useThreadById } from "../storeSelectors";
70import { useUiStateStore } from "../uiStateStore";
67import {71import {
68 buildPlanImplementationThreadTitle,72 buildPlanImplementationThreadTitle,
69 buildPlanImplementationPrompt,73 buildPlanImplementationPrompt,
...@@ -76,8 +80,12 @@ import {...@@ -76,8 +80,12 @@ import {
76 DEFAULT_THREAD_TERMINAL_ID,80 DEFAULT_THREAD_TERMINAL_ID,
77 MAX_TERMINALS_PER_GROUP,81 MAX_TERMINALS_PER_GROUP,
78 type ChatMessage,82 type ChatMessage,
83 type SessionPhase,
84 type Thread,
79 type TurnDiffSummary,85 type TurnDiffSummary,
80} from "../types";86} from "../types";
87import { LRUCache } from "../lib/lruCache";
88
81import { basenameOfPath } from "../vscode-icons";89import { basenameOfPath } from "../vscode-icons";
82import { useTheme } from "../hooks/useTheme";90import { useTheme } from "../hooks/useTheme";
83import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";91import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -168,14 +176,18 @@ import {...@@ -168,14 +176,18 @@ import {
168 buildTemporaryWorktreeBranchName,176 buildTemporaryWorktreeBranchName,
169 cloneComposerImageForRetry,177 cloneComposerImageForRetry,
170 collectUserMessageBlobPreviewUrls,178 collectUserMessageBlobPreviewUrls,
179 createLocalDispatchSnapshot,
171 deriveComposerSendState,180 deriveComposerSendState,
181 hasServerAcknowledgedLocalDispatch,
172 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,182 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
173 LastInvokedScriptByProjectSchema,183 LastInvokedScriptByProjectSchema,
184 type LocalDispatchSnapshot,
174 PullRequestDialogState,185 PullRequestDialogState,
175 readFileAsDataUrl,186 readFileAsDataUrl,
176 revokeBlobPreviewUrl,187 revokeBlobPreviewUrl,
177 revokeUserMessagePreviewUrls,188 revokeUserMessagePreviewUrls,
178 SendPhase,189 threadHasStarted,
190 waitForStartedServerThread,
179} from "./ChatView.logic";191} from "./ChatView.logic";
180import { useLocalStorage } from "~/hooks/useLocalStorage";192import { useLocalStorage } from "~/hooks/useLocalStorage";
181193
...@@ -190,6 +202,81 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];...@@ -190,6 +202,81 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
190const EMPTY_PROVIDERS: ServerProvider[] = [];202const EMPTY_PROVIDERS: ServerProvider[] = [];
191const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};203const 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
193function formatOutgoingPrompt(params: {280function formatOutgoingPrompt(params: {
194 provider: ProviderKind;281 provider: ProviderKind;
195 model: string | null;282 model: string | null;
...@@ -245,13 +332,81 @@ interface PendingPullRequestSetupRequest {...@@ -245,13 +332,81 @@ interface PendingPullRequestSetupRequest {
245 scriptId: string;332 scriptId: string;
246}333}
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
248export default function ChatView({ threadId }: ChatViewProps) {402export default function ChatView({ threadId }: ChatViewProps) {
249 const threads = useStore((store) => store.threads);403 const serverThread = useThreadById(threadId);
250 const projects = useStore((store) => store.projects);
251 const markThreadVisited = useStore((store) => store.markThreadVisited);
252 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
253 const setStoreThreadError = useStore((store) => store.setError);404 const setStoreThreadError = useStore((store) => store.setError);
254 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);405 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
406 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
407 const activeThreadLastVisitedAt = useUiStateStore(
408 (store) => store.threadLastVisitedAtById[threadId],
409 );
255 const settings = useSettings();410 const settings = useSettings();
256 const setStickyComposerModelSelection = useComposerDraftStore(411 const setStickyComposerModelSelection = useComposerDraftStore(
257 (store) => store.setStickyModelSelection,412 (store) => store.setStickyModelSelection,
...@@ -330,8 +485,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -330,8 +485,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
330 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<485 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
331 Record<ThreadId, string | null>486 Record<ThreadId, string | null>
332 >({});487 >({});
333 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
334 const [sendStartedAt, setSendStartedAt] = useState<string | null>(null);
335 const [isConnecting, _setIsConnecting] = useState(false);488 const [isConnecting, _setIsConnecting] = useState(false);
336 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);489 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
337 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);490 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
...@@ -466,8 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -466,8 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
466 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],619 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],
467 );620 );
468621
469 const serverThread = threads.find((t) => t.id === threadId);622 const fallbackDraftProject = useProjectById(draftThread?.projectId);
470 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
471 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);623 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
472 const localDraftThread = useMemo(624 const localDraftThread = useMemo(
473 () =>625 () =>
...@@ -495,12 +647,25 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -495,12 +647,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
495 const diffOpen = rawSearch.diff === "1";647 const diffOpen = rawSearch.diff === "1";
496 const activeThreadId = activeThread?.id ?? null;648 const activeThreadId = activeThread?.id ?? null;
497 const activeLatestTurn = activeThread?.latestTurn ?? null;649 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 );
498 const activeContextWindow = useMemo(663 const activeContextWindow = useMemo(
499 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),664 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
500 [activeThread?.activities],665 [activeThread?.activities],
501 );666 );
502 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);667 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
503 const activeProject = projects.find((p) => p.id === activeThread?.projectId);668 const activeProject = useProjectById(activeThread?.projectId);
504669
505 const openPullRequestDialog = useCallback(670 const openPullRequestDialog = useCallback(
506 (reference?: string) => {671 (reference?: string) => {
...@@ -595,33 +760,28 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -595,33 +760,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
595 );760 );
596761
597 useEffect(() => {762 useEffect(() => {
598 if (!activeThread?.id) return;763 if (!serverThread?.id) return;
599 if (!latestTurnSettled) return;764 if (!latestTurnSettled) return;
600 if (!activeLatestTurn?.completedAt) return;765 if (!activeLatestTurn?.completedAt) return;
601 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);766 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);
602 if (Number.isNaN(turnCompletedAt)) return;767 if (Number.isNaN(turnCompletedAt)) return;
603 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;768 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
604 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;769 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
605770
606 markThreadVisited(activeThread.id);771 markThreadVisited(serverThread.id);
607 }, [772 }, [
608 activeThread?.id,
609 activeThread?.lastVisitedAt,
610 activeLatestTurn?.completedAt,773 activeLatestTurn?.completedAt,
774 activeThreadLastVisitedAt,
611 latestTurnSettled,775 latestTurnSettled,
612 markThreadVisited,776 markThreadVisited,
777 serverThread?.id,
613 ]);778 ]);
614779
615 const sessionProvider = activeThread?.session?.provider ?? null;780 const sessionProvider = activeThread?.session?.provider ?? null;
616 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;781 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
617 const threadProvider =782 const threadProvider =
618 activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null;783 activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null;
619 const hasThreadStarted = Boolean(784 const hasThreadStarted = threadHasStarted(activeThread);
620 activeThread &&
621 (activeThread.latestTurn !== null ||
622 activeThread.messages.length > 0 ||
623 activeThread.session !== null),
624 );
625 const lockedProvider: ProviderKind | null = hasThreadStarted785 const lockedProvider: ProviderKind | null = hasThreadStarted
626 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)786 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
627 : null;787 : null;
...@@ -664,15 +824,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -664,15 +824,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
664 );824 );
665 const selectedModelForPicker = selectedModel;825 const selectedModelForPicker = selectedModel;
666 const phase = derivePhase(activeThread?.session ?? null);826 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 );
676 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;827 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
677 const workLogEntries = useMemo(828 const workLogEntries = useMemo(
678 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),829 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
...@@ -735,12 +886,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -735,12 +886,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
735 const sidebarProposedPlan = useMemo(886 const sidebarProposedPlan = useMemo(
736 () =>887 () =>
737 findSidebarProposedPlan({888 findSidebarProposedPlan({
738 threads,889 threads: threadPlanCatalog,
739 latestTurn: activeLatestTurn,890 latestTurn: activeLatestTurn,
740 latestTurnSettled,891 latestTurnSettled,
741 threadId: activeThread?.id ?? null,892 threadId: activeThread?.id ?? null,
742 }),893 }),
743 [activeLatestTurn, activeThread?.id, latestTurnSettled, threads],894 [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog],
744 );895 );
745 const activePlan = useMemo(896 const activePlan = useMemo(
746 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),897 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
...@@ -752,6 +903,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -752,6 +903,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
752 latestTurnSettled &&903 latestTurnSettled &&
753 hasActionableProposedPlan(activeProposedPlan);904 hasActionableProposedPlan(activeProposedPlan);
754 const activePendingApproval = pendingApprovals[0] ?? null;905 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 );
755 const isComposerApprovalState = activePendingApproval !== null;927 const isComposerApprovalState = activePendingApproval !== null;
756 const hasComposerHeader =928 const hasComposerHeader =
757 isComposerApprovalState ||929 isComposerApprovalState ||
...@@ -980,35 +1152,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -980,35 +1152,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
980 ]);1152 ]);
981 const completionDividerBeforeEntryId = useMemo(() => {1153 const completionDividerBeforeEntryId = useMemo(() => {
982 if (!latestTurnSettled) return null;1154 if (!latestTurnSettled) return null;
983 if (!activeLatestTurn?.startedAt) return null;
984 if (!activeLatestTurn.completedAt) return null;
985 if (!completionSummary) return null;1155 if (!completionSummary) return null;
9861156 return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn);
987 const turnStartedAt = Date.parse(activeLatestTurn.startedAt);1157 }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]);
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 ]);
1012 const gitCwd = activeProject1158 const gitCwd = activeProject
1013 ? projectScriptCwd({1159 ? projectScriptCwd({
1014 project: { cwd: activeProject.cwd },1160 project: { cwd: activeProject.cwd },
...@@ -1230,7 +1376,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1230,7 +1376,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1230 const setThreadError = useCallback(1376 const setThreadError = useCallback(
1231 (targetThreadId: ThreadId | null, error: string | null) => {1377 (targetThreadId: ThreadId | null, error: string | null) => {
1232 if (!targetThreadId) return;1378 if (!targetThreadId) return;
1233 if (threads.some((thread) => thread.id === targetThreadId)) {1379 if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) {
1234 setStoreThreadError(targetThreadId, error);1380 setStoreThreadError(targetThreadId, error);
1235 return;1381 return;
1236 }1382 }
...@@ -1244,7 +1390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1244,7 +1390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1244 };1390 };
1245 });1391 });
1246 },1392 },
1247 [setStoreThreadError, threads],1393 [setStoreThreadError],
1248 );1394 );
12491395
1250 const focusComposer = useCallback(() => {1396 const focusComposer = useCallback(() => {
...@@ -1411,7 +1557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1411,7 +1557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1411 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,1557 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
1412 ...(options?.env ? { extraEnv: options.env } : {}),1558 ...(options?.env ? { extraEnv: options.env } : {}),
1413 });1559 });
1414 const openTerminalInput: Parameters<typeof api.terminal.open>[0] = shouldCreateNewTerminal1560 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
1415 ? {1561 ? {
1416 threadId: activeThreadId,1562 threadId: activeThreadId,
1417 terminalId: targetTerminalId,1563 terminalId: targetTerminalId,
...@@ -2020,15 +2166,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2020,15 +2166,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
2020 }2166 }
2021 return [];2167 return [];
2022 });2168 });
2023 setSendPhase("idle");2169 resetLocalDispatch();
2024 setSendStartedAt(null);
2025 setComposerHighlightedItemId(null);2170 setComposerHighlightedItemId(null);
2026 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));2171 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
2027 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));2172 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
2028 dragDepthRef.current = 0;2173 dragDepthRef.current = 0;
2029 setIsDragOverComposer(false);2174 setIsDragOverComposer(false);
2030 setExpandedImage(null);2175 setExpandedImage(null);
2031 }, [threadId]);2176 }, [resetLocalDispatch, threadId]);
20322177
2033 useEffect(() => {2178 useEffect(() => {
2034 let cancelled = false;2179 let cancelled = false;
...@@ -2161,37 +2306,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2161,37 +2306,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
2161 };2306 };
2162 }, [phase]);2307 }, [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
2195 useEffect(() => {2309 useEffect(() => {
2196 if (!activeThreadId) return;2310 if (!activeThreadId) return;
2197 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;2311 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
...@@ -2529,7 +2643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2529,7 +2643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2529 }2643 }
25302644
2531 sendInFlightRef.current = true;2645 sendInFlightRef.current = true;
2532 beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");2646 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
25332647
2534 const composerImagesSnapshot = [...composerImages];2648 const composerImagesSnapshot = [...composerImages];
2535 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];2649 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
...@@ -2603,7 +2717,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2603,7 +2717,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2603 await (async () => {2717 await (async () => {
2604 // On first message: lock in branch + create worktree if needed.2718 // On first message: lock in branch + create worktree if needed.
2605 if (baseBranchForWorktree) {2719 if (baseBranchForWorktree) {
2606 beginSendPhase("preparing-worktree");2720 beginLocalDispatch({ preparingWorktree: true });
2607 const newBranch = buildTemporaryWorktreeBranchName();2721 const newBranch = buildTemporaryWorktreeBranchName();
2608 const result = await createWorktreeMutation.mutateAsync({2722 const result = await createWorktreeMutation.mutateAsync({
2609 cwd: activeProject.cwd,2723 cwd: activeProject.cwd,
...@@ -2715,7 +2829,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2715,7 +2829,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2715 });2829 });
2716 }2830 }
27172831
2718 beginSendPhase("sending-turn");2832 beginLocalDispatch({ preparingWorktree: false });
2719 const turnAttachments = await turnAttachmentsPromise;2833 const turnAttachments = await turnAttachmentsPromise;
2720 await api.orchestration.dispatchCommand({2834 await api.orchestration.dispatchCommand({
2721 type: "thread.turn.start",2835 type: "thread.turn.start",
...@@ -2772,7 +2886,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2772,7 +2886,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2772 });2886 });
2773 sendInFlightRef.current = false;2887 sendInFlightRef.current = false;
2774 if (!turnStartSucceeded) {2888 if (!turnStartSucceeded) {
2775 resetSendPhase();2889 resetLocalDispatch();
2776 }2890 }
2777 };2891 };
27782892
...@@ -2971,7 +3085,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2971,7 +3085,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2971 });3085 });
29723086
2973 sendInFlightRef.current = true;3087 sendInFlightRef.current = true;
2974 beginSendPhase("sending-turn");3088 beginLocalDispatch({ preparingWorktree: false });
2975 setThreadError(threadIdForSend, null);3089 setThreadError(threadIdForSend, null);
2976 setOptimisticUserMessages((existing) => [3090 setOptimisticUserMessages((existing) => [
2977 ...existing,3091 ...existing,
...@@ -3040,19 +3154,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3040,19 +3154,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
3040 err instanceof Error ? err.message : "Failed to send plan follow-up.",3154 err instanceof Error ? err.message : "Failed to send plan follow-up.",
3041 );3155 );
3042 sendInFlightRef.current = false;3156 sendInFlightRef.current = false;
3043 resetSendPhase();3157 resetLocalDispatch();
3044 }3158 }
3045 },3159 },
3046 [3160 [
3047 activeThread,3161 activeThread,
3048 activeProposedPlan,3162 activeProposedPlan,
3049 beginSendPhase,3163 beginLocalDispatch,
3050 forceStickToBottom,3164 forceStickToBottom,
3051 isConnecting,3165 isConnecting,
3052 isSendBusy,3166 isSendBusy,
3053 isServerThread,3167 isServerThread,
3054 persistThreadSettingsForNextTurn,3168 persistThreadSettingsForNextTurn,
3055 resetSendPhase,3169 resetLocalDispatch,
3056 runtimeMode,3170 runtimeMode,
3057 selectedPromptEffort,3171 selectedPromptEffort,
3058 selectedModelSelection,3172 selectedModelSelection,
...@@ -3094,10 +3208,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3094,10 +3208,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
3094 const nextThreadModelSelection: ModelSelection = selectedModelSelection;3208 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
30953209
3096 sendInFlightRef.current = true;3210 sendInFlightRef.current = true;
3097 beginSendPhase("sending-turn");3211 beginLocalDispatch({ preparingWorktree: false });
3098 const finish = () => {3212 const finish = () => {
3099 sendInFlightRef.current = false;3213 sendInFlightRef.current = false;
3100 resetSendPhase();3214 resetLocalDispatch();
3101 };3215 };
31023216
3103 await api.orchestration3217 await api.orchestration
...@@ -3132,9 +3246,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3132,9 +3246,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
3132 createdAt,3246 createdAt,
3133 });3247 });
3134 })3248 })
3135 .then(() => api.orchestration.getSnapshot())3249 .then(() => {
3136 .then((snapshot) => {3250 return waitForStartedServerThread(nextThreadId);
3137 syncServerReadModel(snapshot);3251 })
3252 .then(() => {
3138 // Signal that the plan sidebar should open on the new thread.3253 // Signal that the plan sidebar should open on the new thread.
3139 planSidebarOpenOnNextThreadRef.current = true;3254 planSidebarOpenOnNextThreadRef.current = true;
3140 return navigate({3255 return navigate({
...@@ -3150,12 +3265,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3150,12 +3265,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
3150 threadId: nextThreadId,3265 threadId: nextThreadId,
3151 })3266 })
3152 .catch(() => undefined);3267 .catch(() => undefined);
3153 await api.orchestration
3154 .getSnapshot()
3155 .then((snapshot) => {
3156 syncServerReadModel(snapshot);
3157 })
3158 .catch(() => undefined);
3159 toastManager.add({3268 toastManager.add({
3160 type: "error",3269 type: "error",
3161 title: "Could not start implementation thread",3270 title: "Could not start implementation thread",
...@@ -3168,18 +3277,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3168,18 +3277,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
3168 activeProject,3277 activeProject,
3169 activeProposedPlan,3278 activeProposedPlan,
3170 activeThread,3279 activeThread,
3171 beginSendPhase,3280 beginLocalDispatch,
3172 isConnecting,3281 isConnecting,
3173 isSendBusy,3282 isSendBusy,
3174 isServerThread,3283 isServerThread,
3175 navigate,3284 navigate,
3176 resetSendPhase,3285 resetLocalDispatch,
3177 runtimeMode,3286 runtimeMode,
3178 selectedPromptEffort,3287 selectedPromptEffort,
3179 selectedModelSelection,3288 selectedModelSelection,
3180 selectedProvider,3289 selectedProvider,
3181 selectedProviderModels,3290 selectedProviderModels,
3182 syncServerReadModel,
3183 selectedModel,3291 selectedModel,
3184 ]);3292 ]);
31853293
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
...@@ -323,7 +323,7 @@ describe("Keybindings update toast", () => {...@@ -323,7 +323,7 @@ describe("Keybindings update toast", () => {
323 useStore.setState({323 useStore.setState({
324 projects: [],324 projects: [],
325 threads: [],325 threads: [],
326 threadsHydrated: false,326 bootstrapComplete: false,
327 });327 });
328 });328 });
329329
apps/web/src/components/Sidebar.logic.test.ts+47-3
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 getProjectSortTimestamp,9 getProjectSortTimestamp,
10 hasUnseenCompletion,10 hasUnseenCompletion,
11 isContextMenuPointerDown,11 isContextMenuPointerDown,
12 orderItemsByPreferredIds,
12 resolveProjectStatusIndicator,13 resolveProjectStatusIndicator,
13 resolveSidebarNewThreadEnvMode,14 resolveSidebarNewThreadEnvMode,
14 resolveThreadRowClassName,15 resolveThreadRowClassName,
...@@ -18,7 +19,7 @@ import {...@@ -18,7 +19,7 @@ import {
18 sortThreadsForSidebar,19 sortThreadsForSidebar,
19 THREAD_JUMP_HINT_SHOW_DELAY_MS,20 THREAD_JUMP_HINT_SHOW_DELAY_MS,
20} from "./Sidebar.logic";21} from "./Sidebar.logic";
21import { ProjectId, ThreadId } from "@t3tools/contracts";22import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
22import {23import {
23 DEFAULT_INTERACTION_MODE,24 DEFAULT_INTERACTION_MODE,
24 DEFAULT_RUNTIME_MODE,25 DEFAULT_RUNTIME_MODE,
...@@ -29,7 +30,7 @@ import {...@@ -29,7 +30,7 @@ import {
29function makeLatestTurn(overrides?: {30function makeLatestTurn(overrides?: {
30 completedAt?: string | null;31 completedAt?: string | null;
31 startedAt?: string | null;32 startedAt?: string | null;
32}): Parameters<typeof hasUnseenCompletion>[0]["latestTurn"] {33}): OrchestrationLatestTurn {
33 return {34 return {
34 turnId: "turn-1" as never,35 turnId: "turn-1" as never,
35 state: "completed",36 state: "completed",
...@@ -163,6 +164,50 @@ describe("resolveSidebarNewThreadEnvMode", () => {...@@ -163,6 +164,50 @@ describe("resolveSidebarNewThreadEnvMode", () => {
163 });164 });
164});165});
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
166describe("resolveAdjacentThreadId", () => {211describe("resolveAdjacentThreadId", () => {
167 it("resolves adjacent thread ids in ordered sidebar traversal", () => {212 it("resolves adjacent thread ids in ordered sidebar traversal", () => {
168 const threads = [213 const threads = [
...@@ -552,7 +597,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {...@@ -552,7 +597,6 @@ function makeProject(overrides: Partial<Project> = {}): Project {
552 model: "gpt-5.4",597 model: "gpt-5.4",
553 ...defaultModelSelection,598 ...defaultModelSelection,
554 },599 },
555 expanded: true,
556 createdAt: "2026-03-09T10:00:00.000Z",600 createdAt: "2026-03-09T10:00:00.000Z",
557 updatedAt: "2026-03-09T10:00:00.000Z",601 updatedAt: "2026-03-09T10:00:00.000Z",
558 scripts: [],602 scripts: [],
apps/web/src/components/Sidebar.logic.ts+52-12
...@@ -17,7 +17,10 @@ type SidebarProject = {...@@ -17,7 +17,10 @@ type SidebarProject = {
17 createdAt?: string | undefined;17 createdAt?: string | undefined;
18 updatedAt?: string | undefined;18 updatedAt?: string | undefined;
19};19};
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
22export type ThreadTraversalDirection = "previous" | "next";25export type ThreadTraversalDirection = "previous" | "next";
2326
...@@ -45,8 +48,10 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {...@@ -45,8 +48,10 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
4548
46type ThreadStatusInput = Pick<49type ThreadStatusInput = Pick<
47 Thread,50 Thread,
48 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"51 "interactionMode" | "latestTurn" | "proposedPlans" | "session"
49>;52> & {
53 lastVisitedAt?: string | undefined;
54};
5055
51export interface ThreadJumpHintVisibilityController {56export interface ThreadJumpHintVisibilityController {
52 sync: (shouldShow: boolean) => void;57 sync: (shouldShow: boolean) => void;
...@@ -156,6 +161,34 @@ export function resolveSidebarNewThreadEnvMode(input: {...@@ -156,6 +161,34 @@ export function resolveSidebarNewThreadEnvMode(input: {
156 return input.requestedEnvMode ?? input.defaultEnvMode;161 return input.requestedEnvMode ?? input.defaultEnvMode;
157}162}
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
159export function getVisibleSidebarThreadIds<TThreadId>(192export function getVisibleSidebarThreadIds<TThreadId>(
160 renderedProjects: readonly {193 renderedProjects: readonly {
161 shouldShowThreadPanel?: boolean;194 shouldShowThreadPanel?: boolean;
...@@ -327,15 +360,15 @@ export function resolveProjectStatusIndicator(...@@ -327,15 +360,15 @@ export function resolveProjectStatusIndicator(
327 return highestPriorityStatus;360 return highestPriorityStatus;
328}361}
329362
330export function getVisibleThreadsForProject(input: {363export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input: {
331 threads: readonly Thread[];364 threads: readonly T[];
332 activeThreadId: Thread["id"] | undefined;365 activeThreadId: T["id"] | undefined;
333 isThreadListExpanded: boolean;366 isThreadListExpanded: boolean;
334 previewLimit: number;367 previewLimit: number;
335}): {368}): {
336 hasHiddenThreads: boolean;369 hasHiddenThreads: boolean;
337 hiddenThreads: Thread[];370 visibleThreads: T[];
338 visibleThreads: Thread[];371 hiddenThreads: T[];
339} {372} {
340 const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input;373 const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input;
341 const hasHiddenThreads = threads.length > previewLimit;374 const hasHiddenThreads = threads.length > previewLimit;
...@@ -382,9 +415,13 @@ function toSortableTimestamp(iso: string | undefined): number | null {...@@ -382,9 +415,13 @@ function toSortableTimestamp(iso: string | undefined): number | null {
382}415}
383416
384function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number {417function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number {
418 if (thread.latestUserMessageAt) {
419 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
420 }
421
385 let latestUserMessageTimestamp: number | null = null;422 let latestUserMessageTimestamp: number | null = null;
386423
387 for (const message of thread.messages) {424 for (const message of thread.messages ?? []) {
388 if (message.role !== "user") continue;425 if (message.role !== "user") continue;
389 const messageTimestamp = toSortableTimestamp(message.createdAt);426 const messageTimestamp = toSortableTimestamp(message.createdAt);
390 if (messageTimestamp === null) continue;427 if (messageTimestamp === null) continue;
...@@ -412,7 +449,7 @@ function getThreadSortTimestamp(...@@ -412,7 +449,7 @@ function getThreadSortTimestamp(
412}449}
413450
414export function sortThreadsForSidebar<451export function sortThreadsForSidebar<
415 T extends Pick<Thread, "id" | "createdAt" | "updatedAt" | "messages">,452 T extends Pick<Thread, "id" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
416>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] {453>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] {
417 return threads.toSorted((left, right) => {454 return threads.toSorted((left, right) => {
418 const rightTimestamp = getThreadSortTimestamp(right, sortOrder);455 const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
...@@ -425,7 +462,7 @@ export function sortThreadsForSidebar<...@@ -425,7 +462,7 @@ export function sortThreadsForSidebar<
425}462}
426463
427export function getFallbackThreadIdAfterDelete<464export function getFallbackThreadIdAfterDelete<
428 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt" | "messages">,465 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
429>(input: {466>(input: {
430 threads: readonly T[];467 threads: readonly T[];
431 deletedThreadId: T["id"];468 deletedThreadId: T["id"];
...@@ -469,7 +506,10 @@ export function getProjectSortTimestamp(...@@ -469,7 +506,10 @@ export function getProjectSortTimestamp(
469 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;506 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
470}507}
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>(
473 projects: readonly TProject[],513 projects: readonly TProject[],
474 threads: readonly TThread[],514 threads: readonly TThread[],
475 sortOrder: SidebarProjectSortOrder,515 sortOrder: SidebarProjectSortOrder,
apps/web/src/components/Sidebar.tsx+121-11
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 type MouseEvent,21 type MouseEvent,
22 type PointerEvent,22 type PointerEvent,
23} from "react";23} from "react";
24import { useShallow } from "zustand/react/shallow";
24import {25import {
25 DndContext,26 DndContext,
26 type DragCancelEvent,27 type DragCancelEvent,
...@@ -55,6 +56,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding";...@@ -55,6 +56,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
55import { isTerminalFocused } from "../lib/terminalFocus";56import { isTerminalFocused } from "../lib/terminalFocus";
56import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";57import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
57import { useStore } from "../store";58import { useStore } from "../store";
59import { useUiStateStore } from "../uiStateStore";
58import {60import {
59 resolveShortcutCommand,61 resolveShortcutCommand,
60 shortcutLabelForCommand,62 shortcutLabelForCommand,
...@@ -114,6 +116,7 @@ import {...@@ -114,6 +116,7 @@ import {
114 resolveSidebarNewThreadEnvMode,116 resolveSidebarNewThreadEnvMode,
115 resolveThreadRowClassName,117 resolveThreadRowClassName,
116 resolveThreadStatusPill,118 resolveThreadStatusPill,
119 orderItemsByPreferredIds,
117 shouldClearThreadSelectionOnMouseDown,120 shouldClearThreadSelectionOnMouseDown,
118 sortProjectsForSidebar,121 sortProjectsForSidebar,
119 sortThreadsForSidebar,122 sortThreadsForSidebar,
...@@ -122,6 +125,7 @@ import {...@@ -122,6 +125,7 @@ import {
122import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";125import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
123import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";126import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
124import { useSettings, useUpdateSettings } from "~/hooks/useSettings";127import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
128import type { Project, Thread } from "../types";
125129
126const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];130const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
127const THREAD_PREVIEW_LIMIT = 6;131const THREAD_PREVIEW_LIMIT = 6;
...@@ -138,6 +142,80 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {...@@ -138,6 +142,80 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
138 duration: 180,142 duration: 180,
139 easing: "ease-out",143 easing: "ease-out",
140} as const;144} 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}
141interface TerminalStatusIndicator {219interface TerminalStatusIndicator {
142 label: "Terminal process running";220 label: "Terminal process running";
143 colorClass: string;221 colorClass: string;
...@@ -361,10 +439,17 @@ function SortableProjectItem({...@@ -361,10 +439,17 @@ function SortableProjectItem({
361439
362export default function Sidebar() {440export default function Sidebar() {
363 const projects = useStore((store) => store.projects);441 const projects = useStore((store) => store.projects);
364 const threads = useStore((store) => store.threads);442 const serverThreads = useStore((store) => store.threads);
365 const markThreadUnread = useStore((store) => store.markThreadUnread);443 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
366 const toggleProject = useStore((store) => store.toggleProject);444 useShallow((store) => ({
367 const reorderProjects = useStore((store) => store.reorderProjects);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);
368 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);453 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
369 const getDraftThreadByProjectId = useComposerDraftStore(454 const getDraftThreadByProjectId = useComposerDraftStore(
370 (store) => store.getDraftThreadByProjectId,455 (store) => store.getDraftThreadByProjectId,
...@@ -418,6 +503,28 @@ export default function Sidebar() {...@@ -418,6 +503,28 @@ export default function Sidebar() {
418 const platform = navigator.platform;503 const platform = navigator.platform;
419 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;504 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
420 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;505 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 );
421 const projectCwdById = useMemo(528 const projectCwdById = useMemo(
422 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),529 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
423 [projects],530 [projects],
...@@ -752,7 +859,7 @@ export default function Sidebar() {...@@ -752,7 +859,7 @@ export default function Sidebar() {
752 }859 }
753860
754 if (clicked === "mark-unread") {861 if (clicked === "mark-unread") {
755 markThreadUnread(threadId);862 markThreadUnread(threadId, thread.latestTurn?.completedAt);
756 return;863 return;
757 }864 }
758 if (clicked === "copy-path") {865 if (clicked === "copy-path") {
...@@ -814,7 +921,8 @@ export default function Sidebar() {...@@ -814,7 +921,8 @@ export default function Sidebar() {
814921
815 if (clicked === "mark-unread") {922 if (clicked === "mark-unread") {
816 for (const id of ids) {923 for (const id of ids) {
817 markThreadUnread(id);924 const thread = threads.find((candidate) => candidate.id === id);
925 markThreadUnread(id, thread?.latestTurn?.completedAt);
818 }926 }
819 clearSelection();927 clearSelection();
820 return;928 return;
...@@ -845,6 +953,7 @@ export default function Sidebar() {...@@ -845,6 +953,7 @@ export default function Sidebar() {
845 markThreadUnread,953 markThreadUnread,
846 removeFromSelection,954 removeFromSelection,
847 selectedThreadIds,955 selectedThreadIds,
956 threads,
848 ],957 ],
849 );958 );
850959
...@@ -987,12 +1096,12 @@ export default function Sidebar() {...@@ -987,12 +1096,12 @@ export default function Sidebar() {
987 dragInProgressRef.current = false;1096 dragInProgressRef.current = false;
988 const { active, over } = event;1097 const { active, over } = event;
989 if (!over || active.id === over.id) return;1098 if (!over || active.id === over.id) return;
990 const activeProject = projects.find((project) => project.id === active.id);1099 const activeProject = sidebarProjects.find((project) => project.id === active.id);
991 const overProject = projects.find((project) => project.id === over.id);1100 const overProject = sidebarProjects.find((project) => project.id === over.id);
992 if (!activeProject || !overProject) return;1101 if (!activeProject || !overProject) return;
993 reorderProjects(activeProject.id, overProject.id);1102 reorderProjects(activeProject.id, overProject.id);
994 },1103 },
995 [appSettings.sidebarProjectSortOrder, projects, reorderProjects],1104 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],
996 );1105 );
9971106
998 const handleProjectDragStart = useCallback(1107 const handleProjectDragStart = useCallback(
...@@ -1052,8 +1161,9 @@ export default function Sidebar() {...@@ -1052,8 +1161,9 @@ export default function Sidebar() {
1052 [threads],1161 [threads],
1053 );1162 );
1054 const sortedProjects = useMemo(1163 const sortedProjects = useMemo(
1055 () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder),1164 () =>
1056 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],1165 sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder),
1166 [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads],
1057 );1167 );
1058 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";1168 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1059 const renderedProjects = useMemo(1169 const renderedProjects = useMemo(
apps/web/src/composerDraftStore.test.ts+53
...@@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";...@@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
99
10import {10import {
11 COMPOSER_DRAFT_STORAGE_KEY,11 COMPOSER_DRAFT_STORAGE_KEY,
12 clearPromotedDraftThread,
13 clearPromotedDraftThreads,
12 type ComposerImageAttachment,14 type ComposerImageAttachment,
13 useComposerDraftStore,15 useComposerDraftStore,
14} from "./composerDraftStore";16} from "./composerDraftStore";
...@@ -549,6 +551,57 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -549,6 +551,57 @@ describe("composerDraftStore project draft thread mapping", () => {
549 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();551 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
550 });552 });
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
552 it("updates branch context on an existing draft thread", () => {605 it("updates branch context on an existing draft thread", () => {
553 const store = useComposerDraftStore.getState();606 const store = useComposerDraftStore.getState();
554 store.setProjectDraftThreadId(projectId, threadId, {607 store.setProjectDraftThreadId(projectId, threadId, {
apps/web/src/composerDraftStore.ts+14-11
...@@ -2195,18 +2195,21 @@ export function useEffectiveComposerModelState(input: {...@@ -2195,18 +2195,21 @@ export function useEffectiveComposerModelState(input: {
2195}2195}
21962196
2197/**2197/**
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.
2199 *2199 *
2200 * Call this after a snapshot sync so the route guard in `_chat.$threadId`2200 * Use the single-thread helper for live `thread.created` events and the
2201 * sees the server thread before the draft is removed — avoids a redirect2201 * iterable helper for bootstrap/recovery paths that discover multiple server
2202 * to `/` caused by a gap where neither draft nor server thread exists.2202 * threads at once.
2203 */2203 */
2204export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet<ThreadId>): void {2204export function clearPromotedDraftThread(threadId: ThreadId): void {
2205 const store = useComposerDraftStore.getState();2205 if (!useComposerDraftStore.getState().getDraftThread(threadId)) {
2206 const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[];2206 return;
2207 for (const draftId of draftThreadIds) {2207 }
2208 if (serverThreadIds.has(draftId)) {2208 useComposerDraftStore.getState().clearDraftThread(threadId);
2209 store.clearDraftThread(draftId);2209}
2210 }2210
2211export function clearPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
2212 for (const threadId of serverThreadIds) {
2213 clearPromotedDraftThread(threadId);
2211 }2214 }
2212}2215}
apps/web/src/hooks/useHandleNewThread.ts+16-8
...@@ -1,29 +1,37 @@...@@ -1,29 +1,37 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3import { useCallback } from "react";3import { useCallback, useMemo } from "react";
4import { useShallow } from "zustand/react/shallow";
4import {5import {
5 type DraftThreadEnvMode,6 type DraftThreadEnvMode,
6 type DraftThreadState,7 type DraftThreadState,
7 useComposerDraftStore,8 useComposerDraftStore,
8} from "../composerDraftStore";9} from "../composerDraftStore";
9import { newThreadId } from "../lib/utils";10import { newThreadId } from "../lib/utils";
11import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
10import { useStore } from "../store";12import { useStore } from "../store";
13import { useThreadById } from "../storeSelectors";
14import { useUiStateStore } from "../uiStateStore";
1115
12export function useHandleNewThread() {16export function useHandleNewThread() {
13 const projects = useStore((store) => store.projects);17 const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id)));
14 const threads = useStore((store) => store.threads);18 const projectOrder = useUiStateStore((store) => store.projectOrder);
15 const navigate = useNavigate();19 const navigate = useNavigate();
16 const routeThreadId = useParams({20 const routeThreadId = useParams({
17 strict: false,21 strict: false,
18 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),22 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
19 });23 });
24 const activeThread = useThreadById(routeThreadId);
20 const activeDraftThread = useComposerDraftStore((store) =>25 const activeDraftThread = useComposerDraftStore((store) =>
21 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,26 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
22 );27 );
2328 const orderedProjects = useMemo(() => {
24 const activeThread = routeThreadId29 return orderItemsByPreferredIds({
25 ? threads.find((thread) => thread.id === routeThreadId)30 items: projectIds,
26 : undefined;31 preferredIds: projectOrder,
32 getId: (projectId) => projectId,
33 });
34 }, [projectIds, projectOrder]);
2735
28 const handleNewThread = useCallback(36 const handleNewThread = useCallback(
29 (37 (
...@@ -111,8 +119,8 @@ export function useHandleNewThread() {...@@ -111,8 +119,8 @@ export function useHandleNewThread() {
111 return {119 return {
112 activeDraftThread,120 activeDraftThread,
113 activeThread,121 activeThread,
122 defaultProjectId: orderedProjects[0] ?? null,
114 handleNewThread,123 handleNewThread,
115 projects,
116 routeThreadId,124 routeThreadId,
117 };125 };
118}126}
apps/web/src/hooks/useThreadActions.ts+5-8
...@@ -16,8 +16,6 @@ import { toastManager } from "../components/ui/toast";...@@ -16,8 +16,6 @@ import { toastManager } from "../components/ui/toast";
16import { useSettings } from "./useSettings";16import { useSettings } from "./useSettings";
1717
18export function useThreadActions() {18export function useThreadActions() {
19 const threads = useStore((store) => store.threads);
20 const projects = useStore((store) => store.projects);
21 const appSettings = useSettings();19 const appSettings = useSettings();
22 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);20 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
23 const clearProjectDraftThreadById = useComposerDraftStore(21 const clearProjectDraftThreadById = useComposerDraftStore(
...@@ -37,7 +35,7 @@ export function useThreadActions() {...@@ -37,7 +35,7 @@ export function useThreadActions() {
37 async (threadId: ThreadId) => {35 async (threadId: ThreadId) => {
38 const api = readNativeApi();36 const api = readNativeApi();
39 if (!api) return;37 if (!api) return;
40 const thread = threads.find((entry) => entry.id === threadId);38 const thread = useStore.getState().threads.find((entry) => entry.id === threadId);
41 if (!thread) return;39 if (!thread) return;
42 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {40 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
43 throw new Error("Cannot archive a running thread.");41 throw new Error("Cannot archive a running thread.");
...@@ -53,7 +51,7 @@ export function useThreadActions() {...@@ -53,7 +51,7 @@ export function useThreadActions() {
53 await handleNewThread(thread.projectId);51 await handleNewThread(thread.projectId);
54 }52 }
55 },53 },
56 [handleNewThread, routeThreadId, threads],54 [handleNewThread, routeThreadId],
57 );55 );
5856
59 const unarchiveThread = useCallback(async (threadId: ThreadId) => {57 const unarchiveThread = useCallback(async (threadId: ThreadId) => {
...@@ -70,6 +68,7 @@ export function useThreadActions() {...@@ -70,6 +68,7 @@ export function useThreadActions() {
70 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {68 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
71 const api = readNativeApi();69 const api = readNativeApi();
72 if (!api) return;70 if (!api) return;
71 const { projects, threads } = useStore.getState();
73 const thread = threads.find((entry) => entry.id === threadId);72 const thread = threads.find((entry) => entry.id === threadId);
74 if (!thread) return;73 if (!thread) return;
75 const threadProject = projects.find((project) => project.id === thread.projectId);74 const threadProject = projects.find((project) => project.id === thread.projectId);
...@@ -171,10 +170,8 @@ export function useThreadActions() {...@@ -171,10 +170,8 @@ export function useThreadActions() {
171 clearTerminalState,170 clearTerminalState,
172 appSettings.sidebarThreadSortOrder,171 appSettings.sidebarThreadSortOrder,
173 navigate,172 navigate,
174 projects,
175 removeWorktreeMutation,173 removeWorktreeMutation,
176 routeThreadId,174 routeThreadId,
177 threads,
178 ],175 ],
179 );176 );
180177
...@@ -182,7 +179,7 @@ export function useThreadActions() {...@@ -182,7 +179,7 @@ export function useThreadActions() {
182 async (threadId: ThreadId) => {179 async (threadId: ThreadId) => {
183 const api = readNativeApi();180 const api = readNativeApi();
184 if (!api) return;181 if (!api) return;
185 const thread = threads.find((entry) => entry.id === threadId);182 const thread = useStore.getState().threads.find((entry) => entry.id === threadId);
186 if (!thread) return;183 if (!thread) return;
187184
188 if (appSettings.confirmThreadDelete) {185 if (appSettings.confirmThreadDelete) {
...@@ -199,7 +196,7 @@ export function useThreadActions() {...@@ -199,7 +196,7 @@ export function useThreadActions() {
199196
200 await deleteThread(threadId);197 await deleteThread(threadId);
201 },198 },
202 [appSettings.confirmThreadDelete, deleteThread, threads],199 [appSettings.confirmThreadDelete, deleteThread],
203 );200 );
204201
205 return {202 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 @@...@@ -1,11 +1,8 @@
1import { createElement } from "react";1import { createElement } from "react";
2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3import { createRouter } from "@tanstack/react-router";3import { createRouter, RouterHistory } from "@tanstack/react-router";
44
5import { routeTree } from "./routeTree.gen";5import { routeTree } from "./routeTree.gen";
6import { StoreProvider } from "./store";
7
8type RouterHistory = NonNullable<Parameters<typeof createRouter>[0]["history"]>;
96
10export function getRouter(history: RouterHistory) {7export function getRouter(history: RouterHistory) {
11 const queryClient = new QueryClient();8 const queryClient = new QueryClient();
...@@ -16,12 +13,7 @@ export function getRouter(history: RouterHistory) {...@@ -16,12 +13,7 @@ export function getRouter(history: RouterHistory) {
16 context: {13 context: {
17 queryClient,14 queryClient,
18 },15 },
19 Wrap: ({ children }) =>16 Wrap: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children),
20 createElement(
21 QueryClientProvider,
22 { client: queryClient },
23 createElement(StoreProvider, null, children),
24 ),
25 });17 });
26}18}
2719
apps/web/src/routes/__root.tsx+146-47
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";1import { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
2import {2import {
3 Outlet,3 Outlet,
4 createRootRouteWithContext,4 createRootRouteWithContext,
...@@ -17,8 +17,13 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component...@@ -17,8 +17,13 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component
17import { resolveAndPersistPreferredEditor } from "../editorPreferences";17import { resolveAndPersistPreferredEditor } from "../editorPreferences";
18import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";18import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
19import { readNativeApi } from "../nativeApi";19import { readNativeApi } from "../nativeApi";
20import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";20import {
21 clearPromotedDraftThread,
22 clearPromotedDraftThreads,
23 useComposerDraftStore,
24} from "../composerDraftStore";
21import { useStore } from "../store";25import { useStore } from "../store";
26import { useUiStateStore } from "../uiStateStore";
22import { useTerminalStateStore } from "../terminalStateStore";27import { useTerminalStateStore } from "../terminalStateStore";
23import { terminalRunningSubprocessFromEvent } from "../terminalActivity";28import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
24import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";29import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
...@@ -26,6 +31,8 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings";...@@ -26,6 +31,8 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings";
26import { providerQueryKeys } from "../lib/providerReactQuery";31import { providerQueryKeys } from "../lib/providerReactQuery";
27import { projectQueryKeys } from "../lib/projectReactQuery";32import { projectQueryKeys } from "../lib/projectReactQuery";
28import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";33import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
34import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
35import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
2936
30export const Route = createRootRouteWithContext<{37export const Route = createRootRouteWithContext<{
31 queryClient: QueryClient;38 queryClient: QueryClient;
...@@ -135,8 +142,13 @@ function errorDetails(error: unknown): string {...@@ -135,8 +142,13 @@ function errorDetails(error: unknown): string {
135}142}
136143
137function EventRouter() {144function EventRouter() {
145 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
138 const syncServerReadModel = useStore((store) => store.syncServerReadModel);146 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);
140 const removeOrphanedTerminalStates = useTerminalStateStore(152 const removeOrphanedTerminalStates = useTerminalStateStore(
141 (store) => store.removeOrphanedTerminalStates,153 (store) => store.removeOrphanedTerminalStates,
142 );154 );
...@@ -152,56 +164,40 @@ function EventRouter() {...@@ -152,56 +164,40 @@ function EventRouter() {
152 const api = readNativeApi();164 const api = readNativeApi();
153 if (!api) return;165 if (!api) return;
154 let disposed = false;166 let disposed = false;
155 let latestSequence = 0;167 const recovery = createOrchestrationRecoveryCoordinator();
156 let syncing = false;
157 let pending = false;
158 let needsProviderInvalidation = false;168 let needsProviderInvalidation = false;
159169
160 const flushSnapshotSync = async (): Promise<void> => {170 const reconcileSnapshotDerivedState = () => {
161 const snapshot = await api.orchestration.getSnapshot();171 const threads = useStore.getState().threads;
162 if (disposed) return;172 const projects = useStore.getState().projects;
163 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);173 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
164 syncServerReadModel(snapshot);174 syncThreads(
165 clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id)));175 threads.map((thread) => ({
176 id: thread.id,
177 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
178 })),
179 );
180 clearPromotedDraftThreads(threads.map((thread) => thread.id));
166 const draftThreadIds = Object.keys(181 const draftThreadIds = Object.keys(
167 useComposerDraftStore.getState().draftThreadsByThreadId,182 useComposerDraftStore.getState().draftThreadsByThreadId,
168 ) as ThreadId[];183 ) as ThreadId[];
169 const activeThreadIds = collectActiveTerminalThreadIds({184 const activeThreadIds = collectActiveTerminalThreadIds({
170 snapshotThreads: snapshot.threads,185 snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })),
171 draftThreadIds,186 draftThreadIds,
172 });187 });
173 removeOrphanedTerminalStates(activeThreadIds);188 removeOrphanedTerminalStates(activeThreadIds);
174 if (pending) {
175 pending = false;
176 await flushSnapshotSync();
177 }
178 };189 };
179190
180 const syncSnapshot = async () => {191 const queryInvalidationThrottler = new Throttler(
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(
196 () => {192 () => {
197 if (needsProviderInvalidation) {193 if (!needsProviderInvalidation) {
198 needsProviderInvalidation = false;194 return;
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 });
203 }195 }
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 });
205 },201 },
206 {202 {
207 wait: 100,203 wait: 100,
...@@ -210,15 +206,113 @@ function EventRouter() {...@@ -210,15 +206,113 @@ function EventRouter() {
210 },206 },
211 );207 );
212208
213 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {209 const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => {
214 if (event.sequence <= latestSequence) {210 const nextEvents = recovery.markEventBatchApplied(events);
211 if (nextEvents.length === 0) {
215 return;212 return;
216 }213 }
217 latestSequence = event.sequence;214
218 if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") {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) {
219 needsProviderInvalidation = true;224 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();
220 }315 }
221 domainEventFlushThrottler.maybeExecute();
222 });316 });
223 const unsubTerminalEvent = api.terminal.onEvent((event) => {317 const unsubTerminalEvent = api.terminal.onEvent((event) => {
224 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);318 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
...@@ -237,7 +331,7 @@ function EventRouter() {...@@ -237,7 +331,7 @@ function EventRouter() {
237 // Migrate old localStorage settings to server on first connect331 // Migrate old localStorage settings to server on first connect
238 migrateLocalSettingsToServer();332 migrateLocalSettingsToServer();
239 void (async () => {333 void (async () => {
240 await syncSnapshot();334 await bootstrapFromSnapshot();
241 if (disposed) {335 if (disposed) {
242 return;336 return;
243 }337 }
...@@ -319,7 +413,7 @@ function EventRouter() {...@@ -319,7 +413,7 @@ function EventRouter() {
319 return () => {413 return () => {
320 disposed = true;414 disposed = true;
321 needsProviderInvalidation = false;415 needsProviderInvalidation = false;
322 domainEventFlushThrottler.cancel();416 queryInvalidationThrottler.cancel();
323 unsubDomainEvent();417 unsubDomainEvent();
324 unsubTerminalEvent();418 unsubTerminalEvent();
325 unsubWelcome();419 unsubWelcome();
...@@ -327,11 +421,16 @@ function EventRouter() {...@@ -327,11 +421,16 @@ function EventRouter() {
327 unsubProvidersUpdated();421 unsubProvidersUpdated();
328 };422 };
329 }, [423 }, [
424 applyOrchestrationEvents,
330 navigate,425 navigate,
331 queryClient,426 queryClient,
427 removeTerminalState,
332 removeOrphanedTerminalStates,428 removeOrphanedTerminalStates,
429 clearThreadUi,
333 setProjectExpanded,430 setProjectExpanded,
431 syncProjects,
334 syncServerReadModel,432 syncServerReadModel,
433 syncThreads,
335 ]);434 ]);
336435
337 return null;436 return null;
apps/web/src/routes/_chat.$threadId.tsx+4-4
...@@ -161,7 +161,7 @@ const DiffPanelInlineSidebar = (props: {...@@ -161,7 +161,7 @@ const DiffPanelInlineSidebar = (props: {
161};161};
162162
163function ChatThreadRouteView() {163function ChatThreadRouteView() {
164 const threadsHydrated = useStore((store) => store.threadsHydrated);164 const bootstrapComplete = useStore((store) => store.bootstrapComplete);
165 const navigate = useNavigate();165 const navigate = useNavigate();
166 const threadId = Route.useParams({166 const threadId = Route.useParams({
167 select: (params) => ThreadId.makeUnsafe(params.threadId),167 select: (params) => ThreadId.makeUnsafe(params.threadId),
...@@ -202,7 +202,7 @@ function ChatThreadRouteView() {...@@ -202,7 +202,7 @@ function ChatThreadRouteView() {
202 }, [diffOpen]);202 }, [diffOpen]);
203203
204 useEffect(() => {204 useEffect(() => {
205 if (!threadsHydrated) {205 if (!bootstrapComplete) {
206 return;206 return;
207 }207 }
208208
...@@ -210,9 +210,9 @@ function ChatThreadRouteView() {...@@ -210,9 +210,9 @@ function ChatThreadRouteView() {
210 void navigate({ to: "/", replace: true });210 void navigate({ to: "/", replace: true });
211 return;211 return;
212 }212 }
213 }, [navigate, routeThreadExists, threadsHydrated, threadId]);213 }, [bootstrapComplete, navigate, routeThreadExists, threadId]);
214214
215 if (!threadsHydrated || !routeThreadExists) {215 if (!bootstrapComplete || !routeThreadExists) {
216 return null;216 return null;
217 }217 }
218218
apps/web/src/routes/_chat.tsx+14-11
...@@ -17,7 +17,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];...@@ -17,7 +17,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
17function ChatRouteGlobalShortcuts() {17function ChatRouteGlobalShortcuts() {
18 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);18 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
19 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);19 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
20 const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } =20 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
21 useHandleNewThread();21 useHandleNewThread();
22 const serverConfigQuery = useQuery(serverConfigQueryOptions());22 const serverConfigQuery = useQuery(serverConfigQueryOptions());
23 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;23 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
...@@ -38,7 +38,7 @@ function ChatRouteGlobalShortcuts() {...@@ -38,7 +38,7 @@ function ChatRouteGlobalShortcuts() {
38 return;38 return;
39 }39 }
4040
41 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;41 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId;
42 if (!projectId) return;42 if (!projectId) return;
4343
44 const command = resolveShortcutCommand(event, keybindings, {44 const command = resolveShortcutCommand(event, keybindings, {
...@@ -59,14 +59,17 @@ function ChatRouteGlobalShortcuts() {...@@ -59,14 +59,17 @@ function ChatRouteGlobalShortcuts() {
59 return;59 return;
60 }60 }
6161
62 if (command !== "chat.new") return;62 if (command === "chat.new") {
63 event.preventDefault();63 event.preventDefault();
64 event.stopPropagation();64 event.stopPropagation();
65 void handleNewThread(projectId, {65 void handleNewThread(projectId, {
66 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,66 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
67 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,67 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
68 envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),68 envMode:
69 });69 activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
70 });
71 return;
72 }
70 };73 };
7174
72 window.addEventListener("keydown", onWindowKeyDown);75 window.addEventListener("keydown", onWindowKeyDown);
...@@ -79,7 +82,7 @@ function ChatRouteGlobalShortcuts() {...@@ -79,7 +82,7 @@ function ChatRouteGlobalShortcuts() {
79 clearSelection,82 clearSelection,
80 handleNewThread,83 handleNewThread,
81 keybindings,84 keybindings,
82 projects,85 defaultProjectId,
83 selectedThreadIdsSize,86 selectedThreadIdsSize,
84 terminalOpen,87 terminalOpen,
85 appSettings.defaultThreadEnvMode,88 appSettings.defaultThreadEnvMode,
apps/web/src/session-logic.test.ts+32
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8import { describe, expect, it } from "vitest";8import { describe, expect, it } from "vitest";
99
10import {10import {
11 deriveCompletionDividerBeforeEntryId,
11 deriveActiveWorkStartedAt,12 deriveActiveWorkStartedAt,
12 deriveActivePlanState,13 deriveActivePlanState,
13 PROVIDER_OPTIONS,14 PROVIDER_OPTIONS,
...@@ -964,6 +965,37 @@ describe("deriveTimelineEntries", () => {...@@ -964,6 +965,37 @@ describe("deriveTimelineEntries", () => {
964 },965 },
965 });966 });
966 });967 });
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 });
967});999});
9681000
969describe("deriveWorkLogEntries context window handling", () => {1001describe("deriveWorkLogEntries context window handling", () => {
apps/web/src/session-logic.ts+47
...@@ -852,6 +852,53 @@ export function deriveTimelineEntries(...@@ -852,6 +852,53 @@ export function deriveTimelineEntries(
852 );852 );
853}853}
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
855export function inferCheckpointTurnCountByTurnId(902export function inferCheckpointTurnCountByTurnId(
856 summaries: TurnDiffSummary[],903 summaries: TurnDiffSummary[],
857): Record<TurnId, number> {904): Record<TurnId, number> {
apps/web/src/store.test.ts+488-94
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1import {1import {
2 CheckpointRef,
2 DEFAULT_MODEL_BY_PROVIDER,3 DEFAULT_MODEL_BY_PROVIDER,
4 EventId,
5 MessageId,
3 ProjectId,6 ProjectId,
4 ThreadId,7 ThreadId,
5 TurnId,8 TurnId,
9 type OrchestrationEvent,
6 type OrchestrationReadModel,10 type OrchestrationReadModel,
7} from "@t3tools/contracts";11} from "@t3tools/contracts";
8import { describe, expect, it } from "vitest";12import { 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";
11import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";20import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
1221
13function makeThread(overrides: Partial<Thread> = {}): Thread {22function makeThread(overrides: Partial<Thread> = {}): Thread {
...@@ -48,15 +57,41 @@ function makeState(thread: Thread): AppState {...@@ -48,15 +57,41 @@ function makeState(thread: Thread): AppState {
48 provider: "codex",57 provider: "codex",
49 model: "gpt-5-codex",58 model: "gpt-5-codex",
50 },59 },
51 expanded: true,
52 scripts: [],60 scripts: [],
53 },61 },
54 ],62 ],
55 threads: [thread],63 threads: [thread],
56 threadsHydrated: true,64 bootstrapComplete: true,
57 };65 };
58}66}
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
60function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {95function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
61 return {96 return {
62 id: ThreadId.makeUnsafe("thread-1"),97 id: ThreadId.makeUnsafe("thread-1"),
...@@ -126,97 +161,18 @@ function makeReadModelProject(...@@ -126,97 +161,18 @@ function makeReadModelProject(
126 };161 };
127}162}
128163
129describe("store pure functions", () => {164describe("store read model sync", () => {
130 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {165 it("marks bootstrap complete after snapshot sync", () => {
131 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";166 const initialState: AppState = {
132 const initialState = makeState(167 ...makeState(makeThread()),
133 makeThread({168 bootstrapComplete: false,
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,
211 };169 };
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);
216 });174 });
217});
218175
219describe("store read model sync", () => {
220 it("preserves claude model slugs without an active session", () => {176 it("preserves claude model slugs without an active session", () => {
221 const initialState = makeState(makeThread());177 const initialState = makeState(makeThread());
222 const readModel = makeReadModel(178 const readModel = makeReadModel(
...@@ -287,7 +243,7 @@ describe("store read model sync", () => {...@@ -287,7 +243,7 @@ describe("store read model sync", () => {
287 expect(next.threads[0]?.archivedAt).toBe(archivedAt);243 expect(next.threads[0]?.archivedAt).toBe(archivedAt);
288 });244 });
289245
290 it("preserves the current project order when syncing incoming read model updates", () => {246 it("replaces projects using snapshot order during recovery", () => {
291 const project1 = ProjectId.makeUnsafe("project-1");247 const project1 = ProjectId.makeUnsafe("project-1");
292 const project2 = ProjectId.makeUnsafe("project-2");248 const project2 = ProjectId.makeUnsafe("project-2");
293 const project3 = ProjectId.makeUnsafe("project-3");249 const project3 = ProjectId.makeUnsafe("project-3");
...@@ -301,7 +257,6 @@ describe("store read model sync", () => {...@@ -301,7 +257,6 @@ describe("store read model sync", () => {
301 provider: "codex",257 provider: "codex",
302 model: DEFAULT_MODEL_BY_PROVIDER.codex,258 model: DEFAULT_MODEL_BY_PROVIDER.codex,
303 },259 },
304 expanded: true,
305 scripts: [],260 scripts: [],
306 },261 },
307 {262 {
...@@ -312,12 +267,11 @@ describe("store read model sync", () => {...@@ -312,12 +267,11 @@ describe("store read model sync", () => {
312 provider: "codex",267 provider: "codex",
313 model: DEFAULT_MODEL_BY_PROVIDER.codex,268 model: DEFAULT_MODEL_BY_PROVIDER.codex,
314 },269 },
315 expanded: true,
316 scripts: [],270 scripts: [],
317 },271 },
318 ],272 ],
319 threads: [],273 threads: [],
320 threadsHydrated: true,274 bootstrapComplete: true,
321 };275 };
322 const readModel: OrchestrationReadModel = {276 const readModel: OrchestrationReadModel = {
323 snapshotSequence: 2,277 snapshotSequence: 2,
...@@ -344,6 +298,446 @@ describe("store read model sync", () => {...@@ -344,6 +298,446 @@ describe("store read model sync", () => {
344298
345 const next = syncServerReadModel(initialState, readModel);299 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();
348 });742 });
349});743});
apps/web/src/store.ts+784-304
...@@ -1,98 +1,36 @@...@@ -1,98 +1,36 @@
1import { Fragment, type ReactNode, createElement, useEffect } from "react";
2import {1import {
2 type OrchestrationEvent,
3 type OrchestrationMessage,
4 type OrchestrationProposedPlan,
3 type ProviderKind,5 type ProviderKind,
4 ThreadId,6 ThreadId,
5 type OrchestrationReadModel,7 type OrchestrationReadModel,
8 type OrchestrationSession,
9 type OrchestrationCheckpointSummary,
10 type OrchestrationThread,
6 type OrchestrationSessionStatus,11 type OrchestrationSessionStatus,
7} from "@t3tools/contracts";12} from "@t3tools/contracts";
8import { resolveModelSlugForProvider } from "@t3tools/shared/model";13import { resolveModelSlugForProvider } from "@t3tools/shared/model";
9import { create } from "zustand";14import { create } from "zustand";
10import { type ChatMessage, type Project, type Thread } from "./types";15import { type ChatMessage, type Project, type Thread } from "./types";
11import { Debouncer } from "@tanstack/react-pacer";
1216
13// ── State ────────────────────────────────────────────────────────────17// ── State ────────────────────────────────────────────────────────────
1418
15export interface AppState {19export interface AppState {
16 projects: Project[];20 projects: Project[];
17 threads: Thread[];21 threads: Thread[];
18 threadsHydrated: boolean;22 bootstrapComplete: boolean;
19}23}
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
34const initialState: AppState = {25const initialState: AppState = {
35 projects: [],26 projects: [],
36 threads: [],27 threads: [],
37 threadsHydrated: false,28 bootstrapComplete: false,
38};29};
39const persistedExpandedProjectCwds = new Set<string>();30const MAX_THREAD_MESSAGES = 2_000;
40const persistedProjectOrderCwds: string[] = [];31const MAX_THREAD_CHECKPOINTS = 500;
4132const MAX_THREAD_PROPOSED_PLANS = 200;
42// ── Persist helpers ──────────────────────────────────────────────────33const MAX_THREAD_ACTIVITIES = 500;
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 });
9634
97// ── Pure helpers ──────────────────────────────────────────────────────35// ── Pure helpers ──────────────────────────────────────────────────────
9836
...@@ -111,66 +49,295 @@ function updateThread(...@@ -111,66 +49,295 @@ function updateThread(
111 return changed ? next : threads;49 return changed ? next : threads;
112}50}
11351
114function mapProjectsFromReadModel(52function updateProject(
115 incoming: OrchestrationReadModel["projects"],53 projects: Project[],
116 previous: Project[],54 projectId: Project["id"],
55 updater: (project: Project) => Project,
117): Project[] {56): Project[] {
118 const previousById = new Map(previous.map((project) => [project.id, project] as const));57 let changed = false;
119 const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const));58 const next = projects.map((project) => {
120 const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));59 if (project.id !== projectId) {
121 const previousOrderByCwd = new Map(60 return project;
122 previous.map((project, index) => [project.cwd, index] as const),61 }
123 );62 const updated = updater(project);
124 const persistedOrderByCwd = new Map(63 if (updated !== project) {
125 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),64 changed = true;
126 );65 }
127 const usePersistedOrder = previous.length === 0;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) => {80function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): Project["scripts"] {
130 const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot);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;
131 return {246 return {
132 id: project.id,247 ...summary,
133 name: project.title,248 assistantMessageId: assistantMessageId ?? undefined,
134 cwd: project.workspaceRoot,249 };
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;
155 });250 });
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 mappedProjects334function retainThreadProposedPlansAfterRevert(
158 .map((project, incomingIndex) => {335 proposedPlans: ReadonlyArray<Thread["proposedPlans"][number]>,
159 const previousIndex =336 retainedTurnIds: ReadonlySet<string>,
160 previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);337): Thread["proposedPlans"] {
161 const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined;338 return proposedPlans.filter(
162 const orderIndex =339 (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId),
163 previousIndex ??340 );
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);
174}341}
175342
176function toLegacySessionStatus(343function toLegacySessionStatus(
...@@ -234,167 +401,506 @@ function attachmentPreviewRoutePath(attachmentId: string): string {...@@ -234,167 +401,506 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
234// ── Pure state transition functions ────────────────────────────────────401// ── Pure state transition functions ────────────────────────────────────
235402
236export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {403export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
237 const projects = mapProjectsFromReadModel(404 const projects = readModel.projects
238 readModel.projects.filter((project) => project.deletedAt === null),405 .filter((project) => project.deletedAt === null)
239 state.projects,406 .map(mapProject);
240 );407 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
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 });
320 return {408 return {
321 ...state,409 ...state,
322 projects,410 projects,
323 threads,411 threads,
324 threadsHydrated: true,412 bootstrapComplete: true,
325 };413 };
326}414}
327415
328export function markThreadVisited(416export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState {
329 state: AppState,417 switch (event.type) {
330 threadId: ThreadId,418 case "project.created": {
331 visitedAt?: string,419 const existingIndex = state.projects.findIndex(
332): AppState {420 (project) =>
333 const at = visitedAt ?? new Date().toISOString();421 project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot,
334 const visitedAtMs = Date.parse(at);422 );
335 const threads = updateThread(state.threads, threadId, (thread) => {423 const nextProject = mapProject({
336 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;424 id: event.payload.projectId,
337 if (425 title: event.payload.title,
338 Number.isFinite(previousVisitedAtMs) &&426 workspaceRoot: event.payload.workspaceRoot,
339 Number.isFinite(visitedAtMs) &&427 defaultModelSelection: event.payload.defaultModelSelection,
340 previousVisitedAtMs >= visitedAtMs428 scripts: event.payload.scripts,
341 ) {429 createdAt: event.payload.createdAt,
342 return thread;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 };
343 }440 }
344 return { ...thread, lastVisitedAt: at };
345 });
346 return threads === state.threads ? state : { ...state, threads };
347}
348441
349export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {442 case "project.meta-updated": {
350 const threads = updateThread(state.threads, threadId, (thread) => {443 const projects = updateProject(state.projects, event.payload.projectId, (project) => ({
351 if (!thread.latestTurn?.completedAt) return thread;444 ...project,
352 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);445 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),
353 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;446 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),
354 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();447 ...(event.payload.defaultModelSelection !== undefined
355 if (thread.lastVisitedAt === unreadVisitedAt) return thread;448 ? {
356 return { ...thread, lastVisitedAt: unreadVisitedAt };449 defaultModelSelection: event.payload.defaultModelSelection
357 });450 ? normalizeModelSelection(event.payload.defaultModelSelection)
358 return threads === state.threads ? state : { ...state, threads };451 : null,
359}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 {462 case "project.deleted": {
362 return {463 const projects = state.projects.filter((project) => project.id !== event.payload.projectId);
363 ...state,464 return projects.length === state.projects.length ? state : { ...state, projects };
364 projects: state.projects.map((p) => (p.id === projectId ? { ...p, expanded: !p.expanded } : p)),465 }
365 };
366}
367466
368export function setProjectExpanded(467 case "thread.created": {
369 state: AppState,468 const existing = state.threads.find((thread) => thread.id === event.payload.threadId);
370 projectId: Project["id"],469 const nextThread = mapThread({
371 expanded: boolean,470 id: event.payload.threadId,
372): AppState {471 projectId: event.payload.projectId,
373 let changed = false;472 title: event.payload.title,
374 const projects = state.projects.map((p) => {473 modelSelection: event.payload.modelSelection,
375 if (p.id !== projectId || p.expanded === expanded) return p;474 runtimeMode: event.payload.runtimeMode,
376 changed = true;475 interactionMode: event.payload.interactionMode,
377 return { ...p, expanded };476 branch: event.payload.branch,
378 });477 worktreePath: event.payload.worktreePath,
379 return changed ? { ...state, projects } : state;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;
380}882}
381883
382export function reorderProjects(884export function applyOrchestrationEvents(
383 state: AppState,885 state: AppState,
384 draggedProjectId: Project["id"],886 events: ReadonlyArray<OrchestrationEvent>,
385 targetProjectId: Project["id"],
386): AppState {887): AppState {
387 if (draggedProjectId === targetProjectId) return state;888 if (events.length === 0) {
388 const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId);889 return state;
389 const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId);890 }
390 if (draggedIndex < 0 || targetIndex < 0) return state;891 return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), 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 };
396}892}
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
398export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {904export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
399 const threads = updateThread(state.threads, threadId, (t) => {905 const threads = updateThread(state.threads, threadId, (t) => {
400 if (t.error === error) return t;906 if (t.error === error) return t;
...@@ -426,44 +932,18 @@ export function setThreadBranch(...@@ -426,44 +932,18 @@ export function setThreadBranch(
426932
427interface AppStore extends AppState {933interface AppStore extends AppState {
428 syncServerReadModel: (readModel: OrchestrationReadModel) => void;934 syncServerReadModel: (readModel: OrchestrationReadModel) => void;
429 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;935 applyOrchestrationEvent: (event: OrchestrationEvent) => void;
430 markThreadUnread: (threadId: ThreadId) => void;936 applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void;
431 toggleProject: (projectId: Project["id"]) => void;
432 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
433 reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void;
434 setError: (threadId: ThreadId, error: string | null) => void;937 setError: (threadId: ThreadId, error: string | null) => void;
435 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;938 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
436}939}
437940
438export const useStore = create<AppStore>((set) => ({941export const useStore = create<AppStore>((set) => ({
439 ...readPersistedState(),942 ...initialState,
440 syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),943 syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),
441 markThreadVisited: (threadId, visitedAt) =>944 applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)),
442 set((state) => markThreadVisited(state, threadId, visitedAt)),945 applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)),
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)),
449 setError: (threadId, error) => set((state) => setError(state, threadId, error)),946 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
450 setThreadBranch: (threadId, branch, worktreePath) =>947 setThreadBranch: (threadId, branch, worktreePath) =>
451 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),948 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
452}));949}));
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 {...@@ -485,6 +485,7 @@ interface TerminalStateStoreState {
485 hasRunningSubprocess: boolean,485 hasRunningSubprocess: boolean,
486 ) => void;486 ) => void;
487 clearTerminalState: (threadId: ThreadId) => void;487 clearTerminalState: (threadId: ThreadId) => void;
488 removeTerminalState: (threadId: ThreadId) => void;
488 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;489 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
489}490}
490491
...@@ -530,6 +531,15 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -530,6 +531,15 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
530 ),531 ),
531 clearTerminalState: (threadId) =>532 clearTerminalState: (threadId) =>
532 updateTerminal(threadId, () => createDefaultThreadTerminalState()),533 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 }),
533 removeOrphanedTerminalStates: (activeThreadIds) =>543 removeOrphanedTerminalStates: (activeThreadIds) =>
534 set((state) => {544 set((state) => {
535 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(545 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
apps/web/src/types.ts+2-2
...@@ -45,6 +45,7 @@ export interface ChatMessage {...@@ -45,6 +45,7 @@ export interface ChatMessage {
45 role: "user" | "assistant" | "system";45 role: "user" | "assistant" | "system";
46 text: string;46 text: string;
47 attachments?: ChatAttachment[];47 attachments?: ChatAttachment[];
48 turnId?: TurnId | null;
48 createdAt: string;49 createdAt: string;
49 completedAt?: string | undefined;50 completedAt?: string | undefined;
50 streaming: boolean;51 streaming: boolean;
...@@ -82,7 +83,6 @@ export interface Project {...@@ -82,7 +83,6 @@ export interface Project {
82 name: string;83 name: string;
83 cwd: string;84 cwd: string;
84 defaultModelSelection: ModelSelection | null;85 defaultModelSelection: ModelSelection | null;
85 expanded: boolean;
86 createdAt?: string | undefined;86 createdAt?: string | undefined;
87 updatedAt?: string | undefined;87 updatedAt?: string | undefined;
88 scripts: ProjectScript[];88 scripts: ProjectScript[];
...@@ -104,7 +104,7 @@ export interface Thread {...@@ -104,7 +104,7 @@ export interface Thread {
104 archivedAt: string | null;104 archivedAt: string | null;
105 updatedAt?: string | undefined;105 updatedAt?: string | undefined;
106 latestTurn: OrchestrationLatestTurn | null;106 latestTurn: OrchestrationLatestTurn | null;
107 lastVisitedAt?: string | undefined;107 pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"];
108 branch: string | null;108 branch: string | null;
109 worktreePath: string | null;109 worktreePath: string | null;
110 turnDiffSummaries: TurnDiffSummary[];110 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}