| author | |
| committer | |
| log | 0ea639196a7bed94deb08f5c96d147418f4e9749 |
| tree | 59062ffa89347de7fb4df33d480b20c29536bd6a |
| parent | 6f517257325f7c8bcac660595f2cbc53c1d32b35 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2 | import "../index.css"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | EventId, | |
| 5 | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | ORCHESTRATION_WS_CHANNELS, | |
| 6 | 8 | type MessageId, |
| 9 | type OrchestrationEvent, | |
| 7 | 10 | type OrchestrationReadModel, |
| 8 | 11 | type ProjectId, |
| 9 | 12 | type ServerConfig, |
| ... | ... | @@ -57,6 +60,8 @@ interface TestFixture { |
| 57 | 60 | let fixture: TestFixture; |
| 58 | 61 | const wsRequests: WsRequestEnvelope["body"][] = []; |
| 59 | 62 | let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null; |
| 63 | let wsClient: { send: (message: string) => void } | null = null; | |
| 64 | let pushSequence = 1; | |
| 60 | 65 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 61 | 66 | |
| 62 | 67 | interface ViewportSpec { |
| ... | ... | @@ -336,6 +341,79 @@ function addThreadToSnapshot( |
| 336 | 341 | }; |
| 337 | 342 | } |
| 338 | 343 | |
| 344 | function 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 | ||
| 374 | function 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 | ||
| 388 | async 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 | ||
| 403 | async 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 | ||
| 339 | 417 | function createDraftOnlySnapshot(): OrchestrationReadModel { |
| 340 | 418 | const snapshot = createSnapshotForTargetUser({ |
| 341 | 419 | targetMessageId: "msg-user-draft-target" as MessageId, |
| ... | ... | @@ -500,10 +578,12 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 500 | 578 | |
| 501 | 579 | const worker = setupWorker( |
| 502 | 580 | wsLink.addEventListener("connection", ({ client }) => { |
| 581 | wsClient = client; | |
| 582 | pushSequence = 1; | |
| 503 | 583 | client.send( |
| 504 | 584 | JSON.stringify({ |
| 505 | 585 | type: "push", |
| 506 | sequence: 1, | |
| 586 | sequence: pushSequence++, | |
| 507 | 587 | channel: WS_CHANNELS.serverWelcome, |
| 508 | 588 | data: fixture.welcome, |
| 509 | 589 | }), |
| ... | ... | @@ -875,7 +955,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 875 | 955 | useStore.setState({ |
| 876 | 956 | projects: [], |
| 877 | 957 | threads: [], |
| 878 | threadsHydrated: false, | |
| 958 | bootstrapComplete: false, | |
| 879 | 959 | }); |
| 880 | 960 | }); |
| 881 | 961 | |
| ... | ... | @@ -1881,21 +1961,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1881 | 1961 | // The composer editor should be present for the new draft thread. |
| 1882 | 1962 | await waitForComposerEditor(); |
| 1883 | 1963 | |
| 1884 | // Simulate the snapshot sync arriving from the server after the draft | |
| 1885 | // thread has been promoted to a server thread (thread.create + turn.start | |
| 1886 | // succeeded). The snapshot now includes the new thread, and the sync | |
| 1887 | // should clear the draft without disrupting the route. | |
| 1888 | const { syncServerReadModel } = useStore.getState(); | |
| 1889 | syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); | |
| 1890 | ||
| 1891 | // Clear the draft now that the server thread exists (mirrors EventRouter behavior). | |
| 1892 | useComposerDraftStore.getState().clearDraftThread(newThreadId); | |
| 1964 | // Simulate the steady-state promotion path: the server emits | |
| 1965 | // `thread.created`, the client materializes the thread incrementally, | |
| 1966 | // and the draft is cleared by live batch effects. | |
| 1967 | await promoteDraftThreadViaDomainEvent(newThreadId); | |
| 1893 | 1968 | |
| 1894 | 1969 | // The route should still be on the new thread — not redirected away. |
| 1895 | 1970 | await waitForURL( |
| 1896 | 1971 | mounted.router, |
| 1897 | 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 | ); |
| 1900 | 1975 | |
| 1901 | 1976 | // The empty thread view and composer should still be visible. |
| ... | ... | @@ -2217,9 +2292,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2217 | 2292 | ); |
| 2218 | 2293 | const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; |
| 2219 | 2294 | |
| 2220 | const { syncServerReadModel } = useStore.getState(); | |
| 2221 | syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); | |
| 2222 | useComposerDraftStore.getState().clearDraftThread(promotedThreadId); | |
| 2295 | await promoteDraftThreadViaDomainEvent(promotedThreadId); | |
| 2223 | 2296 | |
| 2224 | 2297 | const freshThreadPath = await triggerChatNewShortcutUntilPath( |
| 2225 | 2298 | mounted.router, |
apps/web/src/components/ChatView.logic.test.ts+297-3| ... | ... | @@ -1,7 +1,14 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 3 | import { useStore } from "../store"; | |
| 3 | 4 | |
| 4 | import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; | |
| 5 | import { | |
| 6 | buildExpiredTerminalContextToastCopy, | |
| 7 | createLocalDispatchSnapshot, | |
| 8 | deriveComposerSendState, | |
| 9 | hasServerAcknowledgedLocalDispatch, | |
| 10 | waitForStartedServerThread, | |
| 11 | } from "./ChatView.logic"; | |
| 5 | 12 | |
| 6 | 13 | describe("deriveComposerSendState", () => { |
| 7 | 14 | it("treats expired terminal pills as non-sendable content", () => { |
| ... | ... | @@ -67,3 +74,290 @@ describe("buildExpiredTerminalContextToastCopy", () => { |
| 67 | 74 | }); |
| 68 | 75 | }); |
| 69 | 76 | }); |
| 77 | ||
| 78 | const 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 | ||
| 114 | afterEach(() => { | |
| 115 | vi.useRealTimers(); | |
| 116 | vi.restoreAllMocks(); | |
| 117 | useStore.setState((state) => ({ | |
| 118 | ...state, | |
| 119 | projects: [], | |
| 120 | threads: [], | |
| 121 | bootstrapComplete: true, | |
| 122 | })); | |
| 123 | }); | |
| 124 | ||
| 125 | describe("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 | ||
| 225 | describe("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 | import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; | |
| 2 | import { type ChatMessage, type Thread } from "../types"; | |
| 1 | import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; | |
| 2 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; | |
| 3 | 3 | import { randomUUID } from "~/lib/utils"; |
| 4 | 4 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 5 | 5 | import { Schema } from "effect"; |
| 6 | import { useStore } from "../store"; | |
| 6 | 7 | import { |
| 7 | 8 | filterTerminalContextsWithText, |
| 8 | 9 | stripInlineTerminalContextPlaceholders, |
| ... | ... | @@ -34,7 +35,6 @@ export function buildLocalDraftThread( |
| 34 | 35 | createdAt: draftThread.createdAt, |
| 35 | 36 | archivedAt: null, |
| 36 | 37 | latestTurn: null, |
| 37 | lastVisitedAt: draftThread.createdAt, | |
| 38 | 38 | branch: draftThread.branch, |
| 39 | 39 | worktreePath: draftThread.worktreePath, |
| 40 | 40 | turnDiffSummaries: [], |
| ... | ... | @@ -75,8 +75,6 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ |
| 75 | 75 | return previewUrls; |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | |
| 79 | ||
| 80 | 78 | export interface PullRequestDialogState { |
| 81 | 79 | initialReference: string | null; |
| 82 | 80 | key: number; |
| ... | ... | @@ -161,3 +159,116 @@ export function buildExpiredTerminalContextToastCopy( |
| 161 | 159 | description: "Re-add it if you want that terminal output included.", |
| 162 | 160 | }; |
| 163 | 161 | } |
| 162 | ||
| 163 | export 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 | ||
| 169 | export 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 | ||
| 213 | export 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 | ||
| 224 | export 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 | ||
| 242 | export 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 | 20 | OrchestrationThreadActivity, |
| 21 | 21 | ProviderInteractionMode, |
| 22 | 22 | RuntimeMode, |
| 23 | TerminalOpenInput, | |
| 23 | 24 | } from "@t3tools/contracts"; |
| 24 | 25 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; |
| 25 | 26 | import { truncate } from "@t3tools/shared/String"; |
| ... | ... | @@ -42,6 +43,7 @@ import { |
| 42 | 43 | replaceTextRange, |
| 43 | 44 | } from "../composer-logic"; |
| 44 | 45 | import { |
| 46 | deriveCompletionDividerBeforeEntryId, | |
| 45 | 47 | derivePendingApprovals, |
| 46 | 48 | derivePendingUserInputs, |
| 47 | 49 | derivePhase, |
| ... | ... | @@ -64,6 +66,8 @@ import { |
| 64 | 66 | type PendingUserInputDraftAnswer, |
| 65 | 67 | } from "../pendingUserInput"; |
| 66 | 68 | import { useStore } from "../store"; |
| 69 | import { useProjectById, useThreadById } from "../storeSelectors"; | |
| 70 | import { useUiStateStore } from "../uiStateStore"; | |
| 67 | 71 | import { |
| 68 | 72 | buildPlanImplementationThreadTitle, |
| 69 | 73 | buildPlanImplementationPrompt, |
| ... | ... | @@ -76,8 +80,12 @@ import { |
| 76 | 80 | DEFAULT_THREAD_TERMINAL_ID, |
| 77 | 81 | MAX_TERMINALS_PER_GROUP, |
| 78 | 82 | type ChatMessage, |
| 83 | type SessionPhase, | |
| 84 | type Thread, | |
| 79 | 85 | type TurnDiffSummary, |
| 80 | 86 | } from "../types"; |
| 87 | import { LRUCache } from "../lib/lruCache"; | |
| 88 | ||
| 81 | 89 | import { basenameOfPath } from "../vscode-icons"; |
| 82 | 90 | import { useTheme } from "../hooks/useTheme"; |
| 83 | 91 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | ... | @@ -168,14 +176,18 @@ import { |
| 168 | 176 | buildTemporaryWorktreeBranchName, |
| 169 | 177 | cloneComposerImageForRetry, |
| 170 | 178 | collectUserMessageBlobPreviewUrls, |
| 179 | createLocalDispatchSnapshot, | |
| 171 | 180 | deriveComposerSendState, |
| 181 | hasServerAcknowledgedLocalDispatch, | |
| 172 | 182 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 173 | 183 | LastInvokedScriptByProjectSchema, |
| 184 | type LocalDispatchSnapshot, | |
| 174 | 185 | PullRequestDialogState, |
| 175 | 186 | readFileAsDataUrl, |
| 176 | 187 | revokeBlobPreviewUrl, |
| 177 | 188 | revokeUserMessagePreviewUrls, |
| 178 | SendPhase, | |
| 189 | threadHasStarted, | |
| 190 | waitForStartedServerThread, | |
| 179 | 191 | } from "./ChatView.logic"; |
| 180 | 192 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 181 | 193 | |
| ... | ... | @@ -190,6 +202,81 @@ const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 190 | 202 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 191 | 203 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 192 | 204 | |
| 205 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | |
| 206 | ||
| 207 | const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; | |
| 208 | const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; | |
| 209 | const 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 | ||
| 214 | function 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 | ||
| 230 | function 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 | ||
| 251 | function 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 | ||
| 193 | 280 | function formatOutgoingPrompt(params: { |
| 194 | 281 | provider: ProviderKind; |
| 195 | 282 | model: string | null; |
| ... | ... | @@ -245,13 +332,81 @@ interface PendingPullRequestSetupRequest { |
| 245 | 332 | scriptId: string; |
| 246 | 333 | } |
| 247 | 334 | |
| 335 | function 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 | ||
| 248 | 402 | export default function ChatView({ threadId }: ChatViewProps) { |
| 249 | const threads = useStore((store) => store.threads); | |
| 250 | const projects = useStore((store) => store.projects); | |
| 251 | const markThreadVisited = useStore((store) => store.markThreadVisited); | |
| 252 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); | |
| 403 | const serverThread = useThreadById(threadId); | |
| 253 | 404 | const setStoreThreadError = useStore((store) => store.setError); |
| 254 | 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 | 410 | const settings = useSettings(); |
| 256 | 411 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 257 | 412 | (store) => store.setStickyModelSelection, |
| ... | ... | @@ -330,8 +485,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 330 | 485 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 331 | 486 | Record<ThreadId, string | null> |
| 332 | 487 | >({}); |
| 333 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | |
| 334 | const [sendStartedAt, setSendStartedAt] = useState<string | null>(null); | |
| 335 | 488 | const [isConnecting, _setIsConnecting] = useState(false); |
| 336 | 489 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 337 | 490 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| ... | ... | @@ -466,8 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 466 | 619 | [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], |
| 467 | 620 | ); |
| 468 | 621 | |
| 469 | const serverThread = threads.find((t) => t.id === threadId); | |
| 470 | const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); | |
| 622 | const fallbackDraftProject = useProjectById(draftThread?.projectId); | |
| 471 | 623 | const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); |
| 472 | 624 | const localDraftThread = useMemo( |
| 473 | 625 | () => |
| ... | ... | @@ -495,12 +647,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 495 | 647 | const diffOpen = rawSearch.diff === "1"; |
| 496 | 648 | const activeThreadId = activeThread?.id ?? null; |
| 497 | 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 | 663 | const activeContextWindow = useMemo( |
| 499 | 664 | () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), |
| 500 | 665 | [activeThread?.activities], |
| 501 | 666 | ); |
| 502 | 667 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 503 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); | |
| 668 | const activeProject = useProjectById(activeThread?.projectId); | |
| 504 | 669 | |
| 505 | 670 | const openPullRequestDialog = useCallback( |
| 506 | 671 | (reference?: string) => { |
| ... | ... | @@ -595,33 +760,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 595 | 760 | ); |
| 596 | 761 | |
| 597 | 762 | useEffect(() => { |
| 598 | if (!activeThread?.id) return; | |
| 763 | if (!serverThread?.id) return; | |
| 599 | 764 | if (!latestTurnSettled) return; |
| 600 | 765 | if (!activeLatestTurn?.completedAt) return; |
| 601 | 766 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); |
| 602 | 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 | 769 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| 605 | 770 | |
| 606 | markThreadVisited(activeThread.id); | |
| 771 | markThreadVisited(serverThread.id); | |
| 607 | 772 | }, [ |
| 608 | activeThread?.id, | |
| 609 | activeThread?.lastVisitedAt, | |
| 610 | 773 | activeLatestTurn?.completedAt, |
| 774 | activeThreadLastVisitedAt, | |
| 611 | 775 | latestTurnSettled, |
| 612 | 776 | markThreadVisited, |
| 777 | serverThread?.id, | |
| 613 | 778 | ]); |
| 614 | 779 | |
| 615 | 780 | const sessionProvider = activeThread?.session?.provider ?? null; |
| 616 | 781 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 617 | 782 | const threadProvider = |
| 618 | 783 | activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; |
| 619 | const hasThreadStarted = Boolean( | |
| 620 | activeThread && | |
| 621 | (activeThread.latestTurn !== null || | |
| 622 | activeThread.messages.length > 0 || | |
| 623 | activeThread.session !== null), | |
| 624 | ); | |
| 784 | const hasThreadStarted = threadHasStarted(activeThread); | |
| 625 | 785 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 626 | 786 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 627 | 787 | : null; |
| ... | ... | @@ -664,15 +824,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 664 | 824 | ); |
| 665 | 825 | const selectedModelForPicker = selectedModel; |
| 666 | 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 | 827 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 677 | 828 | const workLogEntries = useMemo( |
| 678 | 829 | () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| ... | ... | @@ -735,12 +886,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 735 | 886 | const sidebarProposedPlan = useMemo( |
| 736 | 887 | () => |
| 737 | 888 | findSidebarProposedPlan({ |
| 738 | threads, | |
| 889 | threads: threadPlanCatalog, | |
| 739 | 890 | latestTurn: activeLatestTurn, |
| 740 | 891 | latestTurnSettled, |
| 741 | 892 | threadId: activeThread?.id ?? null, |
| 742 | 893 | }), |
| 743 | [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], | |
| 894 | [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], | |
| 744 | 895 | ); |
| 745 | 896 | const activePlan = useMemo( |
| 746 | 897 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| ... | ... | @@ -752,6 +903,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 752 | 903 | latestTurnSettled && |
| 753 | 904 | hasActionableProposedPlan(activeProposedPlan); |
| 754 | 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 | 927 | const isComposerApprovalState = activePendingApproval !== null; |
| 756 | 928 | const hasComposerHeader = |
| 757 | 929 | isComposerApprovalState || |
| ... | ... | @@ -980,35 +1152,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 980 | 1152 | ]); |
| 981 | 1153 | const completionDividerBeforeEntryId = useMemo(() => { |
| 982 | 1154 | if (!latestTurnSettled) return null; |
| 983 | if (!activeLatestTurn?.startedAt) return null; | |
| 984 | if (!activeLatestTurn.completedAt) return null; | |
| 985 | 1155 | if (!completionSummary) return null; |
| 986 | ||
| 987 | const turnStartedAt = Date.parse(activeLatestTurn.startedAt); | |
| 988 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); | |
| 989 | if (Number.isNaN(turnStartedAt)) return null; | |
| 990 | if (Number.isNaN(turnCompletedAt)) return null; | |
| 991 | ||
| 992 | let inRangeMatch: string | null = null; | |
| 993 | let fallbackMatch: string | null = null; | |
| 994 | for (const timelineEntry of timelineEntries) { | |
| 995 | if (timelineEntry.kind !== "message") continue; | |
| 996 | if (timelineEntry.message.role !== "assistant") continue; | |
| 997 | const messageAt = Date.parse(timelineEntry.message.createdAt); | |
| 998 | if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; | |
| 999 | fallbackMatch = timelineEntry.id; | |
| 1000 | if (messageAt <= turnCompletedAt) { | |
| 1001 | inRangeMatch = timelineEntry.id; | |
| 1002 | } | |
| 1003 | } | |
| 1004 | return inRangeMatch ?? fallbackMatch; | |
| 1005 | }, [ | |
| 1006 | activeLatestTurn?.completedAt, | |
| 1007 | activeLatestTurn?.startedAt, | |
| 1008 | completionSummary, | |
| 1009 | latestTurnSettled, | |
| 1010 | timelineEntries, | |
| 1011 | ]); | |
| 1156 | return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); | |
| 1157 | }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); | |
| 1012 | 1158 | const gitCwd = activeProject |
| 1013 | 1159 | ? projectScriptCwd({ |
| 1014 | 1160 | project: { cwd: activeProject.cwd }, |
| ... | ... | @@ -1230,7 +1376,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1230 | 1376 | const setThreadError = useCallback( |
| 1231 | 1377 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 1232 | 1378 | if (!targetThreadId) return; |
| 1233 | if (threads.some((thread) => thread.id === targetThreadId)) { | |
| 1379 | if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { | |
| 1234 | 1380 | setStoreThreadError(targetThreadId, error); |
| 1235 | 1381 | return; |
| 1236 | 1382 | } |
| ... | ... | @@ -1244,7 +1390,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1244 | 1390 | }; |
| 1245 | 1391 | }); |
| 1246 | 1392 | }, |
| 1247 | [setStoreThreadError, threads], | |
| 1393 | [setStoreThreadError], | |
| 1248 | 1394 | ); |
| 1249 | 1395 | |
| 1250 | 1396 | const focusComposer = useCallback(() => { |
| ... | ... | @@ -1411,7 +1557,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1411 | 1557 | worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, |
| 1412 | 1558 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 1413 | 1559 | }); |
| 1414 | const openTerminalInput: Parameters<typeof api.terminal.open>[0] = shouldCreateNewTerminal | |
| 1560 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal | |
| 1415 | 1561 | ? { |
| 1416 | 1562 | threadId: activeThreadId, |
| 1417 | 1563 | terminalId: targetTerminalId, |
| ... | ... | @@ -2020,15 +2166,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2020 | 2166 | } |
| 2021 | 2167 | return []; |
| 2022 | 2168 | }); |
| 2023 | setSendPhase("idle"); | |
| 2024 | setSendStartedAt(null); | |
| 2169 | resetLocalDispatch(); | |
| 2025 | 2170 | setComposerHighlightedItemId(null); |
| 2026 | 2171 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| 2027 | 2172 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 2028 | 2173 | dragDepthRef.current = 0; |
| 2029 | 2174 | setIsDragOverComposer(false); |
| 2030 | 2175 | setExpandedImage(null); |
| 2031 | }, [threadId]); | |
| 2176 | }, [resetLocalDispatch, threadId]); | |
| 2032 | 2177 | |
| 2033 | 2178 | useEffect(() => { |
| 2034 | 2179 | let cancelled = false; |
| ... | ... | @@ -2161,37 +2306,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2161 | 2306 | }; |
| 2162 | 2307 | }, [phase]); |
| 2163 | 2308 | |
| 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 | 2309 | useEffect(() => { |
| 2196 | 2310 | if (!activeThreadId) return; |
| 2197 | 2311 | const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; |
| ... | ... | @@ -2529,7 +2643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2529 | 2643 | } |
| 2530 | 2644 | |
| 2531 | 2645 | sendInFlightRef.current = true; |
| 2532 | beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); | |
| 2646 | beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); | |
| 2533 | 2647 | |
| 2534 | 2648 | const composerImagesSnapshot = [...composerImages]; |
| 2535 | 2649 | const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; |
| ... | ... | @@ -2603,7 +2717,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2603 | 2717 | await (async () => { |
| 2604 | 2718 | // On first message: lock in branch + create worktree if needed. |
| 2605 | 2719 | if (baseBranchForWorktree) { |
| 2606 | beginSendPhase("preparing-worktree"); | |
| 2720 | beginLocalDispatch({ preparingWorktree: true }); | |
| 2607 | 2721 | const newBranch = buildTemporaryWorktreeBranchName(); |
| 2608 | 2722 | const result = await createWorktreeMutation.mutateAsync({ |
| 2609 | 2723 | cwd: activeProject.cwd, |
| ... | ... | @@ -2715,7 +2829,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2715 | 2829 | }); |
| 2716 | 2830 | } |
| 2717 | 2831 | |
| 2718 | beginSendPhase("sending-turn"); | |
| 2832 | beginLocalDispatch({ preparingWorktree: false }); | |
| 2719 | 2833 | const turnAttachments = await turnAttachmentsPromise; |
| 2720 | 2834 | await api.orchestration.dispatchCommand({ |
| 2721 | 2835 | type: "thread.turn.start", |
| ... | ... | @@ -2772,7 +2886,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2772 | 2886 | }); |
| 2773 | 2887 | sendInFlightRef.current = false; |
| 2774 | 2888 | if (!turnStartSucceeded) { |
| 2775 | resetSendPhase(); | |
| 2889 | resetLocalDispatch(); | |
| 2776 | 2890 | } |
| 2777 | 2891 | }; |
| 2778 | 2892 | |
| ... | ... | @@ -2971,7 +3085,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2971 | 3085 | }); |
| 2972 | 3086 | |
| 2973 | 3087 | sendInFlightRef.current = true; |
| 2974 | beginSendPhase("sending-turn"); | |
| 3088 | beginLocalDispatch({ preparingWorktree: false }); | |
| 2975 | 3089 | setThreadError(threadIdForSend, null); |
| 2976 | 3090 | setOptimisticUserMessages((existing) => [ |
| 2977 | 3091 | ...existing, |
| ... | ... | @@ -3040,19 +3154,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3040 | 3154 | err instanceof Error ? err.message : "Failed to send plan follow-up.", |
| 3041 | 3155 | ); |
| 3042 | 3156 | sendInFlightRef.current = false; |
| 3043 | resetSendPhase(); | |
| 3157 | resetLocalDispatch(); | |
| 3044 | 3158 | } |
| 3045 | 3159 | }, |
| 3046 | 3160 | [ |
| 3047 | 3161 | activeThread, |
| 3048 | 3162 | activeProposedPlan, |
| 3049 | beginSendPhase, | |
| 3163 | beginLocalDispatch, | |
| 3050 | 3164 | forceStickToBottom, |
| 3051 | 3165 | isConnecting, |
| 3052 | 3166 | isSendBusy, |
| 3053 | 3167 | isServerThread, |
| 3054 | 3168 | persistThreadSettingsForNextTurn, |
| 3055 | resetSendPhase, | |
| 3169 | resetLocalDispatch, | |
| 3056 | 3170 | runtimeMode, |
| 3057 | 3171 | selectedPromptEffort, |
| 3058 | 3172 | selectedModelSelection, |
| ... | ... | @@ -3094,10 +3208,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3094 | 3208 | const nextThreadModelSelection: ModelSelection = selectedModelSelection; |
| 3095 | 3209 | |
| 3096 | 3210 | sendInFlightRef.current = true; |
| 3097 | beginSendPhase("sending-turn"); | |
| 3211 | beginLocalDispatch({ preparingWorktree: false }); | |
| 3098 | 3212 | const finish = () => { |
| 3099 | 3213 | sendInFlightRef.current = false; |
| 3100 | resetSendPhase(); | |
| 3214 | resetLocalDispatch(); | |
| 3101 | 3215 | }; |
| 3102 | 3216 | |
| 3103 | 3217 | await api.orchestration |
| ... | ... | @@ -3132,9 +3246,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3132 | 3246 | createdAt, |
| 3133 | 3247 | }); |
| 3134 | 3248 | }) |
| 3135 | .then(() => api.orchestration.getSnapshot()) | |
| 3136 | .then((snapshot) => { | |
| 3137 | syncServerReadModel(snapshot); | |
| 3249 | .then(() => { | |
| 3250 | return waitForStartedServerThread(nextThreadId); | |
| 3251 | }) | |
| 3252 | .then(() => { | |
| 3138 | 3253 | // Signal that the plan sidebar should open on the new thread. |
| 3139 | 3254 | planSidebarOpenOnNextThreadRef.current = true; |
| 3140 | 3255 | return navigate({ |
| ... | ... | @@ -3150,12 +3265,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3150 | 3265 | threadId: nextThreadId, |
| 3151 | 3266 | }) |
| 3152 | 3267 | .catch(() => undefined); |
| 3153 | await api.orchestration | |
| 3154 | .getSnapshot() | |
| 3155 | .then((snapshot) => { | |
| 3156 | syncServerReadModel(snapshot); | |
| 3157 | }) | |
| 3158 | .catch(() => undefined); | |
| 3159 | 3268 | toastManager.add({ |
| 3160 | 3269 | type: "error", |
| 3161 | 3270 | title: "Could not start implementation thread", |
| ... | ... | @@ -3168,18 +3277,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3168 | 3277 | activeProject, |
| 3169 | 3278 | activeProposedPlan, |
| 3170 | 3279 | activeThread, |
| 3171 | beginSendPhase, | |
| 3280 | beginLocalDispatch, | |
| 3172 | 3281 | isConnecting, |
| 3173 | 3282 | isSendBusy, |
| 3174 | 3283 | isServerThread, |
| 3175 | 3284 | navigate, |
| 3176 | resetSendPhase, | |
| 3285 | resetLocalDispatch, | |
| 3177 | 3286 | runtimeMode, |
| 3178 | 3287 | selectedPromptEffort, |
| 3179 | 3288 | selectedModelSelection, |
| 3180 | 3289 | selectedProvider, |
| 3181 | 3290 | selectedProviderModels, |
| 3182 | syncServerReadModel, | |
| 3183 | 3291 | selectedModel, |
| 3184 | 3292 | ]); |
| 3185 | 3293 |
apps/web/src/components/KeybindingsToast.browser.tsx+1-1| ... | ... | @@ -323,7 +323,7 @@ describe("Keybindings update toast", () => { |
| 323 | 323 | useStore.setState({ |
| 324 | 324 | projects: [], |
| 325 | 325 | threads: [], |
| 326 | threadsHydrated: false, | |
| 326 | bootstrapComplete: false, | |
| 327 | 327 | }); |
| 328 | 328 | }); |
| 329 | 329 |
apps/web/src/components/Sidebar.logic.test.ts+47-3| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | getProjectSortTimestamp, |
| 10 | 10 | hasUnseenCompletion, |
| 11 | 11 | isContextMenuPointerDown, |
| 12 | orderItemsByPreferredIds, | |
| 12 | 13 | resolveProjectStatusIndicator, |
| 13 | 14 | resolveSidebarNewThreadEnvMode, |
| 14 | 15 | resolveThreadRowClassName, |
| ... | ... | @@ -18,7 +19,7 @@ import { |
| 18 | 19 | sortThreadsForSidebar, |
| 19 | 20 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 20 | 21 | } from "./Sidebar.logic"; |
| 21 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 22 | import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 22 | 23 | import { |
| 23 | 24 | DEFAULT_INTERACTION_MODE, |
| 24 | 25 | DEFAULT_RUNTIME_MODE, |
| ... | ... | @@ -29,7 +30,7 @@ import { |
| 29 | 30 | function makeLatestTurn(overrides?: { |
| 30 | 31 | completedAt?: string | null; |
| 31 | 32 | startedAt?: string | null; |
| 32 | }): Parameters<typeof hasUnseenCompletion>[0]["latestTurn"] { | |
| 33 | }): OrchestrationLatestTurn { | |
| 33 | 34 | return { |
| 34 | 35 | turnId: "turn-1" as never, |
| 35 | 36 | state: "completed", |
| ... | ... | @@ -163,6 +164,50 @@ describe("resolveSidebarNewThreadEnvMode", () => { |
| 163 | 164 | }); |
| 164 | 165 | }); |
| 165 | 166 | |
| 167 | describe("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 | ||
| 166 | 211 | describe("resolveAdjacentThreadId", () => { |
| 167 | 212 | it("resolves adjacent thread ids in ordered sidebar traversal", () => { |
| 168 | 213 | const threads = [ |
| ... | ... | @@ -552,7 +597,6 @@ function makeProject(overrides: Partial<Project> = {}): Project { |
| 552 | 597 | model: "gpt-5.4", |
| 553 | 598 | ...defaultModelSelection, |
| 554 | 599 | }, |
| 555 | expanded: true, | |
| 556 | 600 | createdAt: "2026-03-09T10:00:00.000Z", |
| 557 | 601 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 558 | 602 | scripts: [], |
apps/web/src/components/Sidebar.logic.ts+52-12| ... | ... | @@ -17,7 +17,10 @@ type SidebarProject = { |
| 17 | 17 | createdAt?: string | undefined; |
| 18 | 18 | updatedAt?: string | undefined; |
| 19 | 19 | }; |
| 20 | type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">; | |
| 20 | type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & { | |
| 21 | latestUserMessageAt?: string | null; | |
| 22 | messages?: Pick<Thread["messages"][number], "createdAt" | "role">[]; | |
| 23 | }; | |
| 21 | 24 | |
| 22 | 25 | export type ThreadTraversalDirection = "previous" | "next"; |
| 23 | 26 | |
| ... | ... | @@ -45,8 +48,10 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { |
| 45 | 48 | |
| 46 | 49 | type ThreadStatusInput = Pick< |
| 47 | 50 | Thread, |
| 48 | "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" | |
| 49 | >; | |
| 51 | "interactionMode" | "latestTurn" | "proposedPlans" | "session" | |
| 52 | > & { | |
| 53 | lastVisitedAt?: string | undefined; | |
| 54 | }; | |
| 50 | 55 | |
| 51 | 56 | export interface ThreadJumpHintVisibilityController { |
| 52 | 57 | sync: (shouldShow: boolean) => void; |
| ... | ... | @@ -156,6 +161,34 @@ export function resolveSidebarNewThreadEnvMode(input: { |
| 156 | 161 | return input.requestedEnvMode ?? input.defaultEnvMode; |
| 157 | 162 | } |
| 158 | 163 | |
| 164 | export 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 | ||
| 159 | 192 | export function getVisibleSidebarThreadIds<TThreadId>( |
| 160 | 193 | renderedProjects: readonly { |
| 161 | 194 | shouldShowThreadPanel?: boolean; |
| ... | ... | @@ -327,15 +360,15 @@ export function resolveProjectStatusIndicator( |
| 327 | 360 | return highestPriorityStatus; |
| 328 | 361 | } |
| 329 | 362 | |
| 330 | export function getVisibleThreadsForProject(input: { | |
| 331 | threads: readonly Thread[]; | |
| 332 | activeThreadId: Thread["id"] | undefined; | |
| 363 | export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input: { | |
| 364 | threads: readonly T[]; | |
| 365 | activeThreadId: T["id"] | undefined; | |
| 333 | 366 | isThreadListExpanded: boolean; |
| 334 | 367 | previewLimit: number; |
| 335 | 368 | }): { |
| 336 | 369 | hasHiddenThreads: boolean; |
| 337 | hiddenThreads: Thread[]; | |
| 338 | visibleThreads: Thread[]; | |
| 370 | visibleThreads: T[]; | |
| 371 | hiddenThreads: T[]; | |
| 339 | 372 | } { |
| 340 | 373 | const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; |
| 341 | 374 | const hasHiddenThreads = threads.length > previewLimit; |
| ... | ... | @@ -382,9 +415,13 @@ function toSortableTimestamp(iso: string | undefined): number | null { |
| 382 | 415 | } |
| 383 | 416 | |
| 384 | 417 | function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { |
| 418 | if (thread.latestUserMessageAt) { | |
| 419 | return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; | |
| 420 | } | |
| 421 | ||
| 385 | 422 | let latestUserMessageTimestamp: number | null = null; |
| 386 | 423 | |
| 387 | for (const message of thread.messages) { | |
| 424 | for (const message of thread.messages ?? []) { | |
| 388 | 425 | if (message.role !== "user") continue; |
| 389 | 426 | const messageTimestamp = toSortableTimestamp(message.createdAt); |
| 390 | 427 | if (messageTimestamp === null) continue; |
| ... | ... | @@ -412,7 +449,7 @@ function getThreadSortTimestamp( |
| 412 | 449 | } |
| 413 | 450 | |
| 414 | 451 | export function sortThreadsForSidebar< |
| 415 | T extends Pick<Thread, "id" | "createdAt" | "updatedAt" | "messages">, | |
| 452 | T extends Pick<Thread, "id" | "createdAt" | "updatedAt"> & SidebarThreadSortInput, | |
| 416 | 453 | >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { |
| 417 | 454 | return threads.toSorted((left, right) => { |
| 418 | 455 | const rightTimestamp = getThreadSortTimestamp(right, sortOrder); |
| ... | ... | @@ -425,7 +462,7 @@ export function sortThreadsForSidebar< |
| 425 | 462 | } |
| 426 | 463 | |
| 427 | 464 | export function getFallbackThreadIdAfterDelete< |
| 428 | T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt" | "messages">, | |
| 465 | T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput, | |
| 429 | 466 | >(input: { |
| 430 | 467 | threads: readonly T[]; |
| 431 | 468 | deletedThreadId: T["id"]; |
| ... | ... | @@ -469,7 +506,10 @@ export function getProjectSortTimestamp( |
| 469 | 506 | return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; |
| 470 | 507 | } |
| 471 | 508 | |
| 472 | export function sortProjectsForSidebar<TProject extends SidebarProject, TThread extends Thread>( | |
| 509 | export function sortProjectsForSidebar< | |
| 510 | TProject extends SidebarProject, | |
| 511 | TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput, | |
| 512 | >( | |
| 473 | 513 | projects: readonly TProject[], |
| 474 | 514 | threads: readonly TThread[], |
| 475 | 515 | sortOrder: SidebarProjectSortOrder, |
apps/web/src/components/Sidebar.tsx+121-11| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | type MouseEvent, |
| 22 | 22 | type PointerEvent, |
| 23 | 23 | } from "react"; |
| 24 | import { useShallow } from "zustand/react/shallow"; | |
| 24 | 25 | import { |
| 25 | 26 | DndContext, |
| 26 | 27 | type DragCancelEvent, |
| ... | ... | @@ -55,6 +56,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 55 | 56 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 56 | 57 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 57 | 58 | import { useStore } from "../store"; |
| 59 | import { useUiStateStore } from "../uiStateStore"; | |
| 58 | 60 | import { |
| 59 | 61 | resolveShortcutCommand, |
| 60 | 62 | shortcutLabelForCommand, |
| ... | ... | @@ -114,6 +116,7 @@ import { |
| 114 | 116 | resolveSidebarNewThreadEnvMode, |
| 115 | 117 | resolveThreadRowClassName, |
| 116 | 118 | resolveThreadStatusPill, |
| 119 | orderItemsByPreferredIds, | |
| 117 | 120 | shouldClearThreadSelectionOnMouseDown, |
| 118 | 121 | sortProjectsForSidebar, |
| 119 | 122 | sortThreadsForSidebar, |
| ... | ... | @@ -122,6 +125,7 @@ import { |
| 122 | 125 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 123 | 126 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 124 | 127 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 128 | import type { Project, Thread } from "../types"; | |
| 125 | 129 | |
| 126 | 130 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 127 | 131 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | ... | @@ -138,6 +142,80 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { |
| 138 | 142 | duration: 180, |
| 139 | 143 | easing: "ease-out", |
| 140 | 144 | } as const; |
| 145 | ||
| 146 | type 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 | ||
| 166 | type SidebarProjectSnapshot = Project & { | |
| 167 | expanded: boolean; | |
| 168 | }; | |
| 169 | ||
| 170 | const sidebarThreadSnapshotCache = new WeakMap< | |
| 171 | Thread, | |
| 172 | { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot } | |
| 173 | >(); | |
| 174 | ||
| 175 | function 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 | ||
| 190 | function 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 | } | |
| 141 | 219 | interface TerminalStatusIndicator { |
| 142 | 220 | label: "Terminal process running"; |
| 143 | 221 | colorClass: string; |
| ... | ... | @@ -361,10 +439,17 @@ function SortableProjectItem({ |
| 361 | 439 | |
| 362 | 440 | export default function Sidebar() { |
| 363 | 441 | const projects = useStore((store) => store.projects); |
| 364 | const threads = useStore((store) => store.threads); | |
| 365 | const markThreadUnread = useStore((store) => store.markThreadUnread); | |
| 366 | const toggleProject = useStore((store) => store.toggleProject); | |
| 367 | const reorderProjects = useStore((store) => store.reorderProjects); | |
| 442 | const serverThreads = useStore((store) => store.threads); | |
| 443 | const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( | |
| 444 | useShallow((store) => ({ | |
| 445 | projectExpandedById: store.projectExpandedById, | |
| 446 | projectOrder: store.projectOrder, | |
| 447 | threadLastVisitedAtById: store.threadLastVisitedAtById, | |
| 448 | })), | |
| 449 | ); | |
| 450 | const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); | |
| 451 | const toggleProject = useUiStateStore((store) => store.toggleProject); | |
| 452 | const reorderProjects = useUiStateStore((store) => store.reorderProjects); | |
| 368 | 453 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 369 | 454 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 370 | 455 | (store) => store.getDraftThreadByProjectId, |
| ... | ... | @@ -418,6 +503,28 @@ export default function Sidebar() { |
| 418 | 503 | const platform = navigator.platform; |
| 419 | 504 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; |
| 420 | 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 | 528 | const projectCwdById = useMemo( |
| 422 | 529 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 423 | 530 | [projects], |
| ... | ... | @@ -752,7 +859,7 @@ export default function Sidebar() { |
| 752 | 859 | } |
| 753 | 860 | |
| 754 | 861 | if (clicked === "mark-unread") { |
| 755 | markThreadUnread(threadId); | |
| 862 | markThreadUnread(threadId, thread.latestTurn?.completedAt); | |
| 756 | 863 | return; |
| 757 | 864 | } |
| 758 | 865 | if (clicked === "copy-path") { |
| ... | ... | @@ -814,7 +921,8 @@ export default function Sidebar() { |
| 814 | 921 | |
| 815 | 922 | if (clicked === "mark-unread") { |
| 816 | 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 | 927 | clearSelection(); |
| 820 | 928 | return; |
| ... | ... | @@ -845,6 +953,7 @@ export default function Sidebar() { |
| 845 | 953 | markThreadUnread, |
| 846 | 954 | removeFromSelection, |
| 847 | 955 | selectedThreadIds, |
| 956 | threads, | |
| 848 | 957 | ], |
| 849 | 958 | ); |
| 850 | 959 | |
| ... | ... | @@ -987,12 +1096,12 @@ export default function Sidebar() { |
| 987 | 1096 | dragInProgressRef.current = false; |
| 988 | 1097 | const { active, over } = event; |
| 989 | 1098 | if (!over || active.id === over.id) return; |
| 990 | const activeProject = projects.find((project) => project.id === active.id); | |
| 991 | const overProject = projects.find((project) => project.id === over.id); | |
| 1099 | const activeProject = sidebarProjects.find((project) => project.id === active.id); | |
| 1100 | const overProject = sidebarProjects.find((project) => project.id === over.id); | |
| 992 | 1101 | if (!activeProject || !overProject) return; |
| 993 | 1102 | reorderProjects(activeProject.id, overProject.id); |
| 994 | 1103 | }, |
| 995 | [appSettings.sidebarProjectSortOrder, projects, reorderProjects], | |
| 1104 | [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], | |
| 996 | 1105 | ); |
| 997 | 1106 | |
| 998 | 1107 | const handleProjectDragStart = useCallback( |
| ... | ... | @@ -1052,8 +1161,9 @@ export default function Sidebar() { |
| 1052 | 1161 | [threads], |
| 1053 | 1162 | ); |
| 1054 | 1163 | const sortedProjects = useMemo( |
| 1055 | () => sortProjectsForSidebar(projects, visibleThreads, appSettings.sidebarProjectSortOrder), | |
| 1056 | [appSettings.sidebarProjectSortOrder, projects, visibleThreads], | |
| 1164 | () => | |
| 1165 | sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), | |
| 1166 | [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], | |
| 1057 | 1167 | ); |
| 1058 | 1168 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; |
| 1059 | 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 | 9 | |
| 10 | 10 | import { |
| 11 | 11 | COMPOSER_DRAFT_STORAGE_KEY, |
| 12 | clearPromotedDraftThread, | |
| 13 | clearPromotedDraftThreads, | |
| 12 | 14 | type ComposerImageAttachment, |
| 13 | 15 | useComposerDraftStore, |
| 14 | 16 | } from "./composerDraftStore"; |
| ... | ... | @@ -549,6 +551,57 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 549 | 551 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); |
| 550 | 552 | }); |
| 551 | 553 | |
| 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 | 605 | it("updates branch context on an existing draft thread", () => { |
| 553 | 606 | const store = useComposerDraftStore.getState(); |
| 554 | 607 | store.setProjectDraftThreadId(projectId, threadId, { |
apps/web/src/composerDraftStore.ts+14-11| ... | ... | @@ -2195,18 +2195,21 @@ export function useEffectiveComposerModelState(input: { |
| 2195 | 2195 | } |
| 2196 | 2196 | |
| 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` | |
| 2201 | * sees the server thread before the draft is removed — avoids a redirect | |
| 2202 | * to `/` caused by a gap where neither draft nor server thread exists. | |
| 2200 | * Use the single-thread helper for live `thread.created` events and the | |
| 2201 | * iterable helper for bootstrap/recovery paths that discover multiple server | |
| 2202 | * threads at once. | |
| 2203 | 2203 | */ |
| 2204 | export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet<ThreadId>): void { | |
| 2205 | const store = useComposerDraftStore.getState(); | |
| 2206 | const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[]; | |
| 2207 | for (const draftId of draftThreadIds) { | |
| 2208 | if (serverThreadIds.has(draftId)) { | |
| 2209 | store.clearDraftThread(draftId); | |
| 2210 | } | |
| 2204 | export function clearPromotedDraftThread(threadId: ThreadId): void { | |
| 2205 | if (!useComposerDraftStore.getState().getDraftThread(threadId)) { | |
| 2206 | return; | |
| 2207 | } | |
| 2208 | useComposerDraftStore.getState().clearDraftThread(threadId); | |
| 2209 | } | |
| 2210 | ||
| 2211 | export 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 | 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import { useCallback } from "react"; | |
| 3 | import { useCallback, useMemo } from "react"; | |
| 4 | import { useShallow } from "zustand/react/shallow"; | |
| 4 | 5 | import { |
| 5 | 6 | type DraftThreadEnvMode, |
| 6 | 7 | type DraftThreadState, |
| 7 | 8 | useComposerDraftStore, |
| 8 | 9 | } from "../composerDraftStore"; |
| 9 | 10 | import { newThreadId } from "../lib/utils"; |
| 11 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; | |
| 10 | 12 | import { useStore } from "../store"; |
| 13 | import { useThreadById } from "../storeSelectors"; | |
| 14 | import { useUiStateStore } from "../uiStateStore"; | |
| 11 | 15 | |
| 12 | 16 | export function useHandleNewThread() { |
| 13 | const projects = useStore((store) => store.projects); | |
| 14 | const threads = useStore((store) => store.threads); | |
| 17 | const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id))); | |
| 18 | const projectOrder = useUiStateStore((store) => store.projectOrder); | |
| 15 | 19 | const navigate = useNavigate(); |
| 16 | 20 | const routeThreadId = useParams({ |
| 17 | 21 | strict: false, |
| 18 | 22 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| 19 | 23 | }); |
| 24 | const activeThread = useThreadById(routeThreadId); | |
| 20 | 25 | const activeDraftThread = useComposerDraftStore((store) => |
| 21 | 26 | routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, |
| 22 | 27 | ); |
| 23 | ||
| 24 | const activeThread = routeThreadId | |
| 25 | ? threads.find((thread) => thread.id === routeThreadId) | |
| 26 | : undefined; | |
| 28 | const orderedProjects = useMemo(() => { | |
| 29 | return orderItemsByPreferredIds({ | |
| 30 | items: projectIds, | |
| 31 | preferredIds: projectOrder, | |
| 32 | getId: (projectId) => projectId, | |
| 33 | }); | |
| 34 | }, [projectIds, projectOrder]); | |
| 27 | 35 | |
| 28 | 36 | const handleNewThread = useCallback( |
| 29 | 37 | ( |
| ... | ... | @@ -111,8 +119,8 @@ export function useHandleNewThread() { |
| 111 | 119 | return { |
| 112 | 120 | activeDraftThread, |
| 113 | 121 | activeThread, |
| 122 | defaultProjectId: orderedProjects[0] ?? null, | |
| 114 | 123 | handleNewThread, |
| 115 | projects, | |
| 116 | 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 | 16 | import { useSettings } from "./useSettings"; |
| 17 | 17 | |
| 18 | 18 | export function useThreadActions() { |
| 19 | const threads = useStore((store) => store.threads); | |
| 20 | const projects = useStore((store) => store.projects); | |
| 21 | 19 | const appSettings = useSettings(); |
| 22 | 20 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 23 | 21 | const clearProjectDraftThreadById = useComposerDraftStore( |
| ... | ... | @@ -37,7 +35,7 @@ export function useThreadActions() { |
| 37 | 35 | async (threadId: ThreadId) => { |
| 38 | 36 | const api = readNativeApi(); |
| 39 | 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 | 39 | if (!thread) return; |
| 42 | 40 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { |
| 43 | 41 | throw new Error("Cannot archive a running thread."); |
| ... | ... | @@ -53,7 +51,7 @@ export function useThreadActions() { |
| 53 | 51 | await handleNewThread(thread.projectId); |
| 54 | 52 | } |
| 55 | 53 | }, |
| 56 | [handleNewThread, routeThreadId, threads], | |
| 54 | [handleNewThread, routeThreadId], | |
| 57 | 55 | ); |
| 58 | 56 | |
| 59 | 57 | const unarchiveThread = useCallback(async (threadId: ThreadId) => { |
| ... | ... | @@ -70,6 +68,7 @@ export function useThreadActions() { |
| 70 | 68 | async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => { |
| 71 | 69 | const api = readNativeApi(); |
| 72 | 70 | if (!api) return; |
| 71 | const { projects, threads } = useStore.getState(); | |
| 73 | 72 | const thread = threads.find((entry) => entry.id === threadId); |
| 74 | 73 | if (!thread) return; |
| 75 | 74 | const threadProject = projects.find((project) => project.id === thread.projectId); |
| ... | ... | @@ -171,10 +170,8 @@ export function useThreadActions() { |
| 171 | 170 | clearTerminalState, |
| 172 | 171 | appSettings.sidebarThreadSortOrder, |
| 173 | 172 | navigate, |
| 174 | projects, | |
| 175 | 173 | removeWorktreeMutation, |
| 176 | 174 | routeThreadId, |
| 177 | threads, | |
| 178 | 175 | ], |
| 179 | 176 | ); |
| 180 | 177 | |
| ... | ... | @@ -182,7 +179,7 @@ export function useThreadActions() { |
| 182 | 179 | async (threadId: ThreadId) => { |
| 183 | 180 | const api = readNativeApi(); |
| 184 | 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 | 183 | if (!thread) return; |
| 187 | 184 | |
| 188 | 185 | if (appSettings.confirmThreadDelete) { |
| ... | ... | @@ -199,7 +196,7 @@ export function useThreadActions() { |
| 199 | 196 | |
| 200 | 197 | await deleteThread(threadId); |
| 201 | 198 | }, |
| 202 | [appSettings.confirmThreadDelete, deleteThread, threads], | |
| 199 | [appSettings.confirmThreadDelete, deleteThread], | |
| 203 | 200 | ); |
| 204 | 201 | |
| 205 | 202 | return { |
apps/web/src/orchestrationEventEffects.test.ts created+108| ... | ... | @@ -0,0 +1,108 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | type OrchestrationEvent, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { describe, expect, it } from "vitest"; | |
| 11 | ||
| 12 | import { deriveOrchestrationBatchEffects } from "./orchestrationEventEffects"; | |
| 13 | ||
| 14 | function 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 | ||
| 41 | describe("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 @@ |
| 1 | import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface OrchestrationBatchEffects { | |
| 4 | clearPromotedDraftThreadIds: ThreadId[]; | |
| 5 | clearDeletedThreadIds: ThreadId[]; | |
| 6 | removeTerminalStateThreadIds: ThreadId[]; | |
| 7 | needsProviderInvalidation: boolean; | |
| 8 | } | |
| 9 | ||
| 10 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { createOrchestrationRecoveryCoordinator } from "./orchestrationRecovery"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | export type OrchestrationRecoveryReason = "bootstrap" | "sequence-gap" | "replay-failed"; | |
| 2 | ||
| 3 | export interface OrchestrationRecoveryPhase { | |
| 4 | kind: "snapshot" | "replay"; | |
| 5 | reason: OrchestrationRecoveryReason; | |
| 6 | } | |
| 7 | ||
| 8 | export interface OrchestrationRecoveryState { | |
| 9 | latestSequence: number; | |
| 10 | highestObservedSequence: number; | |
| 11 | bootstrapped: boolean; | |
| 12 | pendingReplay: boolean; | |
| 13 | inFlight: OrchestrationRecoveryPhase | null; | |
| 14 | } | |
| 15 | ||
| 16 | type SequencedEvent = Readonly<{ sequence: number }>; | |
| 17 | ||
| 18 | export 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 | 1 | import { createElement } from "react"; |
| 2 | 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 3 | import { createRouter } from "@tanstack/react-router"; | |
| 3 | import { createRouter, RouterHistory } from "@tanstack/react-router"; | |
| 4 | 4 | |
| 5 | 5 | import { routeTree } from "./routeTree.gen"; |
| 6 | import { StoreProvider } from "./store"; | |
| 7 | ||
| 8 | type RouterHistory = NonNullable<Parameters<typeof createRouter>[0]["history"]>; | |
| 9 | 6 | |
| 10 | 7 | export function getRouter(history: RouterHistory) { |
| 11 | 8 | const queryClient = new QueryClient(); |
| ... | ... | @@ -16,12 +13,7 @@ export function getRouter(history: RouterHistory) { |
| 16 | 13 | context: { |
| 17 | 14 | queryClient, |
| 18 | 15 | }, |
| 19 | Wrap: ({ children }) => | |
| 20 | createElement( | |
| 21 | QueryClientProvider, | |
| 22 | { client: queryClient }, | |
| 23 | createElement(StoreProvider, null, children), | |
| 24 | ), | |
| 16 | Wrap: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children), | |
| 25 | 17 | }); |
| 26 | 18 | } |
| 27 | 19 |
apps/web/src/routes/__root.tsx+146-47| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | import { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | Outlet, |
| 4 | 4 | createRootRouteWithContext, |
| ... | ... | @@ -17,8 +17,13 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component |
| 17 | 17 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 18 | 18 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; |
| 19 | 19 | import { readNativeApi } from "../nativeApi"; |
| 20 | import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; | |
| 20 | import { | |
| 21 | clearPromotedDraftThread, | |
| 22 | clearPromotedDraftThreads, | |
| 23 | useComposerDraftStore, | |
| 24 | } from "../composerDraftStore"; | |
| 21 | 25 | import { useStore } from "../store"; |
| 26 | import { useUiStateStore } from "../uiStateStore"; | |
| 22 | 27 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 23 | 28 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; |
| 24 | 29 | import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; |
| ... | ... | @@ -26,6 +31,8 @@ import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| 26 | 31 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| 27 | 32 | import { projectQueryKeys } from "../lib/projectReactQuery"; |
| 28 | 33 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; |
| 34 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; | |
| 35 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; | |
| 29 | 36 | |
| 30 | 37 | export const Route = createRootRouteWithContext<{ |
| 31 | 38 | queryClient: QueryClient; |
| ... | ... | @@ -135,8 +142,13 @@ function errorDetails(error: unknown): string { |
| 135 | 142 | } |
| 136 | 143 | |
| 137 | 144 | function EventRouter() { |
| 145 | const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); | |
| 138 | 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 | 152 | const removeOrphanedTerminalStates = useTerminalStateStore( |
| 141 | 153 | (store) => store.removeOrphanedTerminalStates, |
| 142 | 154 | ); |
| ... | ... | @@ -152,56 +164,40 @@ function EventRouter() { |
| 152 | 164 | const api = readNativeApi(); |
| 153 | 165 | if (!api) return; |
| 154 | 166 | let disposed = false; |
| 155 | let latestSequence = 0; | |
| 156 | let syncing = false; | |
| 157 | let pending = false; | |
| 167 | const recovery = createOrchestrationRecoveryCoordinator(); | |
| 158 | 168 | let needsProviderInvalidation = false; |
| 159 | 169 | |
| 160 | const flushSnapshotSync = async (): Promise<void> => { | |
| 161 | const snapshot = await api.orchestration.getSnapshot(); | |
| 162 | if (disposed) return; | |
| 163 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); | |
| 164 | syncServerReadModel(snapshot); | |
| 165 | clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); | |
| 170 | const reconcileSnapshotDerivedState = () => { | |
| 171 | const threads = useStore.getState().threads; | |
| 172 | const projects = useStore.getState().projects; | |
| 173 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | |
| 174 | syncThreads( | |
| 175 | threads.map((thread) => ({ | |
| 176 | id: thread.id, | |
| 177 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | |
| 178 | })), | |
| 179 | ); | |
| 180 | clearPromotedDraftThreads(threads.map((thread) => thread.id)); | |
| 166 | 181 | const draftThreadIds = Object.keys( |
| 167 | 182 | useComposerDraftStore.getState().draftThreadsByThreadId, |
| 168 | 183 | ) as ThreadId[]; |
| 169 | 184 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 170 | snapshotThreads: snapshot.threads, | |
| 185 | snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })), | |
| 171 | 186 | draftThreadIds, |
| 172 | 187 | }); |
| 173 | 188 | removeOrphanedTerminalStates(activeThreadIds); |
| 174 | if (pending) { | |
| 175 | pending = false; | |
| 176 | await flushSnapshotSync(); | |
| 177 | } | |
| 178 | 189 | }; |
| 179 | 190 | |
| 180 | const syncSnapshot = async () => { | |
| 181 | if (syncing) { | |
| 182 | pending = true; | |
| 183 | return; | |
| 184 | } | |
| 185 | syncing = true; | |
| 186 | pending = false; | |
| 187 | try { | |
| 188 | await flushSnapshotSync(); | |
| 189 | } catch { | |
| 190 | // Keep prior state and wait for next domain event to trigger a resync. | |
| 191 | } | |
| 192 | syncing = false; | |
| 193 | }; | |
| 194 | ||
| 195 | const domainEventFlushThrottler = new Throttler( | |
| 191 | const queryInvalidationThrottler = new Throttler( | |
| 196 | 192 | () => { |
| 197 | if (needsProviderInvalidation) { | |
| 198 | needsProviderInvalidation = false; | |
| 199 | void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); | |
| 200 | // Invalidate workspace entry queries so the @-mention file picker | |
| 201 | // reflects files created, deleted, or restored during this turn. | |
| 202 | void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); | |
| 193 | if (!needsProviderInvalidation) { | |
| 194 | return; | |
| 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 | 203 | wait: 100, |
| ... | ... | @@ -210,15 +206,113 @@ function EventRouter() { |
| 210 | 206 | }, |
| 211 | 207 | ); |
| 212 | 208 | |
| 213 | const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { | |
| 214 | if (event.sequence <= latestSequence) { | |
| 209 | const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => { | |
| 210 | const nextEvents = recovery.markEventBatchApplied(events); | |
| 211 | if (nextEvents.length === 0) { | |
| 215 | 212 | return; |
| 216 | 213 | } |
| 217 | latestSequence = event.sequence; | |
| 218 | if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { | |
| 214 | ||
| 215 | const batchEffects = deriveOrchestrationBatchEffects(nextEvents); | |
| 216 | const needsProjectUiSync = nextEvents.some( | |
| 217 | (event) => | |
| 218 | event.type === "project.created" || | |
| 219 | event.type === "project.meta-updated" || | |
| 220 | event.type === "project.deleted", | |
| 221 | ); | |
| 222 | ||
| 223 | if (batchEffects.needsProviderInvalidation) { | |
| 219 | 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 | 317 | const unsubTerminalEvent = api.terminal.onEvent((event) => { |
| 224 | 318 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); |
| ... | ... | @@ -237,7 +331,7 @@ function EventRouter() { |
| 237 | 331 | // Migrate old localStorage settings to server on first connect |
| 238 | 332 | migrateLocalSettingsToServer(); |
| 239 | 333 | void (async () => { |
| 240 | await syncSnapshot(); | |
| 334 | await bootstrapFromSnapshot(); | |
| 241 | 335 | if (disposed) { |
| 242 | 336 | return; |
| 243 | 337 | } |
| ... | ... | @@ -319,7 +413,7 @@ function EventRouter() { |
| 319 | 413 | return () => { |
| 320 | 414 | disposed = true; |
| 321 | 415 | needsProviderInvalidation = false; |
| 322 | domainEventFlushThrottler.cancel(); | |
| 416 | queryInvalidationThrottler.cancel(); | |
| 323 | 417 | unsubDomainEvent(); |
| 324 | 418 | unsubTerminalEvent(); |
| 325 | 419 | unsubWelcome(); |
| ... | ... | @@ -327,11 +421,16 @@ function EventRouter() { |
| 327 | 421 | unsubProvidersUpdated(); |
| 328 | 422 | }; |
| 329 | 423 | }, [ |
| 424 | applyOrchestrationEvents, | |
| 330 | 425 | navigate, |
| 331 | 426 | queryClient, |
| 427 | removeTerminalState, | |
| 332 | 428 | removeOrphanedTerminalStates, |
| 429 | clearThreadUi, | |
| 333 | 430 | setProjectExpanded, |
| 431 | syncProjects, | |
| 334 | 432 | syncServerReadModel, |
| 433 | syncThreads, | |
| 335 | 434 | ]); |
| 336 | 435 | |
| 337 | 436 | return null; |
apps/web/src/routes/_chat.$threadId.tsx+4-4| ... | ... | @@ -161,7 +161,7 @@ const DiffPanelInlineSidebar = (props: { |
| 161 | 161 | }; |
| 162 | 162 | |
| 163 | 163 | function ChatThreadRouteView() { |
| 164 | const threadsHydrated = useStore((store) => store.threadsHydrated); | |
| 164 | const bootstrapComplete = useStore((store) => store.bootstrapComplete); | |
| 165 | 165 | const navigate = useNavigate(); |
| 166 | 166 | const threadId = Route.useParams({ |
| 167 | 167 | select: (params) => ThreadId.makeUnsafe(params.threadId), |
| ... | ... | @@ -202,7 +202,7 @@ function ChatThreadRouteView() { |
| 202 | 202 | }, [diffOpen]); |
| 203 | 203 | |
| 204 | 204 | useEffect(() => { |
| 205 | if (!threadsHydrated) { | |
| 205 | if (!bootstrapComplete) { | |
| 206 | 206 | return; |
| 207 | 207 | } |
| 208 | 208 | |
| ... | ... | @@ -210,9 +210,9 @@ function ChatThreadRouteView() { |
| 210 | 210 | void navigate({ to: "/", replace: true }); |
| 211 | 211 | return; |
| 212 | 212 | } |
| 213 | }, [navigate, routeThreadExists, threadsHydrated, threadId]); | |
| 213 | }, [bootstrapComplete, navigate, routeThreadExists, threadId]); | |
| 214 | 214 | |
| 215 | if (!threadsHydrated || !routeThreadExists) { | |
| 215 | if (!bootstrapComplete || !routeThreadExists) { | |
| 216 | 216 | return null; |
| 217 | 217 | } |
| 218 | 218 |
apps/web/src/routes/_chat.tsx+14-11| ... | ... | @@ -17,7 +17,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 17 | 17 | function ChatRouteGlobalShortcuts() { |
| 18 | 18 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 19 | 19 | const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); |
| 20 | const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = | |
| 20 | const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = | |
| 21 | 21 | useHandleNewThread(); |
| 22 | 22 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 23 | 23 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; |
| ... | ... | @@ -38,7 +38,7 @@ function ChatRouteGlobalShortcuts() { |
| 38 | 38 | return; |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; | |
| 41 | const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; | |
| 42 | 42 | if (!projectId) return; |
| 43 | 43 | |
| 44 | 44 | const command = resolveShortcutCommand(event, keybindings, { |
| ... | ... | @@ -59,14 +59,17 @@ function ChatRouteGlobalShortcuts() { |
| 59 | 59 | return; |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | if (command !== "chat.new") return; | |
| 63 | event.preventDefault(); | |
| 64 | event.stopPropagation(); | |
| 65 | void handleNewThread(projectId, { | |
| 66 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, | |
| 67 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, | |
| 68 | envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), | |
| 69 | }); | |
| 62 | if (command === "chat.new") { | |
| 63 | event.preventDefault(); | |
| 64 | event.stopPropagation(); | |
| 65 | void handleNewThread(projectId, { | |
| 66 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, | |
| 67 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, | |
| 68 | envMode: | |
| 69 | activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), | |
| 70 | }); | |
| 71 | return; | |
| 72 | } | |
| 70 | 73 | }; |
| 71 | 74 | |
| 72 | 75 | window.addEventListener("keydown", onWindowKeyDown); |
| ... | ... | @@ -79,7 +82,7 @@ function ChatRouteGlobalShortcuts() { |
| 79 | 82 | clearSelection, |
| 80 | 83 | handleNewThread, |
| 81 | 84 | keybindings, |
| 82 | projects, | |
| 85 | defaultProjectId, | |
| 83 | 86 | selectedThreadIdsSize, |
| 84 | 87 | terminalOpen, |
| 85 | 88 | appSettings.defaultThreadEnvMode, |
apps/web/src/session-logic.test.ts+32| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | import { describe, expect, it } from "vitest"; |
| 9 | 9 | |
| 10 | 10 | import { |
| 11 | deriveCompletionDividerBeforeEntryId, | |
| 11 | 12 | deriveActiveWorkStartedAt, |
| 12 | 13 | deriveActivePlanState, |
| 13 | 14 | PROVIDER_OPTIONS, |
| ... | ... | @@ -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 | }); |
| 968 | 1000 | |
| 969 | 1001 | describe("deriveWorkLogEntries context window handling", () => { |
apps/web/src/session-logic.ts+47| ... | ... | @@ -852,6 +852,53 @@ export function deriveTimelineEntries( |
| 852 | 852 | ); |
| 853 | 853 | } |
| 854 | 854 | |
| 855 | export 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 | ||
| 855 | 902 | export function inferCheckpointTurnCountByTurnId( |
| 856 | 903 | summaries: TurnDiffSummary[], |
| 857 | 904 | ): Record<TurnId, number> { |
apps/web/src/store.test.ts+488-94| ... | ... | @@ -1,13 +1,22 @@ |
| 1 | 1 | import { |
| 2 | CheckpointRef, | |
| 2 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | EventId, | |
| 5 | MessageId, | |
| 3 | 6 | ProjectId, |
| 4 | 7 | ThreadId, |
| 5 | 8 | TurnId, |
| 9 | type OrchestrationEvent, | |
| 6 | 10 | type OrchestrationReadModel, |
| 7 | 11 | } from "@t3tools/contracts"; |
| 8 | 12 | import { describe, expect, it } from "vitest"; |
| 9 | 13 | |
| 10 | import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store"; | |
| 14 | import { | |
| 15 | applyOrchestrationEvent, | |
| 16 | applyOrchestrationEvents, | |
| 17 | syncServerReadModel, | |
| 18 | type AppState, | |
| 19 | } from "./store"; | |
| 11 | 20 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 12 | 21 | |
| 13 | 22 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| ... | ... | @@ -48,15 +57,41 @@ function makeState(thread: Thread): AppState { |
| 48 | 57 | provider: "codex", |
| 49 | 58 | model: "gpt-5-codex", |
| 50 | 59 | }, |
| 51 | expanded: true, | |
| 52 | 60 | scripts: [], |
| 53 | 61 | }, |
| 54 | 62 | ], |
| 55 | 63 | threads: [thread], |
| 56 | threadsHydrated: true, | |
| 64 | bootstrapComplete: true, | |
| 57 | 65 | }; |
| 58 | 66 | } |
| 59 | 67 | |
| 68 | function 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 | ||
| 60 | 95 | function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) { |
| 61 | 96 | return { |
| 62 | 97 | id: ThreadId.makeUnsafe("thread-1"), |
| ... | ... | @@ -126,97 +161,18 @@ function makeReadModelProject( |
| 126 | 161 | }; |
| 127 | 162 | } |
| 128 | 163 | |
| 129 | describe("store pure functions", () => { | |
| 130 | it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { | |
| 131 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; | |
| 132 | const initialState = makeState( | |
| 133 | makeThread({ | |
| 134 | latestTurn: { | |
| 135 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 136 | state: "completed", | |
| 137 | requestedAt: "2026-02-25T12:28:00.000Z", | |
| 138 | startedAt: "2026-02-25T12:28:30.000Z", | |
| 139 | completedAt: latestTurnCompletedAt, | |
| 140 | assistantMessageId: null, | |
| 141 | }, | |
| 142 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | |
| 143 | }), | |
| 144 | ); | |
| 145 | ||
| 146 | const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); | |
| 147 | ||
| 148 | const updatedThread = next.threads[0]; | |
| 149 | expect(updatedThread).toBeDefined(); | |
| 150 | expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z"); | |
| 151 | expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan( | |
| 152 | Date.parse(latestTurnCompletedAt), | |
| 153 | ); | |
| 154 | }); | |
| 155 | ||
| 156 | it("markThreadUnread does not change a thread without a completed turn", () => { | |
| 157 | const initialState = makeState( | |
| 158 | makeThread({ | |
| 159 | latestTurn: null, | |
| 160 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | |
| 161 | }), | |
| 162 | ); | |
| 163 | ||
| 164 | const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1")); | |
| 165 | ||
| 166 | expect(next).toEqual(initialState); | |
| 167 | }); | |
| 168 | ||
| 169 | it("reorderProjects moves a project to a target index", () => { | |
| 170 | const project1 = ProjectId.makeUnsafe("project-1"); | |
| 171 | const project2 = ProjectId.makeUnsafe("project-2"); | |
| 172 | const project3 = ProjectId.makeUnsafe("project-3"); | |
| 173 | const state: AppState = { | |
| 174 | projects: [ | |
| 175 | { | |
| 176 | id: project1, | |
| 177 | name: "Project 1", | |
| 178 | cwd: "/tmp/project-1", | |
| 179 | defaultModelSelection: { | |
| 180 | provider: "codex", | |
| 181 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 182 | }, | |
| 183 | expanded: true, | |
| 184 | scripts: [], | |
| 185 | }, | |
| 186 | { | |
| 187 | id: project2, | |
| 188 | name: "Project 2", | |
| 189 | cwd: "/tmp/project-2", | |
| 190 | defaultModelSelection: { | |
| 191 | provider: "codex", | |
| 192 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 193 | }, | |
| 194 | expanded: true, | |
| 195 | scripts: [], | |
| 196 | }, | |
| 197 | { | |
| 198 | id: project3, | |
| 199 | name: "Project 3", | |
| 200 | cwd: "/tmp/project-3", | |
| 201 | defaultModelSelection: { | |
| 202 | provider: "codex", | |
| 203 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 204 | }, | |
| 205 | expanded: true, | |
| 206 | scripts: [], | |
| 207 | }, | |
| 208 | ], | |
| 209 | threads: [], | |
| 210 | threadsHydrated: true, | |
| 164 | describe("store read model sync", () => { | |
| 165 | it("marks bootstrap complete after snapshot sync", () => { | |
| 166 | const initialState: AppState = { | |
| 167 | ...makeState(makeThread()), | |
| 168 | bootstrapComplete: false, | |
| 211 | 169 | }; |
| 212 | 170 | |
| 213 | const next = reorderProjects(state, project1, project3); | |
| 171 | const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); | |
| 214 | 172 | |
| 215 | expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]); | |
| 173 | expect(next.bootstrapComplete).toBe(true); | |
| 216 | 174 | }); |
| 217 | }); | |
| 218 | 175 | |
| 219 | describe("store read model sync", () => { | |
| 220 | 176 | it("preserves claude model slugs without an active session", () => { |
| 221 | 177 | const initialState = makeState(makeThread()); |
| 222 | 178 | const readModel = makeReadModel( |
| ... | ... | @@ -287,7 +243,7 @@ describe("store read model sync", () => { |
| 287 | 243 | expect(next.threads[0]?.archivedAt).toBe(archivedAt); |
| 288 | 244 | }); |
| 289 | 245 | |
| 290 | it("preserves the current project order when syncing incoming read model updates", () => { | |
| 246 | it("replaces projects using snapshot order during recovery", () => { | |
| 291 | 247 | const project1 = ProjectId.makeUnsafe("project-1"); |
| 292 | 248 | const project2 = ProjectId.makeUnsafe("project-2"); |
| 293 | 249 | const project3 = ProjectId.makeUnsafe("project-3"); |
| ... | ... | @@ -301,7 +257,6 @@ describe("store read model sync", () => { |
| 301 | 257 | provider: "codex", |
| 302 | 258 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 303 | 259 | }, |
| 304 | expanded: true, | |
| 305 | 260 | scripts: [], |
| 306 | 261 | }, |
| 307 | 262 | { |
| ... | ... | @@ -312,12 +267,11 @@ describe("store read model sync", () => { |
| 312 | 267 | provider: "codex", |
| 313 | 268 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 314 | 269 | }, |
| 315 | expanded: true, | |
| 316 | 270 | scripts: [], |
| 317 | 271 | }, |
| 318 | 272 | ], |
| 319 | 273 | threads: [], |
| 320 | threadsHydrated: true, | |
| 274 | bootstrapComplete: true, | |
| 321 | 275 | }; |
| 322 | 276 | const readModel: OrchestrationReadModel = { |
| 323 | 277 | snapshotSequence: 2, |
| ... | ... | @@ -344,6 +298,446 @@ describe("store read model sync", () => { |
| 344 | 298 | |
| 345 | 299 | const next = syncServerReadModel(initialState, readModel); |
| 346 | 300 | |
| 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 | ||
| 305 | describe("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 | import { Fragment, type ReactNode, createElement, useEffect } from "react"; | |
| 2 | 1 | import { |
| 2 | type OrchestrationEvent, | |
| 3 | type OrchestrationMessage, | |
| 4 | type OrchestrationProposedPlan, | |
| 3 | 5 | type ProviderKind, |
| 4 | 6 | ThreadId, |
| 5 | 7 | type OrchestrationReadModel, |
| 8 | type OrchestrationSession, | |
| 9 | type OrchestrationCheckpointSummary, | |
| 10 | type OrchestrationThread, | |
| 6 | 11 | type OrchestrationSessionStatus, |
| 7 | 12 | } from "@t3tools/contracts"; |
| 8 | 13 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; |
| 9 | 14 | import { create } from "zustand"; |
| 10 | 15 | import { type ChatMessage, type Project, type Thread } from "./types"; |
| 11 | import { Debouncer } from "@tanstack/react-pacer"; | |
| 12 | 16 | |
| 13 | 17 | // ── State ──────────────────────────────────────────────────────────── |
| 14 | 18 | |
| 15 | 19 | export interface AppState { |
| 16 | 20 | projects: Project[]; |
| 17 | 21 | threads: Thread[]; |
| 18 | threadsHydrated: boolean; | |
| 22 | bootstrapComplete: boolean; | |
| 19 | 23 | } |
| 20 | 24 | |
| 21 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; | |
| 22 | const 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 | ||
| 34 | 25 | const initialState: AppState = { |
| 35 | 26 | projects: [], |
| 36 | 27 | threads: [], |
| 37 | threadsHydrated: false, | |
| 28 | bootstrapComplete: false, | |
| 38 | 29 | }; |
| 39 | const persistedExpandedProjectCwds = new Set<string>(); | |
| 40 | const persistedProjectOrderCwds: string[] = []; | |
| 41 | ||
| 42 | // ── Persist helpers ────────────────────────────────────────────────── | |
| 43 | ||
| 44 | function 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 | ||
| 71 | let legacyKeysCleanedUp = false; | |
| 72 | ||
| 73 | function 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 | } | |
| 95 | const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); | |
| 30 | const MAX_THREAD_MESSAGES = 2_000; | |
| 31 | const MAX_THREAD_CHECKPOINTS = 500; | |
| 32 | const MAX_THREAD_PROPOSED_PLANS = 200; | |
| 33 | const MAX_THREAD_ACTIVITIES = 500; | |
| 96 | 34 | |
| 97 | 35 | // ── Pure helpers ────────────────────────────────────────────────────── |
| 98 | 36 | |
| ... | ... | @@ -111,66 +49,295 @@ function updateThread( |
| 111 | 49 | return changed ? next : threads; |
| 112 | 50 | } |
| 113 | 51 | |
| 114 | function mapProjectsFromReadModel( | |
| 115 | incoming: OrchestrationReadModel["projects"], | |
| 116 | previous: Project[], | |
| 52 | function updateProject( | |
| 53 | projects: Project[], | |
| 54 | projectId: Project["id"], | |
| 55 | updater: (project: Project) => Project, | |
| 117 | 56 | ): Project[] { |
| 118 | const previousById = new Map(previous.map((project) => [project.id, project] as const)); | |
| 119 | const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const)); | |
| 120 | const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const)); | |
| 121 | const previousOrderByCwd = new Map( | |
| 122 | previous.map((project, index) => [project.cwd, index] as const), | |
| 123 | ); | |
| 124 | const persistedOrderByCwd = new Map( | |
| 125 | persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), | |
| 126 | ); | |
| 127 | const usePersistedOrder = previous.length === 0; | |
| 57 | let changed = false; | |
| 58 | const next = projects.map((project) => { | |
| 59 | if (project.id !== projectId) { | |
| 60 | return project; | |
| 61 | } | |
| 62 | const updated = updater(project); | |
| 63 | if (updated !== project) { | |
| 64 | changed = true; | |
| 65 | } | |
| 66 | return updated; | |
| 67 | }); | |
| 68 | return changed ? next : projects; | |
| 69 | } | |
| 70 | ||
| 71 | function 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 | } | |
| 128 | 79 | |
| 129 | const mappedProjects = incoming.map((project) => { | |
| 130 | const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot); | |
| 80 | function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): Project["scripts"] { | |
| 81 | return scripts.map((script) => ({ ...script })); | |
| 82 | } | |
| 83 | ||
| 84 | function 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 | ||
| 96 | function 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 | ||
| 118 | function 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 | ||
| 130 | function 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 | ||
| 144 | function 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 | ||
| 169 | function 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 | ||
| 183 | function 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 | ||
| 193 | function 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 | ||
| 210 | function 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 | ||
| 235 | function 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 | 246 | return { |
| 132 | id: project.id, | |
| 133 | name: project.title, | |
| 134 | cwd: project.workspaceRoot, | |
| 135 | defaultModelSelection: | |
| 136 | existing?.defaultModelSelection ?? | |
| 137 | (project.defaultModelSelection | |
| 138 | ? { | |
| 139 | ...project.defaultModelSelection, | |
| 140 | model: resolveModelSlugForProvider( | |
| 141 | project.defaultModelSelection.provider, | |
| 142 | project.defaultModelSelection.model, | |
| 143 | ), | |
| 144 | } | |
| 145 | : null), | |
| 146 | expanded: | |
| 147 | existing?.expanded ?? | |
| 148 | (persistedExpandedProjectCwds.size > 0 | |
| 149 | ? persistedExpandedProjectCwds.has(project.workspaceRoot) | |
| 150 | : true), | |
| 151 | createdAt: project.createdAt, | |
| 152 | updatedAt: project.updatedAt, | |
| 153 | scripts: project.scripts.map((script) => ({ ...script })), | |
| 154 | } satisfies Project; | |
| 247 | ...summary, | |
| 248 | assistantMessageId: assistantMessageId ?? undefined, | |
| 249 | }; | |
| 155 | 250 | }); |
| 251 | return changed ? nextSummaries : [...turnDiffSummaries]; | |
| 252 | } | |
| 253 | ||
| 254 | function 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 | ||
| 325 | function 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 | } | |
| 156 | 333 | |
| 157 | return mappedProjects | |
| 158 | .map((project, incomingIndex) => { | |
| 159 | const previousIndex = | |
| 160 | previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd); | |
| 161 | const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined; | |
| 162 | const orderIndex = | |
| 163 | previousIndex ?? | |
| 164 | persistedIndex ?? | |
| 165 | (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex; | |
| 166 | return { project, incomingIndex, orderIndex }; | |
| 167 | }) | |
| 168 | .toSorted((a, b) => { | |
| 169 | const byOrder = a.orderIndex - b.orderIndex; | |
| 170 | if (byOrder !== 0) return byOrder; | |
| 171 | return a.incomingIndex - b.incomingIndex; | |
| 172 | }) | |
| 173 | .map((entry) => entry.project); | |
| 334 | function retainThreadProposedPlansAfterRevert( | |
| 335 | proposedPlans: ReadonlyArray<Thread["proposedPlans"][number]>, | |
| 336 | retainedTurnIds: ReadonlySet<string>, | |
| 337 | ): Thread["proposedPlans"] { | |
| 338 | return proposedPlans.filter( | |
| 339 | (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), | |
| 340 | ); | |
| 174 | 341 | } |
| 175 | 342 | |
| 176 | 343 | function toLegacySessionStatus( |
| ... | ... | @@ -234,167 +401,506 @@ function attachmentPreviewRoutePath(attachmentId: string): string { |
| 234 | 401 | // ── Pure state transition functions ──────────────────────────────────── |
| 235 | 402 | |
| 236 | 403 | export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { |
| 237 | const projects = mapProjectsFromReadModel( | |
| 238 | readModel.projects.filter((project) => project.deletedAt === null), | |
| 239 | state.projects, | |
| 240 | ); | |
| 241 | const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); | |
| 242 | const threads = readModel.threads | |
| 243 | .filter((thread) => thread.deletedAt === null) | |
| 244 | .map((thread) => { | |
| 245 | const existing = existingThreadById.get(thread.id); | |
| 246 | return { | |
| 247 | id: thread.id, | |
| 248 | codexThreadId: null, | |
| 249 | projectId: thread.projectId, | |
| 250 | title: thread.title, | |
| 251 | modelSelection: { | |
| 252 | ...thread.modelSelection, | |
| 253 | model: resolveModelSlugForProvider( | |
| 254 | thread.modelSelection.provider, | |
| 255 | thread.modelSelection.model, | |
| 256 | ), | |
| 257 | }, | |
| 258 | runtimeMode: thread.runtimeMode, | |
| 259 | interactionMode: thread.interactionMode, | |
| 260 | session: thread.session | |
| 261 | ? { | |
| 262 | provider: toLegacyProvider(thread.session.providerName), | |
| 263 | status: toLegacySessionStatus(thread.session.status), | |
| 264 | orchestrationStatus: thread.session.status, | |
| 265 | activeTurnId: thread.session.activeTurnId ?? undefined, | |
| 266 | createdAt: thread.session.updatedAt, | |
| 267 | updatedAt: thread.session.updatedAt, | |
| 268 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), | |
| 269 | } | |
| 270 | : null, | |
| 271 | messages: thread.messages.map((message) => { | |
| 272 | const attachments = message.attachments?.map((attachment) => ({ | |
| 273 | type: "image" as const, | |
| 274 | id: attachment.id, | |
| 275 | name: attachment.name, | |
| 276 | mimeType: attachment.mimeType, | |
| 277 | sizeBytes: attachment.sizeBytes, | |
| 278 | previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), | |
| 279 | })); | |
| 280 | const normalizedMessage: ChatMessage = { | |
| 281 | id: message.id, | |
| 282 | role: message.role, | |
| 283 | text: message.text, | |
| 284 | createdAt: message.createdAt, | |
| 285 | streaming: message.streaming, | |
| 286 | ...(message.streaming ? {} : { completedAt: message.updatedAt }), | |
| 287 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | |
| 288 | }; | |
| 289 | return normalizedMessage; | |
| 290 | }), | |
| 291 | proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ | |
| 292 | id: proposedPlan.id, | |
| 293 | turnId: proposedPlan.turnId, | |
| 294 | planMarkdown: proposedPlan.planMarkdown, | |
| 295 | implementedAt: proposedPlan.implementedAt, | |
| 296 | implementationThreadId: proposedPlan.implementationThreadId, | |
| 297 | createdAt: proposedPlan.createdAt, | |
| 298 | updatedAt: proposedPlan.updatedAt, | |
| 299 | })), | |
| 300 | error: thread.session?.lastError ?? null, | |
| 301 | createdAt: thread.createdAt, | |
| 302 | archivedAt: thread.archivedAt, | |
| 303 | updatedAt: thread.updatedAt, | |
| 304 | latestTurn: thread.latestTurn, | |
| 305 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, | |
| 306 | branch: thread.branch, | |
| 307 | worktreePath: thread.worktreePath, | |
| 308 | turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ | |
| 309 | turnId: checkpoint.turnId, | |
| 310 | completedAt: checkpoint.completedAt, | |
| 311 | status: checkpoint.status, | |
| 312 | assistantMessageId: checkpoint.assistantMessageId ?? undefined, | |
| 313 | checkpointTurnCount: checkpoint.checkpointTurnCount, | |
| 314 | checkpointRef: checkpoint.checkpointRef, | |
| 315 | files: checkpoint.files.map((file) => ({ ...file })), | |
| 316 | })), | |
| 317 | activities: thread.activities.map((activity) => ({ ...activity })), | |
| 318 | }; | |
| 319 | }); | |
| 404 | const projects = readModel.projects | |
| 405 | .filter((project) => project.deletedAt === null) | |
| 406 | .map(mapProject); | |
| 407 | const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); | |
| 320 | 408 | return { |
| 321 | 409 | ...state, |
| 322 | 410 | projects, |
| 323 | 411 | threads, |
| 324 | threadsHydrated: true, | |
| 412 | bootstrapComplete: true, | |
| 325 | 413 | }; |
| 326 | 414 | } |
| 327 | 415 | |
| 328 | export function markThreadVisited( | |
| 329 | state: AppState, | |
| 330 | threadId: ThreadId, | |
| 331 | visitedAt?: string, | |
| 332 | ): AppState { | |
| 333 | const at = visitedAt ?? new Date().toISOString(); | |
| 334 | const visitedAtMs = Date.parse(at); | |
| 335 | const threads = updateThread(state.threads, threadId, (thread) => { | |
| 336 | const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; | |
| 337 | if ( | |
| 338 | Number.isFinite(previousVisitedAtMs) && | |
| 339 | Number.isFinite(visitedAtMs) && | |
| 340 | previousVisitedAtMs >= visitedAtMs | |
| 341 | ) { | |
| 342 | return thread; | |
| 416 | export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { | |
| 417 | switch (event.type) { | |
| 418 | case "project.created": { | |
| 419 | const existingIndex = state.projects.findIndex( | |
| 420 | (project) => | |
| 421 | project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, | |
| 422 | ); | |
| 423 | const nextProject = mapProject({ | |
| 424 | id: event.payload.projectId, | |
| 425 | title: event.payload.title, | |
| 426 | workspaceRoot: event.payload.workspaceRoot, | |
| 427 | defaultModelSelection: event.payload.defaultModelSelection, | |
| 428 | scripts: event.payload.scripts, | |
| 429 | createdAt: event.payload.createdAt, | |
| 430 | updatedAt: event.payload.updatedAt, | |
| 431 | deletedAt: null, | |
| 432 | }); | |
| 433 | const projects = | |
| 434 | existingIndex >= 0 | |
| 435 | ? state.projects.map((project, index) => | |
| 436 | index === existingIndex ? nextProject : project, | |
| 437 | ) | |
| 438 | : [...state.projects, nextProject]; | |
| 439 | return { ...state, projects }; | |
| 343 | 440 | } |
| 344 | return { ...thread, lastVisitedAt: at }; | |
| 345 | }); | |
| 346 | return threads === state.threads ? state : { ...state, threads }; | |
| 347 | } | |
| 348 | 441 | |
| 349 | export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { | |
| 350 | const threads = updateThread(state.threads, threadId, (thread) => { | |
| 351 | if (!thread.latestTurn?.completedAt) return thread; | |
| 352 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); | |
| 353 | if (Number.isNaN(latestTurnCompletedAtMs)) return thread; | |
| 354 | const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); | |
| 355 | if (thread.lastVisitedAt === unreadVisitedAt) return thread; | |
| 356 | return { ...thread, lastVisitedAt: unreadVisitedAt }; | |
| 357 | }); | |
| 358 | return threads === state.threads ? state : { ...state, threads }; | |
| 359 | } | |
| 442 | case "project.meta-updated": { | |
| 443 | const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ | |
| 444 | ...project, | |
| 445 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), | |
| 446 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), | |
| 447 | ...(event.payload.defaultModelSelection !== undefined | |
| 448 | ? { | |
| 449 | defaultModelSelection: event.payload.defaultModelSelection | |
| 450 | ? normalizeModelSelection(event.payload.defaultModelSelection) | |
| 451 | : null, | |
| 452 | } | |
| 453 | : {}), | |
| 454 | ...(event.payload.scripts !== undefined | |
| 455 | ? { scripts: mapProjectScripts(event.payload.scripts) } | |
| 456 | : {}), | |
| 457 | updatedAt: event.payload.updatedAt, | |
| 458 | })); | |
| 459 | return projects === state.projects ? state : { ...state, projects }; | |
| 460 | } | |
| 360 | 461 | |
| 361 | export function toggleProject(state: AppState, projectId: Project["id"]): AppState { | |
| 362 | return { | |
| 363 | ...state, | |
| 364 | projects: state.projects.map((p) => (p.id === projectId ? { ...p, expanded: !p.expanded } : p)), | |
| 365 | }; | |
| 366 | } | |
| 462 | case "project.deleted": { | |
| 463 | const projects = state.projects.filter((project) => project.id !== event.payload.projectId); | |
| 464 | return projects.length === state.projects.length ? state : { ...state, projects }; | |
| 465 | } | |
| 367 | 466 | |
| 368 | export function setProjectExpanded( | |
| 369 | state: AppState, | |
| 370 | projectId: Project["id"], | |
| 371 | expanded: boolean, | |
| 372 | ): AppState { | |
| 373 | let changed = false; | |
| 374 | const projects = state.projects.map((p) => { | |
| 375 | if (p.id !== projectId || p.expanded === expanded) return p; | |
| 376 | changed = true; | |
| 377 | return { ...p, expanded }; | |
| 378 | }); | |
| 379 | return changed ? { ...state, projects } : state; | |
| 467 | case "thread.created": { | |
| 468 | const existing = state.threads.find((thread) => thread.id === event.payload.threadId); | |
| 469 | const nextThread = mapThread({ | |
| 470 | id: event.payload.threadId, | |
| 471 | projectId: event.payload.projectId, | |
| 472 | title: event.payload.title, | |
| 473 | modelSelection: event.payload.modelSelection, | |
| 474 | runtimeMode: event.payload.runtimeMode, | |
| 475 | interactionMode: event.payload.interactionMode, | |
| 476 | branch: event.payload.branch, | |
| 477 | worktreePath: event.payload.worktreePath, | |
| 478 | latestTurn: null, | |
| 479 | createdAt: event.payload.createdAt, | |
| 480 | updatedAt: event.payload.updatedAt, | |
| 481 | archivedAt: null, | |
| 482 | deletedAt: null, | |
| 483 | messages: [], | |
| 484 | proposedPlans: [], | |
| 485 | activities: [], | |
| 486 | checkpoints: [], | |
| 487 | session: null, | |
| 488 | }); | |
| 489 | const threads = existing | |
| 490 | ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) | |
| 491 | : [...state.threads, nextThread]; | |
| 492 | return { ...state, threads }; | |
| 493 | } | |
| 494 | ||
| 495 | case "thread.deleted": { | |
| 496 | const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); | |
| 497 | return threads.length === state.threads.length ? state : { ...state, threads }; | |
| 498 | } | |
| 499 | ||
| 500 | case "thread.archived": { | |
| 501 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 502 | ...thread, | |
| 503 | archivedAt: event.payload.archivedAt, | |
| 504 | updatedAt: event.payload.updatedAt, | |
| 505 | })); | |
| 506 | return threads === state.threads ? state : { ...state, threads }; | |
| 507 | } | |
| 508 | ||
| 509 | case "thread.unarchived": { | |
| 510 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 511 | ...thread, | |
| 512 | archivedAt: null, | |
| 513 | updatedAt: event.payload.updatedAt, | |
| 514 | })); | |
| 515 | return threads === state.threads ? state : { ...state, threads }; | |
| 516 | } | |
| 517 | ||
| 518 | case "thread.meta-updated": { | |
| 519 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 520 | ...thread, | |
| 521 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | |
| 522 | ...(event.payload.modelSelection !== undefined | |
| 523 | ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } | |
| 524 | : {}), | |
| 525 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | |
| 526 | ...(event.payload.worktreePath !== undefined | |
| 527 | ? { worktreePath: event.payload.worktreePath } | |
| 528 | : {}), | |
| 529 | updatedAt: event.payload.updatedAt, | |
| 530 | })); | |
| 531 | return threads === state.threads ? state : { ...state, threads }; | |
| 532 | } | |
| 533 | ||
| 534 | case "thread.runtime-mode-set": { | |
| 535 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 536 | ...thread, | |
| 537 | runtimeMode: event.payload.runtimeMode, | |
| 538 | updatedAt: event.payload.updatedAt, | |
| 539 | })); | |
| 540 | return threads === state.threads ? state : { ...state, threads }; | |
| 541 | } | |
| 542 | ||
| 543 | case "thread.interaction-mode-set": { | |
| 544 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 545 | ...thread, | |
| 546 | interactionMode: event.payload.interactionMode, | |
| 547 | updatedAt: event.payload.updatedAt, | |
| 548 | })); | |
| 549 | return threads === state.threads ? state : { ...state, threads }; | |
| 550 | } | |
| 551 | ||
| 552 | case "thread.turn-start-requested": { | |
| 553 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 554 | ...thread, | |
| 555 | ...(event.payload.modelSelection !== undefined | |
| 556 | ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } | |
| 557 | : {}), | |
| 558 | runtimeMode: event.payload.runtimeMode, | |
| 559 | interactionMode: event.payload.interactionMode, | |
| 560 | pendingSourceProposedPlan: event.payload.sourceProposedPlan, | |
| 561 | updatedAt: event.occurredAt, | |
| 562 | })); | |
| 563 | return threads === state.threads ? state : { ...state, threads }; | |
| 564 | } | |
| 565 | ||
| 566 | case "thread.turn-interrupt-requested": { | |
| 567 | if (event.payload.turnId === undefined) { | |
| 568 | return state; | |
| 569 | } | |
| 570 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => { | |
| 571 | const latestTurn = thread.latestTurn; | |
| 572 | if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { | |
| 573 | return thread; | |
| 574 | } | |
| 575 | return { | |
| 576 | ...thread, | |
| 577 | latestTurn: buildLatestTurn({ | |
| 578 | previous: latestTurn, | |
| 579 | turnId: event.payload.turnId, | |
| 580 | state: "interrupted", | |
| 581 | requestedAt: latestTurn.requestedAt, | |
| 582 | startedAt: latestTurn.startedAt ?? event.payload.createdAt, | |
| 583 | completedAt: latestTurn.completedAt ?? event.payload.createdAt, | |
| 584 | assistantMessageId: latestTurn.assistantMessageId, | |
| 585 | }), | |
| 586 | updatedAt: event.occurredAt, | |
| 587 | }; | |
| 588 | }); | |
| 589 | return threads === state.threads ? state : { ...state, threads }; | |
| 590 | } | |
| 591 | ||
| 592 | case "thread.message-sent": { | |
| 593 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => { | |
| 594 | const message = mapMessage({ | |
| 595 | id: event.payload.messageId, | |
| 596 | role: event.payload.role, | |
| 597 | text: event.payload.text, | |
| 598 | ...(event.payload.attachments !== undefined | |
| 599 | ? { attachments: event.payload.attachments } | |
| 600 | : {}), | |
| 601 | turnId: event.payload.turnId, | |
| 602 | streaming: event.payload.streaming, | |
| 603 | createdAt: event.payload.createdAt, | |
| 604 | updatedAt: event.payload.updatedAt, | |
| 605 | }); | |
| 606 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | |
| 607 | const messages = existingMessage | |
| 608 | ? thread.messages.map((entry) => | |
| 609 | entry.id !== message.id | |
| 610 | ? entry | |
| 611 | : { | |
| 612 | ...entry, | |
| 613 | text: message.streaming | |
| 614 | ? `${entry.text}${message.text}` | |
| 615 | : message.text.length > 0 | |
| 616 | ? message.text | |
| 617 | : entry.text, | |
| 618 | streaming: message.streaming, | |
| 619 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | |
| 620 | ...(message.streaming | |
| 621 | ? entry.completedAt !== undefined | |
| 622 | ? { completedAt: entry.completedAt } | |
| 623 | : {} | |
| 624 | : message.completedAt !== undefined | |
| 625 | ? { completedAt: message.completedAt } | |
| 626 | : {}), | |
| 627 | ...(message.attachments !== undefined | |
| 628 | ? { attachments: message.attachments } | |
| 629 | : {}), | |
| 630 | }, | |
| 631 | ) | |
| 632 | : [...thread.messages, message]; | |
| 633 | const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); | |
| 634 | const turnDiffSummaries = | |
| 635 | event.payload.role === "assistant" && event.payload.turnId !== null | |
| 636 | ? rebindTurnDiffSummariesForAssistantMessage( | |
| 637 | thread.turnDiffSummaries, | |
| 638 | event.payload.turnId, | |
| 639 | event.payload.messageId, | |
| 640 | ) | |
| 641 | : thread.turnDiffSummaries; | |
| 642 | const latestTurn: Thread["latestTurn"] = | |
| 643 | event.payload.role === "assistant" && | |
| 644 | event.payload.turnId !== null && | |
| 645 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | |
| 646 | ? buildLatestTurn({ | |
| 647 | previous: thread.latestTurn, | |
| 648 | turnId: event.payload.turnId, | |
| 649 | state: event.payload.streaming | |
| 650 | ? "running" | |
| 651 | : thread.latestTurn?.state === "interrupted" | |
| 652 | ? "interrupted" | |
| 653 | : thread.latestTurn?.state === "error" | |
| 654 | ? "error" | |
| 655 | : "completed", | |
| 656 | requestedAt: | |
| 657 | thread.latestTurn?.turnId === event.payload.turnId | |
| 658 | ? thread.latestTurn.requestedAt | |
| 659 | : event.payload.createdAt, | |
| 660 | startedAt: | |
| 661 | thread.latestTurn?.turnId === event.payload.turnId | |
| 662 | ? (thread.latestTurn.startedAt ?? event.payload.createdAt) | |
| 663 | : event.payload.createdAt, | |
| 664 | sourceProposedPlan: thread.pendingSourceProposedPlan, | |
| 665 | completedAt: event.payload.streaming | |
| 666 | ? thread.latestTurn?.turnId === event.payload.turnId | |
| 667 | ? (thread.latestTurn.completedAt ?? null) | |
| 668 | : null | |
| 669 | : event.payload.updatedAt, | |
| 670 | assistantMessageId: event.payload.messageId, | |
| 671 | }) | |
| 672 | : thread.latestTurn; | |
| 673 | return { | |
| 674 | ...thread, | |
| 675 | messages: cappedMessages, | |
| 676 | turnDiffSummaries, | |
| 677 | latestTurn, | |
| 678 | updatedAt: event.occurredAt, | |
| 679 | }; | |
| 680 | }); | |
| 681 | return threads === state.threads ? state : { ...state, threads }; | |
| 682 | } | |
| 683 | ||
| 684 | case "thread.session-set": { | |
| 685 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ | |
| 686 | ...thread, | |
| 687 | session: mapSession(event.payload.session), | |
| 688 | error: event.payload.session.lastError ?? null, | |
| 689 | latestTurn: | |
| 690 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | |
| 691 | ? buildLatestTurn({ | |
| 692 | previous: thread.latestTurn, | |
| 693 | turnId: event.payload.session.activeTurnId, | |
| 694 | state: "running", | |
| 695 | requestedAt: | |
| 696 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | |
| 697 | ? thread.latestTurn.requestedAt | |
| 698 | : event.payload.session.updatedAt, | |
| 699 | startedAt: | |
| 700 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | |
| 701 | ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) | |
| 702 | : event.payload.session.updatedAt, | |
| 703 | completedAt: null, | |
| 704 | assistantMessageId: | |
| 705 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | |
| 706 | ? thread.latestTurn.assistantMessageId | |
| 707 | : null, | |
| 708 | sourceProposedPlan: thread.pendingSourceProposedPlan, | |
| 709 | }) | |
| 710 | : thread.latestTurn, | |
| 711 | updatedAt: event.occurredAt, | |
| 712 | })); | |
| 713 | return threads === state.threads ? state : { ...state, threads }; | |
| 714 | } | |
| 715 | ||
| 716 | case "thread.session-stop-requested": { | |
| 717 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => | |
| 718 | thread.session === null | |
| 719 | ? thread | |
| 720 | : { | |
| 721 | ...thread, | |
| 722 | session: { | |
| 723 | ...thread.session, | |
| 724 | status: "closed", | |
| 725 | orchestrationStatus: "stopped", | |
| 726 | activeTurnId: undefined, | |
| 727 | updatedAt: event.payload.createdAt, | |
| 728 | }, | |
| 729 | updatedAt: event.occurredAt, | |
| 730 | }, | |
| 731 | ); | |
| 732 | return threads === state.threads ? state : { ...state, threads }; | |
| 733 | } | |
| 734 | ||
| 735 | case "thread.proposed-plan-upserted": { | |
| 736 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => { | |
| 737 | const proposedPlan = mapProposedPlan(event.payload.proposedPlan); | |
| 738 | const proposedPlans = [ | |
| 739 | ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), | |
| 740 | proposedPlan, | |
| 741 | ] | |
| 742 | .toSorted( | |
| 743 | (left, right) => | |
| 744 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | |
| 745 | ) | |
| 746 | .slice(-MAX_THREAD_PROPOSED_PLANS); | |
| 747 | return { | |
| 748 | ...thread, | |
| 749 | proposedPlans, | |
| 750 | updatedAt: event.occurredAt, | |
| 751 | }; | |
| 752 | }); | |
| 753 | return threads === state.threads ? state : { ...state, threads }; | |
| 754 | } | |
| 755 | ||
| 756 | case "thread.turn-diff-completed": { | |
| 757 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => { | |
| 758 | const checkpoint = mapTurnDiffSummary({ | |
| 759 | turnId: event.payload.turnId, | |
| 760 | checkpointTurnCount: event.payload.checkpointTurnCount, | |
| 761 | checkpointRef: event.payload.checkpointRef, | |
| 762 | status: event.payload.status, | |
| 763 | files: event.payload.files, | |
| 764 | assistantMessageId: event.payload.assistantMessageId, | |
| 765 | completedAt: event.payload.completedAt, | |
| 766 | }); | |
| 767 | const existing = thread.turnDiffSummaries.find( | |
| 768 | (entry) => entry.turnId === checkpoint.turnId, | |
| 769 | ); | |
| 770 | if (existing && existing.status !== "missing" && checkpoint.status === "missing") { | |
| 771 | return thread; | |
| 772 | } | |
| 773 | const turnDiffSummaries = [ | |
| 774 | ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), | |
| 775 | checkpoint, | |
| 776 | ] | |
| 777 | .toSorted( | |
| 778 | (left, right) => | |
| 779 | (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - | |
| 780 | (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), | |
| 781 | ) | |
| 782 | .slice(-MAX_THREAD_CHECKPOINTS); | |
| 783 | const latestTurn = | |
| 784 | thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId | |
| 785 | ? buildLatestTurn({ | |
| 786 | previous: thread.latestTurn, | |
| 787 | turnId: event.payload.turnId, | |
| 788 | state: checkpointStatusToLatestTurnState(event.payload.status), | |
| 789 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | |
| 790 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | |
| 791 | completedAt: event.payload.completedAt, | |
| 792 | assistantMessageId: event.payload.assistantMessageId, | |
| 793 | sourceProposedPlan: thread.pendingSourceProposedPlan, | |
| 794 | }) | |
| 795 | : thread.latestTurn; | |
| 796 | return { | |
| 797 | ...thread, | |
| 798 | turnDiffSummaries, | |
| 799 | latestTurn, | |
| 800 | updatedAt: event.occurredAt, | |
| 801 | }; | |
| 802 | }); | |
| 803 | return threads === state.threads ? state : { ...state, threads }; | |
| 804 | } | |
| 805 | ||
| 806 | case "thread.reverted": { | |
| 807 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => { | |
| 808 | const turnDiffSummaries = thread.turnDiffSummaries | |
| 809 | .filter( | |
| 810 | (entry) => | |
| 811 | entry.checkpointTurnCount !== undefined && | |
| 812 | entry.checkpointTurnCount <= event.payload.turnCount, | |
| 813 | ) | |
| 814 | .toSorted( | |
| 815 | (left, right) => | |
| 816 | (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - | |
| 817 | (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), | |
| 818 | ) | |
| 819 | .slice(-MAX_THREAD_CHECKPOINTS); | |
| 820 | const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); | |
| 821 | const messages = retainThreadMessagesAfterRevert( | |
| 822 | thread.messages, | |
| 823 | retainedTurnIds, | |
| 824 | event.payload.turnCount, | |
| 825 | ).slice(-MAX_THREAD_MESSAGES); | |
| 826 | const proposedPlans = retainThreadProposedPlansAfterRevert( | |
| 827 | thread.proposedPlans, | |
| 828 | retainedTurnIds, | |
| 829 | ).slice(-MAX_THREAD_PROPOSED_PLANS); | |
| 830 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); | |
| 831 | const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; | |
| 832 | ||
| 833 | return { | |
| 834 | ...thread, | |
| 835 | turnDiffSummaries, | |
| 836 | messages, | |
| 837 | proposedPlans, | |
| 838 | activities, | |
| 839 | pendingSourceProposedPlan: undefined, | |
| 840 | latestTurn: | |
| 841 | latestCheckpoint === null | |
| 842 | ? null | |
| 843 | : { | |
| 844 | turnId: latestCheckpoint.turnId, | |
| 845 | state: checkpointStatusToLatestTurnState( | |
| 846 | (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", | |
| 847 | ), | |
| 848 | requestedAt: latestCheckpoint.completedAt, | |
| 849 | startedAt: latestCheckpoint.completedAt, | |
| 850 | completedAt: latestCheckpoint.completedAt, | |
| 851 | assistantMessageId: latestCheckpoint.assistantMessageId ?? null, | |
| 852 | }, | |
| 853 | updatedAt: event.occurredAt, | |
| 854 | }; | |
| 855 | }); | |
| 856 | return threads === state.threads ? state : { ...state, threads }; | |
| 857 | } | |
| 858 | ||
| 859 | case "thread.activity-appended": { | |
| 860 | const threads = updateThread(state.threads, event.payload.threadId, (thread) => { | |
| 861 | const activities = [ | |
| 862 | ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), | |
| 863 | { ...event.payload.activity }, | |
| 864 | ] | |
| 865 | .toSorted(compareActivities) | |
| 866 | .slice(-MAX_THREAD_ACTIVITIES); | |
| 867 | return { | |
| 868 | ...thread, | |
| 869 | activities, | |
| 870 | updatedAt: event.occurredAt, | |
| 871 | }; | |
| 872 | }); | |
| 873 | return threads === state.threads ? state : { ...state, threads }; | |
| 874 | } | |
| 875 | ||
| 876 | case "thread.approval-response-requested": | |
| 877 | case "thread.user-input-response-requested": | |
| 878 | return state; | |
| 879 | } | |
| 880 | ||
| 881 | return state; | |
| 380 | 882 | } |
| 381 | 883 | |
| 382 | export function reorderProjects( | |
| 884 | export function applyOrchestrationEvents( | |
| 383 | 885 | state: AppState, |
| 384 | draggedProjectId: Project["id"], | |
| 385 | targetProjectId: Project["id"], | |
| 886 | events: ReadonlyArray<OrchestrationEvent>, | |
| 386 | 887 | ): AppState { |
| 387 | if (draggedProjectId === targetProjectId) return state; | |
| 388 | const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId); | |
| 389 | const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId); | |
| 390 | if (draggedIndex < 0 || targetIndex < 0) return state; | |
| 391 | const projects = [...state.projects]; | |
| 392 | const [draggedProject] = projects.splice(draggedIndex, 1); | |
| 393 | if (!draggedProject) return state; | |
| 394 | projects.splice(targetIndex, 0, draggedProject); | |
| 395 | return { ...state, projects }; | |
| 888 | if (events.length === 0) { | |
| 889 | return state; | |
| 890 | } | |
| 891 | return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); | |
| 396 | 892 | } |
| 397 | 893 | |
| 894 | export const selectProjectById = | |
| 895 | (projectId: Project["id"] | null | undefined) => | |
| 896 | (state: AppState): Project | undefined => | |
| 897 | projectId ? state.projects.find((project) => project.id === projectId) : undefined; | |
| 898 | ||
| 899 | export const selectThreadById = | |
| 900 | (threadId: ThreadId | null | undefined) => | |
| 901 | (state: AppState): Thread | undefined => | |
| 902 | threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; | |
| 903 | ||
| 398 | 904 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { |
| 399 | 905 | const threads = updateThread(state.threads, threadId, (t) => { |
| 400 | 906 | if (t.error === error) return t; |
| ... | ... | @@ -426,44 +932,18 @@ export function setThreadBranch( |
| 426 | 932 | |
| 427 | 933 | interface AppStore extends AppState { |
| 428 | 934 | syncServerReadModel: (readModel: OrchestrationReadModel) => void; |
| 429 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; | |
| 430 | markThreadUnread: (threadId: ThreadId) => void; | |
| 431 | toggleProject: (projectId: Project["id"]) => void; | |
| 432 | setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; | |
| 433 | reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void; | |
| 935 | applyOrchestrationEvent: (event: OrchestrationEvent) => void; | |
| 936 | applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void; | |
| 434 | 937 | setError: (threadId: ThreadId, error: string | null) => void; |
| 435 | 938 | setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; |
| 436 | 939 | } |
| 437 | 940 | |
| 438 | 941 | export const useStore = create<AppStore>((set) => ({ |
| 439 | ...readPersistedState(), | |
| 942 | ...initialState, | |
| 440 | 943 | syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), |
| 441 | markThreadVisited: (threadId, visitedAt) => | |
| 442 | set((state) => markThreadVisited(state, threadId, visitedAt)), | |
| 443 | markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), | |
| 444 | toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), | |
| 445 | setProjectExpanded: (projectId, expanded) => | |
| 446 | set((state) => setProjectExpanded(state, projectId, expanded)), | |
| 447 | reorderProjects: (draggedProjectId, targetProjectId) => | |
| 448 | set((state) => reorderProjects(state, draggedProjectId, targetProjectId)), | |
| 944 | applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), | |
| 945 | applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), | |
| 449 | 946 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), |
| 450 | 947 | setThreadBranch: (threadId, branch, worktreePath) => |
| 451 | 948 | set((state) => setThreadBranch(state, threadId, branch, worktreePath)), |
| 452 | 949 | })); |
| 453 | ||
| 454 | // Persist state changes with debouncing to avoid localStorage thrashing | |
| 455 | useStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); | |
| 456 | ||
| 457 | // Flush pending writes synchronously before page unload to prevent data loss. | |
| 458 | if (typeof window !== "undefined") { | |
| 459 | window.addEventListener("beforeunload", () => { | |
| 460 | debouncedPersistState.flush(); | |
| 461 | }); | |
| 462 | } | |
| 463 | ||
| 464 | export 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 @@ |
| 1 | import { type ThreadId } from "@t3tools/contracts"; | |
| 2 | import { useMemo } from "react"; | |
| 3 | import { selectProjectById, selectThreadById, useStore } from "./store"; | |
| 4 | import { type Project, type Thread } from "./types"; | |
| 5 | ||
| 6 | export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { | |
| 7 | const selector = useMemo(() => selectProjectById(projectId), [projectId]); | |
| 8 | return useStore(selector); | |
| 9 | } | |
| 10 | ||
| 11 | export 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 | 485 | hasRunningSubprocess: boolean, |
| 486 | 486 | ) => void; |
| 487 | 487 | clearTerminalState: (threadId: ThreadId) => void; |
| 488 | removeTerminalState: (threadId: ThreadId) => void; | |
| 488 | 489 | removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void; |
| 489 | 490 | } |
| 490 | 491 | |
| ... | ... | @@ -530,6 +531,15 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 530 | 531 | ), |
| 531 | 532 | clearTerminalState: (threadId) => |
| 532 | 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 | 543 | removeOrphanedTerminalStates: (activeThreadIds) => |
| 534 | 544 | set((state) => { |
| 535 | 545 | const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( |
apps/web/src/types.ts+2-2| ... | ... | @@ -45,6 +45,7 @@ export interface ChatMessage { |
| 45 | 45 | role: "user" | "assistant" | "system"; |
| 46 | 46 | text: string; |
| 47 | 47 | attachments?: ChatAttachment[]; |
| 48 | turnId?: TurnId | null; | |
| 48 | 49 | createdAt: string; |
| 49 | 50 | completedAt?: string | undefined; |
| 50 | 51 | streaming: boolean; |
| ... | ... | @@ -82,7 +83,6 @@ export interface Project { |
| 82 | 83 | name: string; |
| 83 | 84 | cwd: string; |
| 84 | 85 | defaultModelSelection: ModelSelection | null; |
| 85 | expanded: boolean; | |
| 86 | 86 | createdAt?: string | undefined; |
| 87 | 87 | updatedAt?: string | undefined; |
| 88 | 88 | scripts: ProjectScript[]; |
| ... | ... | @@ -104,7 +104,7 @@ export interface Thread { |
| 104 | 104 | archivedAt: string | null; |
| 105 | 105 | updatedAt?: string | undefined; |
| 106 | 106 | latestTurn: OrchestrationLatestTurn | null; |
| 107 | lastVisitedAt?: string | undefined; | |
| 107 | pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; | |
| 108 | 108 | branch: string | null; |
| 109 | 109 | worktreePath: string | null; |
| 110 | 110 | turnDiffSummaries: TurnDiffSummary[]; |
apps/web/src/uiStateStore.test.ts created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | clearThreadUi, | |
| 6 | markThreadUnread, | |
| 7 | reorderProjects, | |
| 8 | setProjectExpanded, | |
| 9 | syncProjects, | |
| 10 | syncThreads, | |
| 11 | type UiState, | |
| 12 | } from "./uiStateStore"; | |
| 13 | ||
| 14 | function makeUiState(overrides: Partial<UiState> = {}): UiState { | |
| 15 | return { | |
| 16 | projectExpandedById: {}, | |
| 17 | projectOrder: [], | |
| 18 | threadLastVisitedAtById: {}, | |
| 19 | ...overrides, | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | describe("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 @@ |
| 1 | import { Debouncer } from "@tanstack/react-pacer"; | |
| 2 | import { type ProjectId, type ThreadId } from "@t3tools/contracts"; | |
| 3 | import { create } from "zustand"; | |
| 4 | ||
| 5 | const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; | |
| 6 | const 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 | ||
| 19 | interface PersistedUiState { | |
| 20 | expandedProjectCwds?: string[]; | |
| 21 | projectOrderCwds?: string[]; | |
| 22 | } | |
| 23 | ||
| 24 | export interface UiProjectState { | |
| 25 | projectExpandedById: Record<string, boolean>; | |
| 26 | projectOrder: ProjectId[]; | |
| 27 | } | |
| 28 | ||
| 29 | export interface UiThreadState { | |
| 30 | threadLastVisitedAtById: Record<string, string>; | |
| 31 | } | |
| 32 | ||
| 33 | export interface UiState extends UiProjectState, UiThreadState {} | |
| 34 | ||
| 35 | export interface SyncProjectInput { | |
| 36 | id: ProjectId; | |
| 37 | cwd: string; | |
| 38 | } | |
| 39 | ||
| 40 | export interface SyncThreadInput { | |
| 41 | id: ThreadId; | |
| 42 | seedVisitedAt?: string | undefined; | |
| 43 | } | |
| 44 | ||
| 45 | const initialState: UiState = { | |
| 46 | projectExpandedById: {}, | |
| 47 | projectOrder: [], | |
| 48 | threadLastVisitedAtById: {}, | |
| 49 | }; | |
| 50 | ||
| 51 | const persistedExpandedProjectCwds = new Set<string>(); | |
| 52 | const persistedProjectOrderCwds: string[] = []; | |
| 53 | const currentProjectCwdById = new Map<ProjectId, string>(); | |
| 54 | let legacyKeysCleanedUp = false; | |
| 55 | ||
| 56 | function 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 | ||
| 80 | function 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 | ||
| 95 | function 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 | ||
| 128 | const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); | |
| 129 | ||
| 130 | function 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 | ||
| 144 | function 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 | ||
| 150 | export 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 | ||
| 248 | export 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 | ||
| 273 | export 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 | ||
| 294 | export 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 | ||
| 319 | export 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 | ||
| 331 | export 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 | ||
| 342 | export 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 | ||
| 359 | export 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 | ||
| 384 | interface 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 | ||
| 395 | export 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 | ||
| 411 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); | |
| 412 | ||
| 413 | if (typeof window !== "undefined") { | |
| 414 | window.addEventListener("beforeunload", () => { | |
| 415 | debouncedPersistState.flush(); | |
| 416 | }); | |
| 417 | } |