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