| author | |
| committer | |
| log | 1ec346c22e2a4df96721502c66c16c98b1f4f14d |
| tree | b80bd7059a745aca78c308ea08bc351f39649f1a |
| parent | 28e481eb24dc7e790b6d1ea963f20024b6a2bbc4 |
| signature | Signed by PGP key B5690EEEBB952194 |
21 files changed, 1329 insertions(+), 544 deletions(-)
CLAUDE.md+1-1| ... | @@ -1 +1 @@ | ... | @@ -1 +1 @@ |
| 1 | AGENTS.md | ||
| \ No newline at end of file | |||
| 1 | AGENTS.md | ||
apps/web/src/components/BranchToolbar.tsx+7-7| ... | @@ -34,15 +34,15 @@ export default function BranchToolbar({ | ... | @@ -34,15 +34,15 @@ export default function BranchToolbar({ |
| 34 | onCheckoutPullRequestRequest, | 34 | onCheckoutPullRequestRequest, |
| 35 | onComposerFocusRequest, | 35 | onComposerFocusRequest, |
| 36 | }: BranchToolbarProps) { | 36 | }: BranchToolbarProps) { |
| 37 | const threads = useStore((store) => store.threads); | 37 | const serverThread = useStore((store) => store.threadShellById[threadId]); |
| 38 | const projects = useStore((store) => store.projects); | 38 | const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); |
| 39 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | 39 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 40 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); | 40 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 41 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 41 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 42 | |||
| 43 | const serverThread = threads.find((thread) => thread.id === threadId); | ||
| 44 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | 42 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; |
| 45 | const activeProject = projects.find((project) => project.id === activeProjectId); | 43 | const activeProject = useStore((store) => |
| 44 | activeProjectId ? store.projectById[activeProjectId] : undefined, | ||
| 45 | ); | ||
| 46 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | 46 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 47 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; | 47 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; |
| 48 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 48 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| ... | @@ -60,7 +60,7 @@ export default function BranchToolbar({ | ... | @@ -60,7 +60,7 @@ export default function BranchToolbar({ |
| 60 | const api = readNativeApi(); | 60 | const api = readNativeApi(); |
| 61 | // If the effective cwd is about to change, stop the running session so the | 61 | // If the effective cwd is about to change, stop the running session so the |
| 62 | // next message creates a new one with the correct cwd. | 62 | // next message creates a new one with the correct cwd. |
| 63 | if (serverThread?.session && worktreePath !== activeWorktreePath && api) { | 63 | if (serverSession && worktreePath !== activeWorktreePath && api) { |
| 64 | void api.orchestration | 64 | void api.orchestration |
| 65 | .dispatchCommand({ | 65 | .dispatchCommand({ |
| 66 | type: "thread.session.stop", | 66 | type: "thread.session.stop", |
| ... | @@ -96,7 +96,7 @@ export default function BranchToolbar({ | ... | @@ -96,7 +96,7 @@ export default function BranchToolbar({ |
| 96 | }, | 96 | }, |
| 97 | [ | 97 | [ |
| 98 | activeThreadId, | 98 | activeThreadId, |
| 99 | serverThread?.session, | 99 | serverSession, |
| 100 | activeWorktreePath, | 100 | activeWorktreePath, |
| 101 | hasServerThread, | 101 | hasServerThread, |
| 102 | setThreadBranchAction, | 102 | setThreadBranchAction, |
apps/web/src/components/ChatView.browser.tsx+16-2| ... | @@ -1176,8 +1176,22 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1176,8 +1176,22 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1176 | stickyActiveProvider: null, | 1176 | stickyActiveProvider: null, |
| 1177 | }); | 1177 | }); |
| 1178 | useStore.setState({ | 1178 | useStore.setState({ |
| 1179 | projects: [], | 1179 | projectIds: [], |
| 1180 | threads: [], | 1180 | projectById: {}, |
| 1181 | threadIds: [], | ||
| 1182 | threadIdsByProjectId: {}, | ||
| 1183 | threadShellById: {}, | ||
| 1184 | threadSessionById: {}, | ||
| 1185 | threadTurnStateById: {}, | ||
| 1186 | messageIdsByThreadId: {}, | ||
| 1187 | messageByThreadId: {}, | ||
| 1188 | activityIdsByThreadId: {}, | ||
| 1189 | activityByThreadId: {}, | ||
| 1190 | proposedPlanIdsByThreadId: {}, | ||
| 1191 | proposedPlanByThreadId: {}, | ||
| 1192 | turnDiffIdsByThreadId: {}, | ||
| 1193 | turnDiffSummaryByThreadId: {}, | ||
| 1194 | sidebarThreadSummaryById: {}, | ||
| 1181 | bootstrapComplete: false, | 1195 | bootstrapComplete: false, |
| 1182 | }); | 1196 | }); |
| 1183 | useTerminalStateStore.persist.clearStorage(); | 1197 | useTerminalStateStore.persist.clearStorage(); |
apps/web/src/components/ChatView.logic.test.ts+140-64| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | import { useStore } from "../store"; | 3 | import { useStore } from "../store"; |
| 4 | import { type Thread } from "../types"; | ||
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| 6 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 7 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| ... | @@ -178,7 +179,7 @@ const makeThread = (input?: { | ... | @@ -178,7 +179,7 @@ const makeThread = (input?: { |
| 178 | startedAt: string | null; | 179 | startedAt: string | null; |
| 179 | completedAt: string | null; | 180 | completedAt: string | null; |
| 180 | } | null; | 181 | } | null; |
| 181 | }) => ({ | 182 | }): Thread => ({ |
| 182 | id: input?.id ?? ThreadId.makeUnsafe("thread-1"), | 183 | id: input?.id ?? ThreadId.makeUnsafe("thread-1"), |
| 183 | codexThreadId: null, | 184 | codexThreadId: null, |
| 184 | projectId: ProjectId.makeUnsafe("project-1"), | 185 | projectId: ProjectId.makeUnsafe("project-1"), |
| ... | @@ -205,94 +206,172 @@ const makeThread = (input?: { | ... | @@ -205,94 +206,172 @@ const makeThread = (input?: { |
| 205 | activities: [], | 206 | activities: [], |
| 206 | }); | 207 | }); |
| 207 | 208 | ||
| 209 | function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) { | ||
| 210 | const projectId = ProjectId.makeUnsafe("project-1"); | ||
| 211 | useStore.setState({ | ||
| 212 | projectIds: [projectId], | ||
| 213 | projectById: { | ||
| 214 | [projectId]: { | ||
| 215 | id: projectId, | ||
| 216 | name: "Project", | ||
| 217 | cwd: "/tmp/project", | ||
| 218 | defaultModelSelection: { | ||
| 219 | provider: "codex", | ||
| 220 | model: "gpt-5.4", | ||
| 221 | }, | ||
| 222 | createdAt: "2026-03-29T00:00:00.000Z", | ||
| 223 | updatedAt: "2026-03-29T00:00:00.000Z", | ||
| 224 | scripts: [], | ||
| 225 | }, | ||
| 226 | }, | ||
| 227 | threadIds: threads.map((thread) => thread.id), | ||
| 228 | threadIdsByProjectId: { | ||
| 229 | [projectId]: threads.map((thread) => thread.id), | ||
| 230 | }, | ||
| 231 | threadShellById: Object.fromEntries( | ||
| 232 | threads.map((thread) => [ | ||
| 233 | thread.id, | ||
| 234 | { | ||
| 235 | id: thread.id, | ||
| 236 | codexThreadId: thread.codexThreadId, | ||
| 237 | projectId: thread.projectId, | ||
| 238 | title: thread.title, | ||
| 239 | modelSelection: thread.modelSelection, | ||
| 240 | runtimeMode: thread.runtimeMode, | ||
| 241 | interactionMode: thread.interactionMode, | ||
| 242 | error: thread.error, | ||
| 243 | createdAt: thread.createdAt, | ||
| 244 | archivedAt: thread.archivedAt, | ||
| 245 | updatedAt: thread.updatedAt, | ||
| 246 | branch: thread.branch, | ||
| 247 | worktreePath: thread.worktreePath, | ||
| 248 | }, | ||
| 249 | ]), | ||
| 250 | ), | ||
| 251 | threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), | ||
| 252 | threadTurnStateById: Object.fromEntries( | ||
| 253 | threads.map((thread) => [ | ||
| 254 | thread.id, | ||
| 255 | { | ||
| 256 | latestTurn: thread.latestTurn, | ||
| 257 | ...(thread.pendingSourceProposedPlan | ||
| 258 | ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } | ||
| 259 | : {}), | ||
| 260 | }, | ||
| 261 | ]), | ||
| 262 | ), | ||
| 263 | messageIdsByThreadId: Object.fromEntries( | ||
| 264 | threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), | ||
| 265 | ), | ||
| 266 | messageByThreadId: Object.fromEntries( | ||
| 267 | threads.map((thread) => [ | ||
| 268 | thread.id, | ||
| 269 | Object.fromEntries(thread.messages.map((message) => [message.id, message])), | ||
| 270 | ]), | ||
| 271 | ), | ||
| 272 | activityIdsByThreadId: Object.fromEntries( | ||
| 273 | threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), | ||
| 274 | ), | ||
| 275 | activityByThreadId: Object.fromEntries( | ||
| 276 | threads.map((thread) => [ | ||
| 277 | thread.id, | ||
| 278 | Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), | ||
| 279 | ]), | ||
| 280 | ), | ||
| 281 | proposedPlanIdsByThreadId: Object.fromEntries( | ||
| 282 | threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), | ||
| 283 | ), | ||
| 284 | proposedPlanByThreadId: Object.fromEntries( | ||
| 285 | threads.map((thread) => [ | ||
| 286 | thread.id, | ||
| 287 | Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), | ||
| 288 | ]), | ||
| 289 | ), | ||
| 290 | turnDiffIdsByThreadId: Object.fromEntries( | ||
| 291 | threads.map((thread) => [ | ||
| 292 | thread.id, | ||
| 293 | thread.turnDiffSummaries.map((summary) => summary.turnId), | ||
| 294 | ]), | ||
| 295 | ), | ||
| 296 | turnDiffSummaryByThreadId: Object.fromEntries( | ||
| 297 | threads.map((thread) => [ | ||
| 298 | thread.id, | ||
| 299 | Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), | ||
| 300 | ]), | ||
| 301 | ), | ||
| 302 | sidebarThreadSummaryById: {}, | ||
| 303 | bootstrapComplete: true, | ||
| 304 | }); | ||
| 305 | } | ||
| 306 | |||
| 208 | afterEach(() => { | 307 | afterEach(() => { |
| 209 | vi.useRealTimers(); | 308 | vi.useRealTimers(); |
| 210 | vi.restoreAllMocks(); | 309 | vi.restoreAllMocks(); |
| 211 | useStore.setState((state) => ({ | 310 | setStoreThreads([]); |
| 212 | ...state, | ||
| 213 | projects: [], | ||
| 214 | threads: [], | ||
| 215 | bootstrapComplete: true, | ||
| 216 | })); | ||
| 217 | }); | 311 | }); |
| 218 | 312 | ||
| 219 | describe("waitForStartedServerThread", () => { | 313 | describe("waitForStartedServerThread", () => { |
| 220 | it("resolves immediately when the thread is already started", async () => { | 314 | it("resolves immediately when the thread is already started", async () => { |
| 221 | const threadId = ThreadId.makeUnsafe("thread-started"); | 315 | const threadId = ThreadId.makeUnsafe("thread-started"); |
| 222 | useStore.setState((state) => ({ | 316 | setStoreThreads([ |
| 223 | ...state, | 317 | makeThread({ |
| 224 | threads: [ | 318 | id: threadId, |
| 225 | makeThread({ | 319 | latestTurn: { |
| 226 | id: threadId, | 320 | turnId: TurnId.makeUnsafe("turn-started"), |
| 227 | latestTurn: { | 321 | state: "running", |
| 228 | turnId: TurnId.makeUnsafe("turn-started"), | 322 | requestedAt: "2026-03-29T00:00:01.000Z", |
| 229 | state: "running", | 323 | startedAt: "2026-03-29T00:00:01.000Z", |
| 230 | requestedAt: "2026-03-29T00:00:01.000Z", | 324 | completedAt: null, |
| 231 | startedAt: "2026-03-29T00:00:01.000Z", | 325 | }, |
| 232 | completedAt: null, | 326 | }), |
| 233 | }, | 327 | ]); |
| 234 | }), | ||
| 235 | ], | ||
| 236 | })); | ||
| 237 | 328 | ||
| 238 | await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); | 329 | await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); |
| 239 | }); | 330 | }); |
| 240 | 331 | ||
| 241 | it("waits for the thread to start via subscription updates", async () => { | 332 | it("waits for the thread to start via subscription updates", async () => { |
| 242 | const threadId = ThreadId.makeUnsafe("thread-wait"); | 333 | const threadId = ThreadId.makeUnsafe("thread-wait"); |
| 243 | useStore.setState((state) => ({ | 334 | setStoreThreads([makeThread({ id: threadId })]); |
| 244 | ...state, | ||
| 245 | threads: [makeThread({ id: threadId })], | ||
| 246 | })); | ||
| 247 | 335 | ||
| 248 | const promise = waitForStartedServerThread(threadId, 500); | 336 | const promise = waitForStartedServerThread(threadId, 500); |
| 249 | 337 | ||
| 250 | useStore.setState((state) => ({ | 338 | setStoreThreads([ |
| 251 | ...state, | 339 | makeThread({ |
| 252 | threads: [ | 340 | id: threadId, |
| 253 | makeThread({ | 341 | latestTurn: { |
| 254 | id: threadId, | 342 | turnId: TurnId.makeUnsafe("turn-started"), |
| 255 | latestTurn: { | 343 | state: "running", |
| 256 | turnId: TurnId.makeUnsafe("turn-started"), | 344 | requestedAt: "2026-03-29T00:00:01.000Z", |
| 257 | state: "running", | 345 | startedAt: "2026-03-29T00:00:01.000Z", |
| 258 | requestedAt: "2026-03-29T00:00:01.000Z", | 346 | completedAt: null, |
| 259 | startedAt: "2026-03-29T00:00:01.000Z", | 347 | }, |
| 260 | completedAt: null, | 348 | }), |
| 261 | }, | 349 | ]); |
| 262 | }), | ||
| 263 | ], | ||
| 264 | })); | ||
| 265 | 350 | ||
| 266 | await expect(promise).resolves.toBe(true); | 351 | await expect(promise).resolves.toBe(true); |
| 267 | }); | 352 | }); |
| 268 | 353 | ||
| 269 | it("handles the thread starting between the initial read and subscription setup", async () => { | 354 | it("handles the thread starting between the initial read and subscription setup", async () => { |
| 270 | const threadId = ThreadId.makeUnsafe("thread-race"); | 355 | const threadId = ThreadId.makeUnsafe("thread-race"); |
| 271 | useStore.setState((state) => ({ | 356 | setStoreThreads([makeThread({ id: threadId })]); |
| 272 | ...state, | ||
| 273 | threads: [makeThread({ id: threadId })], | ||
| 274 | })); | ||
| 275 | 357 | ||
| 276 | const originalSubscribe = useStore.subscribe.bind(useStore); | 358 | const originalSubscribe = useStore.subscribe.bind(useStore); |
| 277 | let raced = false; | 359 | let raced = false; |
| 278 | vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { | 360 | vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { |
| 279 | if (!raced) { | 361 | if (!raced) { |
| 280 | raced = true; | 362 | raced = true; |
| 281 | useStore.setState((state) => ({ | 363 | setStoreThreads([ |
| 282 | ...state, | 364 | makeThread({ |
| 283 | threads: [ | 365 | id: threadId, |
| 284 | makeThread({ | 366 | latestTurn: { |
| 285 | id: threadId, | 367 | turnId: TurnId.makeUnsafe("turn-race"), |
| 286 | latestTurn: { | 368 | state: "running", |
| 287 | turnId: TurnId.makeUnsafe("turn-race"), | 369 | requestedAt: "2026-03-29T00:00:01.000Z", |
| 288 | state: "running", | 370 | startedAt: "2026-03-29T00:00:01.000Z", |
| 289 | requestedAt: "2026-03-29T00:00:01.000Z", | 371 | completedAt: null, |
| 290 | startedAt: "2026-03-29T00:00:01.000Z", | 372 | }, |
| 291 | completedAt: null, | 373 | }), |
| 292 | }, | 374 | ]); |
| 293 | }), | ||
| 294 | ], | ||
| 295 | })); | ||
| 296 | } | 375 | } |
| 297 | return originalSubscribe(listener); | 376 | return originalSubscribe(listener); |
| 298 | }); | 377 | }); |
| ... | @@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => { | ... | @@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => { |
| 304 | vi.useFakeTimers(); | 383 | vi.useFakeTimers(); |
| 305 | 384 | ||
| 306 | const threadId = ThreadId.makeUnsafe("thread-timeout"); | 385 | const threadId = ThreadId.makeUnsafe("thread-timeout"); |
| 307 | useStore.setState((state) => ({ | 386 | setStoreThreads([makeThread({ id: threadId })]); |
| 308 | ...state, | ||
| 309 | threads: [makeThread({ id: threadId })], | ||
| 310 | })); | ||
| 311 | const promise = waitForStartedServerThread(threadId, 500); | 387 | const promise = waitForStartedServerThread(threadId, 500); |
| 312 | 388 | ||
| 313 | await vi.advanceTimersByTimeAsync(500); | 389 | await vi.advanceTimersByTimeAsync(500); |
apps/web/src/components/ChatView.logic.ts+3-3| ... | @@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } | ... | @@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } |
| 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 { selectThreadById, useStore } from "../store"; |
| 7 | import { | 7 | import { |
| 8 | filterTerminalContextsWithText, | 8 | filterTerminalContextsWithText, |
| 9 | stripInlineTerminalContextPlaceholders, | 9 | stripInlineTerminalContextPlaceholders, |
| ... | @@ -202,7 +202,7 @@ export async function waitForStartedServerThread( | ... | @@ -202,7 +202,7 @@ export async function waitForStartedServerThread( |
| 202 | threadId: ThreadId, | 202 | threadId: ThreadId, |
| 203 | timeoutMs = 1_000, | 203 | timeoutMs = 1_000, |
| 204 | ): Promise<boolean> { | 204 | ): Promise<boolean> { |
| 205 | const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); | 205 | const getThread = () => selectThreadById(threadId)(useStore.getState()); |
| 206 | const thread = getThread(); | 206 | const thread = getThread(); |
| 207 | 207 | ||
| 208 | if (threadHasStarted(thread)) { | 208 | if (threadHasStarted(thread)) { |
| ... | @@ -225,7 +225,7 @@ export async function waitForStartedServerThread( | ... | @@ -225,7 +225,7 @@ export async function waitForStartedServerThread( |
| 225 | }; | 225 | }; |
| 226 | 226 | ||
| 227 | const unsubscribe = useStore.subscribe((state) => { | 227 | const unsubscribe = useStore.subscribe((state) => { |
| 228 | if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { | 228 | if (!threadHasStarted(selectThreadById(threadId)(state))) { |
| 229 | return; | 229 | return; |
| 230 | } | 230 | } |
| 231 | finish(true); | 231 | finish(true); |
apps/web/src/components/ChatView.tsx+36-78| ... | @@ -65,7 +65,7 @@ import { | ... | @@ -65,7 +65,7 @@ import { |
| 65 | type PendingUserInputDraftAnswer, | 65 | type PendingUserInputDraftAnswer, |
| 66 | } from "../pendingUserInput"; | 66 | } from "../pendingUserInput"; |
| 67 | import { useStore } from "../store"; | 67 | import { useStore } from "../store"; |
| 68 | import { useProjectById, useThreadById } from "../storeSelectors"; | 68 | import { createThreadSelector } from "../storeSelectors"; |
| 69 | import { useUiStateStore } from "../uiStateStore"; | 69 | import { useUiStateStore } from "../uiStateStore"; |
| 70 | import { | 70 | import { |
| 71 | buildPlanImplementationThreadTitle, | 71 | buildPlanImplementationThreadTitle, |
| ... | @@ -83,8 +83,6 @@ import { | ... | @@ -83,8 +83,6 @@ import { |
| 83 | type Thread, | 83 | type Thread, |
| 84 | type TurnDiffSummary, | 84 | type TurnDiffSummary, |
| 85 | } from "../types"; | 85 | } from "../types"; |
| 86 | import { LRUCache } from "../lib/lruCache"; | ||
| 87 | |||
| 88 | import { basenameOfPath } from "../vscode-icons"; | 86 | import { basenameOfPath } from "../vscode-icons"; |
| 89 | import { useTheme } from "../hooks/useTheme"; | 87 | import { useTheme } from "../hooks/useTheme"; |
| 90 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 88 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | @@ -211,77 +209,28 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw | ... | @@ -211,77 +209,28 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw |
| 211 | 209 | ||
| 212 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | 210 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| 213 | 211 | ||
| 214 | const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; | ||
| 215 | const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; | ||
| 216 | const threadPlanCatalogCache = new LRUCache<{ | ||
| 217 | proposedPlans: Thread["proposedPlans"]; | ||
| 218 | entry: ThreadPlanCatalogEntry; | ||
| 219 | }>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); | ||
| 220 | |||
| 221 | function estimateThreadPlanCatalogEntrySize(thread: Thread): number { | ||
| 222 | return Math.max( | ||
| 223 | 64, | ||
| 224 | thread.id.length + | ||
| 225 | thread.proposedPlans.reduce( | ||
| 226 | (total, plan) => | ||
| 227 | total + | ||
| 228 | plan.id.length + | ||
| 229 | plan.planMarkdown.length + | ||
| 230 | plan.updatedAt.length + | ||
| 231 | (plan.turnId?.length ?? 0), | ||
| 232 | 0, | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | } | ||
| 236 | |||
| 237 | function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { | ||
| 238 | const cached = threadPlanCatalogCache.get(thread.id); | ||
| 239 | if (cached && cached.proposedPlans === thread.proposedPlans) { | ||
| 240 | return cached.entry; | ||
| 241 | } | ||
| 242 | |||
| 243 | const entry: ThreadPlanCatalogEntry = { | ||
| 244 | id: thread.id, | ||
| 245 | proposedPlans: thread.proposedPlans, | ||
| 246 | }; | ||
| 247 | threadPlanCatalogCache.set( | ||
| 248 | thread.id, | ||
| 249 | { | ||
| 250 | proposedPlans: thread.proposedPlans, | ||
| 251 | entry, | ||
| 252 | }, | ||
| 253 | estimateThreadPlanCatalogEntrySize(thread), | ||
| 254 | ); | ||
| 255 | return entry; | ||
| 256 | } | ||
| 257 | |||
| 258 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { | 212 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { |
| 259 | const selector = useMemo(() => { | 213 | const threadShellById = useStore((state) => state.threadShellById); |
| 260 | let previousThreads: Array<Thread | undefined> | null = null; | 214 | const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); |
| 261 | let previousEntries: ThreadPlanCatalogEntry[] = []; | 215 | const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); |
| 262 | 216 | ||
| 263 | return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { | 217 | return useMemo( |
| 264 | const nextThreads = threadIds.map((threadId) => | 218 | () => |
| 265 | state.threads.find((thread) => thread.id === threadId), | 219 | threadIds.flatMap((threadId) => { |
| 266 | ); | 220 | if (!threadShellById[threadId]) { |
| 267 | const cachedThreads = previousThreads; | 221 | return []; |
| 268 | if ( | 222 | } |
| 269 | cachedThreads && | ||
| 270 | nextThreads.length === cachedThreads.length && | ||
| 271 | nextThreads.every((thread, index) => thread === cachedThreads[index]) | ||
| 272 | ) { | ||
| 273 | return previousEntries; | ||
| 274 | } | ||
| 275 | 223 | ||
| 276 | previousThreads = nextThreads; | 224 | const proposedPlans = |
| 277 | previousEntries = nextThreads.flatMap((thread) => | 225 | proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { |
| 278 | thread ? [toThreadPlanCatalogEntry(thread)] : [], | 226 | const plan = proposedPlanByThreadId[threadId]?.[planId]; |
| 279 | ); | 227 | return plan ? [plan] : []; |
| 280 | return previousEntries; | 228 | }) ?? []; |
| 281 | }; | ||
| 282 | }, [threadIds]); | ||
| 283 | 229 | ||
| 284 | return useStore(selector); | 230 | return [{ id: threadId, proposedPlans }]; |
| 231 | }), | ||
| 232 | [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], | ||
| 233 | ); | ||
| 285 | } | 234 | } |
| 286 | 235 | ||
| 287 | function formatOutgoingPrompt(params: { | 236 | function formatOutgoingPrompt(params: { |
| ... | @@ -429,11 +378,17 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -429,11 +378,17 @@ function PersistentThreadTerminalDrawer({ |
| 429 | closeShortcutLabel, | 378 | closeShortcutLabel, |
| 430 | onAddTerminalContext, | 379 | onAddTerminalContext, |
| 431 | }: PersistentThreadTerminalDrawerProps) { | 380 | }: PersistentThreadTerminalDrawerProps) { |
| 432 | const serverThread = useThreadById(threadId); | 381 | const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); |
| 433 | const draftThread = useComposerDraftStore( | 382 | const draftThread = useComposerDraftStore( |
| 434 | (store) => store.draftThreadsByThreadId[threadId] ?? null, | 383 | (store) => store.draftThreadsByThreadId[threadId] ?? null, |
| 435 | ); | 384 | ); |
| 436 | const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); | 385 | const project = useStore((state) => |
| 386 | serverThread?.projectId | ||
| 387 | ? state.projectById[serverThread.projectId] | ||
| 388 | : draftThread?.projectId | ||
| 389 | ? state.projectById[draftThread.projectId] | ||
| 390 | : undefined, | ||
| 391 | ); | ||
| 437 | const terminalState = useTerminalStateStore((state) => | 392 | const terminalState = useTerminalStateStore((state) => |
| 438 | selectThreadTerminalState(state.terminalStateByThreadId, threadId), | 393 | selectThreadTerminalState(state.terminalStateByThreadId, threadId), |
| 439 | ); | 394 | ); |
| ... | @@ -576,7 +531,7 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -576,7 +531,7 @@ function PersistentThreadTerminalDrawer({ |
| 576 | } | 531 | } |
| 577 | 532 | ||
| 578 | export default function ChatView({ threadId }: ChatViewProps) { | 533 | export default function ChatView({ threadId }: ChatViewProps) { |
| 579 | const serverThread = useThreadById(threadId); | 534 | const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); |
| 580 | const setStoreThreadError = useStore((store) => store.setError); | 535 | const setStoreThreadError = useStore((store) => store.setError); |
| 581 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); | 536 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 582 | const activeThreadLastVisitedAt = useUiStateStore( | 537 | const activeThreadLastVisitedAt = useUiStateStore( |
| ... | @@ -751,14 +706,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -751,14 +706,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 751 | const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); | 706 | const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); |
| 752 | const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); | 707 | const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); |
| 753 | const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); | 708 | const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); |
| 709 | const serverThreadIds = useStore((state) => state.threadIds); | ||
| 754 | const storeServerTerminalLaunchContext = useTerminalStateStore( | 710 | const storeServerTerminalLaunchContext = useTerminalStateStore( |
| 755 | (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, | 711 | (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, |
| 756 | ); | 712 | ); |
| 757 | const storeClearTerminalLaunchContext = useTerminalStateStore( | 713 | const storeClearTerminalLaunchContext = useTerminalStateStore( |
| 758 | (s) => s.clearTerminalLaunchContext, | 714 | (s) => s.clearTerminalLaunchContext, |
| 759 | ); | 715 | ); |
| 760 | const threads = useStore((state) => state.threads); | ||
| 761 | const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); | ||
| 762 | const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); | 716 | const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); |
| 763 | const draftThreadIds = useMemo( | 717 | const draftThreadIds = useMemo( |
| 764 | () => Object.keys(draftThreadsByThreadId) as ThreadId[], | 718 | () => Object.keys(draftThreadsByThreadId) as ThreadId[], |
| ... | @@ -819,7 +773,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -819,7 +773,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 819 | [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], | 773 | [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], |
| 820 | ); | 774 | ); |
| 821 | 775 | ||
| 822 | const fallbackDraftProject = useProjectById(draftThread?.projectId); | 776 | const fallbackDraftProject = useStore((state) => |
| 777 | draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, | ||
| 778 | ); | ||
| 823 | const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); | 779 | const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); |
| 824 | const localDraftThread = useMemo( | 780 | const localDraftThread = useMemo( |
| 825 | () => | 781 | () => |
| ... | @@ -884,7 +840,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -884,7 +840,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 884 | }); | 840 | }); |
| 885 | }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); | 841 | }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); |
| 886 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 842 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 887 | const activeProject = useProjectById(activeThread?.projectId); | 843 | const activeProject = useStore((state) => |
| 844 | activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, | ||
| 845 | ); | ||
| 888 | 846 | ||
| 889 | const openPullRequestDialog = useCallback( | 847 | const openPullRequestDialog = useCallback( |
| 890 | (reference?: string) => { | 848 | (reference?: string) => { |
| ... | @@ -1603,7 +1561,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1603,7 +1561,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1603 | (targetThreadId: ThreadId | null, error: string | null) => { | 1561 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 1604 | if (!targetThreadId) return; | 1562 | if (!targetThreadId) return; |
| 1605 | const nextError = sanitizeThreadErrorMessage(error); | 1563 | const nextError = sanitizeThreadErrorMessage(error); |
| 1606 | if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { | 1564 | if (useStore.getState().threadShellById[targetThreadId] !== undefined) { |
| 1607 | setStoreThreadError(targetThreadId, nextError); | 1565 | setStoreThreadError(targetThreadId, nextError); |
| 1608 | return; | 1566 | return; |
| 1609 | } | 1567 | } |
apps/web/src/components/DiffPanel.tsx+4-3| ... | @@ -30,6 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; | ... | @@ -30,6 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 30 | import { resolveDiffThemeName } from "../lib/diffRendering"; | 30 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 31 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 31 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 32 | import { useStore } from "../store"; | 32 | import { useStore } from "../store"; |
| 33 | import { createThreadSelector } from "../storeSelectors"; | ||
| 33 | import { useSettings } from "../hooks/useSettings"; | 34 | import { useSettings } from "../hooks/useSettings"; |
| 34 | import { formatShortTimestamp } from "../timestampFormat"; | 35 | import { formatShortTimestamp } from "../timestampFormat"; |
| 35 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; | 36 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| ... | @@ -181,12 +182,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -181,12 +182,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 181 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | 182 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 182 | const diffOpen = diffSearch.diff === "1"; | 183 | const diffOpen = diffSearch.diff === "1"; |
| 183 | const activeThreadId = routeThreadId; | 184 | const activeThreadId = routeThreadId; |
| 184 | const activeThread = useStore((store) => | 185 | const activeThread = useStore( |
| 185 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, | 186 | useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), |
| 186 | ); | 187 | ); |
| 187 | const activeProjectId = activeThread?.projectId ?? null; | 188 | const activeProjectId = activeThread?.projectId ?? null; |
| 188 | const activeProject = useStore((store) => | 189 | const activeProject = useStore((store) => |
| 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, | 190 | activeProjectId ? store.projectById[activeProjectId] : undefined, |
| 190 | ); | 191 | ); |
| 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 192 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 192 | const gitStatusQuery = useGitStatus(activeCwd ?? null); | 193 | const gitStatusQuery = useGitStatus(activeCwd ?? null); |
apps/web/src/components/GitActionsControl.browser.tsx+4-4| ... | @@ -141,10 +141,10 @@ vi.mock("~/store", () => ({ | ... | @@ -141,10 +141,10 @@ vi.mock("~/store", () => ({ |
| 141 | useStore: (selector: (state: unknown) => unknown) => | 141 | useStore: (selector: (state: unknown) => unknown) => |
| 142 | selector({ | 142 | selector({ |
| 143 | setThreadBranch: setThreadBranchSpy, | 143 | setThreadBranch: setThreadBranchSpy, |
| 144 | threads: [ | 144 | threadShellById: { |
| 145 | { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, | 145 | [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, |
| 146 | { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, | 146 | [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, |
| 147 | ], | 147 | }, |
| 148 | }), | 148 | }), |
| 149 | })); | 149 | })); |
| 150 | 150 |
apps/web/src/components/GitActionsControl.tsx+1-1| ... | @@ -212,7 +212,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -212,7 +212,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 212 | [activeThreadId], | 212 | [activeThreadId], |
| 213 | ); | 213 | ); |
| 214 | const activeServerThread = useStore((store) => | 214 | const activeServerThread = useStore((store) => |
| 215 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, | 215 | activeThreadId ? store.threadShellById[activeThreadId] : undefined, |
| 216 | ); | 216 | ); |
| 217 | const setThreadBranch = useStore((store) => store.setThreadBranch); | 217 | const setThreadBranch = useStore((store) => store.setThreadBranch); |
| 218 | const queryClient = useQueryClient(); | 218 | const queryClient = useQueryClient(); |
apps/web/src/components/KeybindingsToast.browser.tsx+16-2| ... | @@ -356,8 +356,22 @@ describe("Keybindings update toast", () => { | ... | @@ -356,8 +356,22 @@ describe("Keybindings update toast", () => { |
| 356 | projectDraftThreadIdByProjectId: {}, | 356 | projectDraftThreadIdByProjectId: {}, |
| 357 | }); | 357 | }); |
| 358 | useStore.setState({ | 358 | useStore.setState({ |
| 359 | projects: [], | 359 | projectIds: [], |
| 360 | threads: [], | 360 | projectById: {}, |
| 361 | threadIds: [], | ||
| 362 | threadIdsByProjectId: {}, | ||
| 363 | threadShellById: {}, | ||
| 364 | threadSessionById: {}, | ||
| 365 | threadTurnStateById: {}, | ||
| 366 | messageIdsByThreadId: {}, | ||
| 367 | messageByThreadId: {}, | ||
| 368 | activityIdsByThreadId: {}, | ||
| 369 | activityByThreadId: {}, | ||
| 370 | proposedPlanIdsByThreadId: {}, | ||
| 371 | proposedPlanByThreadId: {}, | ||
| 372 | turnDiffIdsByThreadId: {}, | ||
| 373 | turnDiffSummaryByThreadId: {}, | ||
| 374 | sidebarThreadSummaryById: {}, | ||
| 361 | bootstrapComplete: false, | 375 | bootstrapComplete: false, |
| 362 | }); | 376 | }); |
| 363 | }); | 377 | }); |
apps/web/src/components/Sidebar.tsx+12-4| ... | @@ -128,7 +128,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | ... | @@ -128,7 +128,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 128 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 128 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 129 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 129 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 130 | import { useServerKeybindings } from "../rpc/serverState"; | 130 | import { useServerKeybindings } from "../rpc/serverState"; |
| 131 | import { useSidebarThreadSummaryById } from "../storeSelectors"; | ||
| 132 | import type { Project } from "../types"; | 131 | import type { Project } from "../types"; |
| 133 | const THREAD_PREVIEW_LIMIT = 6; | 132 | const THREAD_PREVIEW_LIMIT = 6; |
| 134 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { | 133 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| ... | @@ -292,7 +291,7 @@ interface SidebarThreadRowProps { | ... | @@ -292,7 +291,7 @@ interface SidebarThreadRowProps { |
| 292 | } | 291 | } |
| 293 | 292 | ||
| 294 | function SidebarThreadRow(props: SidebarThreadRowProps) { | 293 | function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 295 | const thread = useSidebarThreadSummaryById(props.threadId); | 294 | const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); |
| 296 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); | 295 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); |
| 297 | const runningTerminalIds = useTerminalStateStore( | 296 | const runningTerminalIds = useTerminalStateStore( |
| 298 | (state) => | 297 | (state) => |
| ... | @@ -674,8 +673,9 @@ function SortableProjectItem({ | ... | @@ -674,8 +673,9 @@ function SortableProjectItem({ |
| 674 | } | 673 | } |
| 675 | 674 | ||
| 676 | export default function Sidebar() { | 675 | export default function Sidebar() { |
| 677 | const projects = useStore((store) => store.projects); | 676 | const projectIds = useStore((store) => store.projectIds); |
| 678 | const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); | 677 | const projectById = useStore((store) => store.projectById); |
| 678 | const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); | ||
| 679 | const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); | 679 | const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); |
| 680 | const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( | 680 | const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( |
| 681 | useShallow((store) => ({ | 681 | useShallow((store) => ({ |
| ... | @@ -741,6 +741,14 @@ export default function Sidebar() { | ... | @@ -741,6 +741,14 @@ export default function Sidebar() { |
| 741 | const platform = navigator.platform; | 741 | const platform = navigator.platform; |
| 742 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; | 742 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; |
| 743 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | 743 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 744 | const projects = useMemo( | ||
| 745 | () => | ||
| 746 | projectIds.flatMap((projectId) => { | ||
| 747 | const project = projectById[projectId]; | ||
| 748 | return project ? [project] : []; | ||
| 749 | }), | ||
| 750 | [projectById, projectIds], | ||
| 751 | ); | ||
| 744 | const orderedProjects = useMemo(() => { | 752 | const orderedProjects = useMemo(() => { |
| 745 | return orderItemsByPreferredIds({ | 753 | return orderItemsByPreferredIds({ |
| 746 | items: projects, | 754 | items: projects, |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1| ... | @@ -95,7 +95,7 @@ describe("MessagesTimeline", () => { | ... | @@ -95,7 +95,7 @@ describe("MessagesTimeline", () => { |
| 95 | expect(markup).toContain("Terminal 1 lines 1-5"); | 95 | expect(markup).toContain("Terminal 1 lines 1-5"); |
| 96 | expect(markup).toContain("lucide-terminal"); | 96 | expect(markup).toContain("lucide-terminal"); |
| 97 | expect(markup).toContain("yoo what&#x27;s "); | 97 | expect(markup).toContain("yoo what&#x27;s "); |
| 98 | }); | 98 | }, 10_000); |
| 99 | 99 | ||
| 100 | it("renders context compaction entries in the normal work log", async () => { | 100 | it("renders context compaction entries in the normal work log", async () => { |
| 101 | const { MessagesTimeline } = await import("./MessagesTimeline"); | 101 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
apps/web/src/components/settings/SettingsPanels.tsx+14-5| ... | @@ -1479,12 +1479,21 @@ export function GeneralSettingsPanel() { | ... | @@ -1479,12 +1479,21 @@ export function GeneralSettingsPanel() { |
| 1479 | } | 1479 | } |
| 1480 | 1480 | ||
| 1481 | export function ArchivedThreadsPanel() { | 1481 | export function ArchivedThreadsPanel() { |
| 1482 | const projects = useStore((store) => store.projects); | 1482 | const projectIds = useStore((store) => store.projectIds); |
| 1483 | const threads = useStore((store) => store.threads); | 1483 | const projectById = useStore((store) => store.projectById); |
| 1484 | const threadIds = useStore((store) => store.threadIds); | ||
| 1485 | const threadShellById = useStore((store) => store.threadShellById); | ||
| 1484 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); | 1486 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 1485 | const archivedGroups = useMemo(() => { | 1487 | const archivedGroups = useMemo(() => { |
| 1486 | const projectById = new Map(projects.map((project) => [project.id, project] as const)); | 1488 | const projects = projectIds.flatMap((projectId) => { |
| 1487 | return [...projectById.values()] | 1489 | const project = projectById[projectId]; |
| 1490 | return project ? [project] : []; | ||
| 1491 | }); | ||
| 1492 | const threads = threadIds.flatMap((threadId) => { | ||
| 1493 | const thread = threadShellById[threadId]; | ||
| 1494 | return thread ? [thread] : []; | ||
| 1495 | }); | ||
| 1496 | return projects | ||
| 1488 | .map((project) => ({ | 1497 | .map((project) => ({ |
| 1489 | project, | 1498 | project, |
| 1490 | threads: threads | 1499 | threads: threads |
| ... | @@ -1496,7 +1505,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -1496,7 +1505,7 @@ export function ArchivedThreadsPanel() { |
| 1496 | }), | 1505 | }), |
| 1497 | })) | 1506 | })) |
| 1498 | .filter((group) => group.threads.length > 0); | 1507 | .filter((group) => group.threads.length > 0); |
| 1499 | }, [projects, threads]); | 1508 | }, [projectById, projectIds, threadIds, threadShellById]); |
| 1500 | 1509 | ||
| 1501 | const handleArchivedThreadContextMenu = useCallback( | 1510 | const handleArchivedThreadContextMenu = useCallback( |
| 1502 | async (threadId: ThreadId, position: { x: number; y: number }) => { | 1511 | async (threadId: ThreadId, position: { x: number; y: number }) => { |
apps/web/src/hooks/useHandleNewThread.ts+5-3| ... | @@ -10,18 +10,20 @@ import { | ... | @@ -10,18 +10,20 @@ import { |
| 10 | import { newThreadId } from "../lib/utils"; | 10 | import { newThreadId } from "../lib/utils"; |
| 11 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; | 11 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; |
| 12 | import { useStore } from "../store"; | 12 | import { useStore } from "../store"; |
| 13 | import { useThreadById } from "../storeSelectors"; | 13 | import { createThreadSelector } from "../storeSelectors"; |
| 14 | import { useUiStateStore } from "../uiStateStore"; | 14 | import { useUiStateStore } from "../uiStateStore"; |
| 15 | 15 | ||
| 16 | export function useHandleNewThread() { | 16 | export function useHandleNewThread() { |
| 17 | const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id))); | 17 | const projectIds = useStore(useShallow((store) => store.projectIds)); |
| 18 | const projectOrder = useUiStateStore((store) => store.projectOrder); | 18 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 19 | const navigate = useNavigate(); | 19 | const navigate = useNavigate(); |
| 20 | const routeThreadId = useParams({ | 20 | const routeThreadId = useParams({ |
| 21 | strict: false, | 21 | strict: false, |
| 22 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | 22 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| 23 | }); | 23 | }); |
| 24 | const activeThread = useThreadById(routeThreadId); | 24 | const activeThread = useStore( |
| 25 | useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), | ||
| 26 | ); | ||
| 25 | const activeDraftThread = useComposerDraftStore((store) => | 27 | const activeDraftThread = useComposerDraftStore((store) => |
| 26 | routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, | 28 | routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, |
| 27 | ); | 29 | ); |
apps/web/src/hooks/useThreadActions.ts+7-6| ... | @@ -9,7 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread"; | ... | @@ -9,7 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread"; |
| 9 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 9 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; |
| 10 | import { newCommandId } from "../lib/utils"; | 10 | import { newCommandId } from "../lib/utils"; |
| 11 | import { readNativeApi } from "../nativeApi"; | 11 | import { readNativeApi } from "../nativeApi"; |
| 12 | import { useStore } from "../store"; | 12 | import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; |
| 13 | import { useTerminalStateStore } from "../terminalStateStore"; | 13 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 14 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 14 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 15 | import { toastManager } from "../components/ui/toast"; | 15 | import { toastManager } from "../components/ui/toast"; |
| ... | @@ -35,7 +35,7 @@ export function useThreadActions() { | ... | @@ -35,7 +35,7 @@ export function useThreadActions() { |
| 35 | async (threadId: ThreadId) => { | 35 | async (threadId: ThreadId) => { |
| 36 | const api = readNativeApi(); | 36 | const api = readNativeApi(); |
| 37 | if (!api) return; | 37 | if (!api) return; |
| 38 | const thread = useStore.getState().threads.find((entry) => entry.id === threadId); | 38 | const thread = selectThreadById(threadId)(useStore.getState()); |
| 39 | if (!thread) return; | 39 | if (!thread) return; |
| 40 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { | 40 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { |
| 41 | throw new Error("Cannot archive a running thread."); | 41 | throw new Error("Cannot archive a running thread."); |
| ... | @@ -68,10 +68,11 @@ export function useThreadActions() { | ... | @@ -68,10 +68,11 @@ export function useThreadActions() { |
| 68 | async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => { | 68 | async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => { |
| 69 | const api = readNativeApi(); | 69 | const api = readNativeApi(); |
| 70 | if (!api) return; | 70 | if (!api) return; |
| 71 | const { projects, threads } = useStore.getState(); | 71 | const state = useStore.getState(); |
| 72 | const thread = threads.find((entry) => entry.id === threadId); | 72 | const threads = selectThreads(state); |
| 73 | const thread = selectThreadById(threadId)(state); | ||
| 73 | if (!thread) return; | 74 | if (!thread) return; |
| 74 | const threadProject = projects.find((project) => project.id === thread.projectId); | 75 | const threadProject = selectProjectById(thread.projectId)(state); |
| 75 | const deletedIds = opts.deletedThreadIds; | 76 | const deletedIds = opts.deletedThreadIds; |
| 76 | const survivingThreads = | 77 | const survivingThreads = |
| 77 | deletedIds && deletedIds.size > 0 | 78 | deletedIds && deletedIds.size > 0 |
| ... | @@ -179,7 +180,7 @@ export function useThreadActions() { | ... | @@ -179,7 +180,7 @@ export function useThreadActions() { |
| 179 | async (threadId: ThreadId) => { | 180 | async (threadId: ThreadId) => { |
| 180 | const api = readNativeApi(); | 181 | const api = readNativeApi(); |
| 181 | if (!api) return; | 182 | if (!api) return; |
| 182 | const thread = useStore.getState().threads.find((entry) => entry.id === threadId); | 183 | const thread = selectThreadById(threadId)(useStore.getState()); |
| 183 | if (!thread) return; | 184 | if (!thread) return; |
| 184 | 185 | ||
| 185 | if (appSettings.confirmThreadDelete) { | 186 | if (appSettings.confirmThreadDelete) { |
apps/web/src/routes/__root.tsx+8-7| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | OrchestrationEvent, | 2 | OrchestrationEvent, |
| 3 | type ServerLifecycleWelcomePayload, | 3 | type ServerLifecycleWelcomePayload, |
| 4 | type ThreadId, | 4 | ThreadId, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { | 6 | import { |
| 7 | Outlet, | 7 | Outlet, |
| ... | @@ -38,7 +38,7 @@ import { | ... | @@ -38,7 +38,7 @@ import { |
| 38 | clearPromotedDraftThreads, | 38 | clearPromotedDraftThreads, |
| 39 | useComposerDraftStore, | 39 | useComposerDraftStore, |
| 40 | } from "../composerDraftStore"; | 40 | } from "../composerDraftStore"; |
| 41 | import { useStore } from "../store"; | 41 | import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; |
| 42 | import { useUiStateStore } from "../uiStateStore"; | 42 | import { useUiStateStore } from "../uiStateStore"; |
| 43 | import { useTerminalStateStore } from "../terminalStateStore"; | 43 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 44 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; | 44 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| ... | @@ -328,8 +328,9 @@ function EventRouter() { | ... | @@ -328,8 +328,9 @@ function EventRouter() { |
| 328 | let flushPendingDomainEventsScheduled = false; | 328 | let flushPendingDomainEventsScheduled = false; |
| 329 | 329 | ||
| 330 | const reconcileSnapshotDerivedState = () => { | 330 | const reconcileSnapshotDerivedState = () => { |
| 331 | const threads = useStore.getState().threads; | 331 | const storeState = useStore.getState(); |
| 332 | const projects = useStore.getState().projects; | 332 | const threads = selectThreads(storeState); |
| 333 | const projects = selectProjects(storeState); | ||
| 333 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | 334 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); |
| 334 | syncThreads( | 335 | syncThreads( |
| 335 | threads.map((thread) => ({ | 336 | threads.map((thread) => ({ |
| ... | @@ -392,14 +393,14 @@ function EventRouter() { | ... | @@ -392,14 +393,14 @@ function EventRouter() { |
| 392 | 393 | ||
| 393 | applyOrchestrationEvents(uiEvents); | 394 | applyOrchestrationEvents(uiEvents); |
| 394 | if (needsProjectUiSync) { | 395 | if (needsProjectUiSync) { |
| 395 | const projects = useStore.getState().projects; | 396 | const projects = selectProjects(useStore.getState()); |
| 396 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | 397 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); |
| 397 | } | 398 | } |
| 398 | const needsThreadUiSync = nextEvents.some( | 399 | const needsThreadUiSync = nextEvents.some( |
| 399 | (event) => event.type === "thread.created" || event.type === "thread.deleted", | 400 | (event) => event.type === "thread.created" || event.type === "thread.deleted", |
| 400 | ); | 401 | ); |
| 401 | if (needsThreadUiSync) { | 402 | if (needsThreadUiSync) { |
| 402 | const threads = useStore.getState().threads; | 403 | const threads = selectThreads(useStore.getState()); |
| 403 | syncThreads( | 404 | syncThreads( |
| 404 | threads.map((thread) => ({ | 405 | threads.map((thread) => ({ |
| 405 | id: thread.id, | 406 | id: thread.id, |
| ... | @@ -555,7 +556,7 @@ function EventRouter() { | ... | @@ -555,7 +556,7 @@ function EventRouter() { |
| 555 | }, | 556 | }, |
| 556 | ); | 557 | ); |
| 557 | const unsubTerminalEvent = api.terminal.onEvent((event) => { | 558 | const unsubTerminalEvent = api.terminal.onEvent((event) => { |
| 558 | const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId); | 559 | const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); |
| 559 | if (thread && thread.archivedAt !== null) { | 560 | if (thread && thread.archivedAt !== null) { |
| 560 | return; | 561 | return; |
| 561 | } | 562 | } |
apps/web/src/routes/_chat.$threadId.tsx+1-1| ... | @@ -167,7 +167,7 @@ function ChatThreadRouteView() { | ... | @@ -167,7 +167,7 @@ function ChatThreadRouteView() { |
| 167 | select: (params) => ThreadId.makeUnsafe(params.threadId), | 167 | select: (params) => ThreadId.makeUnsafe(params.threadId), |
| 168 | }); | 168 | }); |
| 169 | const search = Route.useSearch(); | 169 | const search = Route.useSearch(); |
| 170 | const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); | 170 | const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); |
| 171 | const draftThreadExists = useComposerDraftStore((store) => | 171 | const draftThreadExists = useComposerDraftStore((store) => |
| 172 | Object.hasOwn(store.draftThreadsByThreadId, threadId), | 172 | Object.hasOwn(store.draftThreadsByThreadId, threadId), |
| 173 | ); | 173 | ); |
apps/web/src/store.test.ts+244-74| ... | @@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest"; | ... | @@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest"; |
| 14 | import { | 14 | import { |
| 15 | applyOrchestrationEvent, | 15 | applyOrchestrationEvent, |
| 16 | applyOrchestrationEvents, | 16 | applyOrchestrationEvents, |
| 17 | selectProjects, | ||
| 18 | selectThreads, | ||
| 17 | syncServerReadModel, | 19 | syncServerReadModel, |
| 18 | type AppState, | 20 | type AppState, |
| 19 | } from "./store"; | 21 | } from "./store"; |
| ... | @@ -47,29 +49,125 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -47,29 +49,125 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 47 | } | 49 | } |
| 48 | 50 | ||
| 49 | function makeState(thread: Thread): AppState { | 51 | function makeState(thread: Thread): AppState { |
| 52 | const projectId = ProjectId.makeUnsafe("project-1"); | ||
| 53 | const project = { | ||
| 54 | id: projectId, | ||
| 55 | name: "Project", | ||
| 56 | cwd: "/tmp/project", | ||
| 57 | defaultModelSelection: { | ||
| 58 | provider: "codex" as const, | ||
| 59 | model: "gpt-5-codex", | ||
| 60 | }, | ||
| 61 | createdAt: "2026-02-13T00:00:00.000Z", | ||
| 62 | updatedAt: "2026-02-13T00:00:00.000Z", | ||
| 63 | scripts: [], | ||
| 64 | }; | ||
| 50 | const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { | 65 | const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { |
| 51 | [thread.projectId]: [thread.id], | 66 | [thread.projectId]: [thread.id], |
| 52 | }; | 67 | }; |
| 53 | return { | 68 | return { |
| 54 | projects: [ | 69 | projectIds: [projectId], |
| 55 | { | 70 | projectById: { |
| 56 | id: ProjectId.makeUnsafe("project-1"), | 71 | [projectId]: project, |
| 57 | name: "Project", | 72 | }, |
| 58 | cwd: "/tmp/project", | 73 | threadIds: [thread.id], |
| 59 | defaultModelSelection: { | ||
| 60 | provider: "codex", | ||
| 61 | model: "gpt-5-codex", | ||
| 62 | }, | ||
| 63 | scripts: [], | ||
| 64 | }, | ||
| 65 | ], | ||
| 66 | threads: [thread], | ||
| 67 | sidebarThreadsById: {}, | ||
| 68 | threadIdsByProjectId, | 74 | threadIdsByProjectId, |
| 75 | threadShellById: { | ||
| 76 | [thread.id]: { | ||
| 77 | id: thread.id, | ||
| 78 | codexThreadId: thread.codexThreadId, | ||
| 79 | projectId: thread.projectId, | ||
| 80 | title: thread.title, | ||
| 81 | modelSelection: thread.modelSelection, | ||
| 82 | runtimeMode: thread.runtimeMode, | ||
| 83 | interactionMode: thread.interactionMode, | ||
| 84 | error: thread.error, | ||
| 85 | createdAt: thread.createdAt, | ||
| 86 | archivedAt: thread.archivedAt, | ||
| 87 | updatedAt: thread.updatedAt, | ||
| 88 | branch: thread.branch, | ||
| 89 | worktreePath: thread.worktreePath, | ||
| 90 | }, | ||
| 91 | }, | ||
| 92 | threadSessionById: { | ||
| 93 | [thread.id]: thread.session, | ||
| 94 | }, | ||
| 95 | threadTurnStateById: { | ||
| 96 | [thread.id]: { | ||
| 97 | latestTurn: thread.latestTurn, | ||
| 98 | ...(thread.pendingSourceProposedPlan | ||
| 99 | ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } | ||
| 100 | : {}), | ||
| 101 | }, | ||
| 102 | }, | ||
| 103 | messageIdsByThreadId: { | ||
| 104 | [thread.id]: thread.messages.map((message) => message.id), | ||
| 105 | }, | ||
| 106 | messageByThreadId: { | ||
| 107 | [thread.id]: Object.fromEntries( | ||
| 108 | thread.messages.map((message) => [message.id, message] as const), | ||
| 109 | ) as AppState["messageByThreadId"][ThreadId], | ||
| 110 | }, | ||
| 111 | activityIdsByThreadId: { | ||
| 112 | [thread.id]: thread.activities.map((activity) => activity.id), | ||
| 113 | }, | ||
| 114 | activityByThreadId: { | ||
| 115 | [thread.id]: Object.fromEntries( | ||
| 116 | thread.activities.map((activity) => [activity.id, activity] as const), | ||
| 117 | ) as AppState["activityByThreadId"][ThreadId], | ||
| 118 | }, | ||
| 119 | proposedPlanIdsByThreadId: { | ||
| 120 | [thread.id]: thread.proposedPlans.map((plan) => plan.id), | ||
| 121 | }, | ||
| 122 | proposedPlanByThreadId: { | ||
| 123 | [thread.id]: Object.fromEntries( | ||
| 124 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), | ||
| 125 | ) as AppState["proposedPlanByThreadId"][ThreadId], | ||
| 126 | }, | ||
| 127 | turnDiffIdsByThreadId: { | ||
| 128 | [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), | ||
| 129 | }, | ||
| 130 | turnDiffSummaryByThreadId: { | ||
| 131 | [thread.id]: Object.fromEntries( | ||
| 132 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), | ||
| 133 | ) as AppState["turnDiffSummaryByThreadId"][ThreadId], | ||
| 134 | }, | ||
| 135 | sidebarThreadSummaryById: {}, | ||
| 136 | bootstrapComplete: true, | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | function makeEmptyState(overrides: Partial<AppState> = {}): AppState { | ||
| 141 | return { | ||
| 142 | projectIds: [], | ||
| 143 | projectById: {}, | ||
| 144 | threadIds: [], | ||
| 145 | threadIdsByProjectId: {}, | ||
| 146 | threadShellById: {}, | ||
| 147 | threadSessionById: {}, | ||
| 148 | threadTurnStateById: {}, | ||
| 149 | messageIdsByThreadId: {}, | ||
| 150 | messageByThreadId: {}, | ||
| 151 | activityIdsByThreadId: {}, | ||
| 152 | activityByThreadId: {}, | ||
| 153 | proposedPlanIdsByThreadId: {}, | ||
| 154 | proposedPlanByThreadId: {}, | ||
| 155 | turnDiffIdsByThreadId: {}, | ||
| 156 | turnDiffSummaryByThreadId: {}, | ||
| 157 | sidebarThreadSummaryById: {}, | ||
| 69 | bootstrapComplete: true, | 158 | bootstrapComplete: true, |
| 159 | ...overrides, | ||
| 70 | }; | 160 | }; |
| 71 | } | 161 | } |
| 72 | 162 | ||
| 163 | function projectsOf(state: AppState) { | ||
| 164 | return selectProjects(state); | ||
| 165 | } | ||
| 166 | |||
| 167 | function threadsOf(state: AppState) { | ||
| 168 | return selectThreads(state); | ||
| 169 | } | ||
| 170 | |||
| 73 | function makeEvent<T extends OrchestrationEvent["type"]>( | 171 | function makeEvent<T extends OrchestrationEvent["type"]>( |
| 74 | type: T, | 172 | type: T, |
| 75 | payload: Extract<OrchestrationEvent, { type: T }>["payload"], | 173 | payload: Extract<OrchestrationEvent, { type: T }>["payload"], |
| ... | @@ -191,7 +289,7 @@ describe("store read model sync", () => { | ... | @@ -191,7 +289,7 @@ describe("store read model sync", () => { |
| 191 | 289 | ||
| 192 | const next = syncServerReadModel(initialState, readModel); | 290 | const next = syncServerReadModel(initialState, readModel); |
| 193 | 291 | ||
| 194 | expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6"); | 292 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); |
| 195 | }); | 293 | }); |
| 196 | 294 | ||
| 197 | it("resolves claude aliases when session provider is claudeAgent", () => { | 295 | it("resolves claude aliases when session provider is claudeAgent", () => { |
| ... | @@ -216,7 +314,7 @@ describe("store read model sync", () => { | ... | @@ -216,7 +314,7 @@ describe("store read model sync", () => { |
| 216 | 314 | ||
| 217 | const next = syncServerReadModel(initialState, readModel); | 315 | const next = syncServerReadModel(initialState, readModel); |
| 218 | 316 | ||
| 219 | expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); | 317 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); |
| 220 | }); | 318 | }); |
| 221 | 319 | ||
| 222 | it("preserves project and thread updatedAt timestamps from the read model", () => { | 320 | it("preserves project and thread updatedAt timestamps from the read model", () => { |
| ... | @@ -229,8 +327,8 @@ describe("store read model sync", () => { | ... | @@ -229,8 +327,8 @@ describe("store read model sync", () => { |
| 229 | 327 | ||
| 230 | const next = syncServerReadModel(initialState, readModel); | 328 | const next = syncServerReadModel(initialState, readModel); |
| 231 | 329 | ||
| 232 | expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); | 330 | expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); |
| 233 | expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); | 331 | expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); |
| 234 | }); | 332 | }); |
| 235 | 333 | ||
| 236 | it("maps archivedAt from the read model", () => { | 334 | it("maps archivedAt from the read model", () => { |
| ... | @@ -245,16 +343,17 @@ describe("store read model sync", () => { | ... | @@ -245,16 +343,17 @@ describe("store read model sync", () => { |
| 245 | ), | 343 | ), |
| 246 | ); | 344 | ); |
| 247 | 345 | ||
| 248 | expect(next.threads[0]?.archivedAt).toBe(archivedAt); | 346 | expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); |
| 249 | }); | 347 | }); |
| 250 | 348 | ||
| 251 | it("replaces projects using snapshot order during recovery", () => { | 349 | it("replaces projects using snapshot order during recovery", () => { |
| 252 | const project1 = ProjectId.makeUnsafe("project-1"); | 350 | const project1 = ProjectId.makeUnsafe("project-1"); |
| 253 | const project2 = ProjectId.makeUnsafe("project-2"); | 351 | const project2 = ProjectId.makeUnsafe("project-2"); |
| 254 | const project3 = ProjectId.makeUnsafe("project-3"); | 352 | const project3 = ProjectId.makeUnsafe("project-3"); |
| 255 | const initialState: AppState = { | 353 | const initialState: AppState = makeEmptyState({ |
| 256 | projects: [ | 354 | projectIds: [project2, project1], |
| 257 | { | 355 | projectById: { |
| 356 | [project2]: { | ||
| 258 | id: project2, | 357 | id: project2, |
| 259 | name: "Project 2", | 358 | name: "Project 2", |
| 260 | cwd: "/tmp/project-2", | 359 | cwd: "/tmp/project-2", |
| ... | @@ -262,9 +361,11 @@ describe("store read model sync", () => { | ... | @@ -262,9 +361,11 @@ describe("store read model sync", () => { |
| 262 | provider: "codex", | 361 | provider: "codex", |
| 263 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 362 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 264 | }, | 363 | }, |
| 364 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 365 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 265 | scripts: [], | 366 | scripts: [], |
| 266 | }, | 367 | }, |
| 267 | { | 368 | [project1]: { |
| 268 | id: project1, | 369 | id: project1, |
| 269 | name: "Project 1", | 370 | name: "Project 1", |
| 270 | cwd: "/tmp/project-1", | 371 | cwd: "/tmp/project-1", |
| ... | @@ -272,14 +373,12 @@ describe("store read model sync", () => { | ... | @@ -272,14 +373,12 @@ describe("store read model sync", () => { |
| 272 | provider: "codex", | 373 | provider: "codex", |
| 273 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 374 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 274 | }, | 375 | }, |
| 376 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 377 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 275 | scripts: [], | 378 | scripts: [], |
| 276 | }, | 379 | }, |
| 277 | ], | 380 | }, |
| 278 | threads: [], | 381 | }); |
| 279 | sidebarThreadsById: {}, | ||
| 280 | threadIdsByProjectId: {}, | ||
| 281 | bootstrapComplete: true, | ||
| 282 | }; | ||
| 283 | const readModel: OrchestrationReadModel = { | 382 | const readModel: OrchestrationReadModel = { |
| 284 | snapshotSequence: 2, | 383 | snapshotSequence: 2, |
| 285 | updatedAt: "2026-02-27T00:00:00.000Z", | 384 | updatedAt: "2026-02-27T00:00:00.000Z", |
| ... | @@ -305,7 +404,7 @@ describe("store read model sync", () => { | ... | @@ -305,7 +404,7 @@ describe("store read model sync", () => { |
| 305 | 404 | ||
| 306 | const next = syncServerReadModel(initialState, readModel); | 405 | const next = syncServerReadModel(initialState, readModel); |
| 307 | 406 | ||
| 308 | expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); | 407 | expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); |
| 309 | }); | 408 | }); |
| 310 | }); | 409 | }); |
| 311 | 410 | ||
| ... | @@ -345,8 +444,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -345,8 +444,8 @@ describe("incremental orchestration updates", () => { |
| 345 | }), | 444 | }), |
| 346 | ); | 445 | ); |
| 347 | 446 | ||
| 348 | expect(next.projects[0]?.name).toBe("Renamed Project"); | 447 | expect(next.projectById[projectId]?.name).toBe("Renamed Project"); |
| 349 | expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); | 448 | expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); |
| 350 | }); | 449 | }); |
| 351 | 450 | ||
| 352 | it("preserves state identity for no-op project and thread deletes", () => { | 451 | it("preserves state identity for no-op project and thread deletes", () => { |
| ... | @@ -375,9 +474,10 @@ describe("incremental orchestration updates", () => { | ... | @@ -375,9 +474,10 @@ describe("incremental orchestration updates", () => { |
| 375 | it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { | 474 | it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { |
| 376 | const originalProjectId = ProjectId.makeUnsafe("project-1"); | 475 | const originalProjectId = ProjectId.makeUnsafe("project-1"); |
| 377 | const recreatedProjectId = ProjectId.makeUnsafe("project-2"); | 476 | const recreatedProjectId = ProjectId.makeUnsafe("project-2"); |
| 378 | const state: AppState = { | 477 | const state: AppState = makeEmptyState({ |
| 379 | projects: [ | 478 | projectIds: [originalProjectId], |
| 380 | { | 479 | projectById: { |
| 480 | [originalProjectId]: { | ||
| 381 | id: originalProjectId, | 481 | id: originalProjectId, |
| 382 | name: "Project", | 482 | name: "Project", |
| 383 | cwd: "/tmp/project", | 483 | cwd: "/tmp/project", |
| ... | @@ -385,14 +485,12 @@ describe("incremental orchestration updates", () => { | ... | @@ -385,14 +485,12 @@ describe("incremental orchestration updates", () => { |
| 385 | provider: "codex", | 485 | provider: "codex", |
| 386 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 486 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 387 | }, | 487 | }, |
| 488 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 489 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 388 | scripts: [], | 490 | scripts: [], |
| 389 | }, | 491 | }, |
| 390 | ], | 492 | }, |
| 391 | threads: [], | 493 | }); |
| 392 | sidebarThreadsById: {}, | ||
| 393 | threadIdsByProjectId: {}, | ||
| 394 | bootstrapComplete: true, | ||
| 395 | }; | ||
| 396 | 494 | ||
| 397 | const next = applyOrchestrationEvent( | 495 | const next = applyOrchestrationEvent( |
| 398 | state, | 496 | state, |
| ... | @@ -410,10 +508,13 @@ describe("incremental orchestration updates", () => { | ... | @@ -410,10 +508,13 @@ describe("incremental orchestration updates", () => { |
| 410 | }), | 508 | }), |
| 411 | ); | 509 | ); |
| 412 | 510 | ||
| 413 | expect(next.projects).toHaveLength(1); | 511 | expect(projectsOf(next)).toHaveLength(1); |
| 414 | expect(next.projects[0]?.id).toBe(recreatedProjectId); | 512 | expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); |
| 415 | expect(next.projects[0]?.cwd).toBe("/tmp/project"); | 513 | expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); |
| 416 | expect(next.projects[0]?.name).toBe("Project Recreated"); | 514 | expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); |
| 515 | expect(next.projectIds).toEqual([recreatedProjectId]); | ||
| 516 | expect(next.projectById[originalProjectId]).toBeUndefined(); | ||
| 517 | expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); | ||
| 417 | }); | 518 | }); |
| 418 | 519 | ||
| 419 | it("removes stale project index entries when thread.created recreates a thread under a new project", () => { | 520 | it("removes stale project index entries when thread.created recreates a thread under a new project", () => { |
| ... | @@ -425,8 +526,10 @@ describe("incremental orchestration updates", () => { | ... | @@ -425,8 +526,10 @@ describe("incremental orchestration updates", () => { |
| 425 | projectId: originalProjectId, | 526 | projectId: originalProjectId, |
| 426 | }); | 527 | }); |
| 427 | const state: AppState = { | 528 | const state: AppState = { |
| 428 | projects: [ | 529 | ...makeState(thread), |
| 429 | { | 530 | projectIds: [originalProjectId, recreatedProjectId], |
| 531 | projectById: { | ||
| 532 | [originalProjectId]: { | ||
| 430 | id: originalProjectId, | 533 | id: originalProjectId, |
| 431 | name: "Project 1", | 534 | name: "Project 1", |
| 432 | cwd: "/tmp/project-1", | 535 | cwd: "/tmp/project-1", |
| ... | @@ -434,9 +537,11 @@ describe("incremental orchestration updates", () => { | ... | @@ -434,9 +537,11 @@ describe("incremental orchestration updates", () => { |
| 434 | provider: "codex", | 537 | provider: "codex", |
| 435 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 538 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 436 | }, | 539 | }, |
| 540 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 541 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 437 | scripts: [], | 542 | scripts: [], |
| 438 | }, | 543 | }, |
| 439 | { | 544 | [recreatedProjectId]: { |
| 440 | id: recreatedProjectId, | 545 | id: recreatedProjectId, |
| 441 | name: "Project 2", | 546 | name: "Project 2", |
| 442 | cwd: "/tmp/project-2", | 547 | cwd: "/tmp/project-2", |
| ... | @@ -444,15 +549,11 @@ describe("incremental orchestration updates", () => { | ... | @@ -444,15 +549,11 @@ describe("incremental orchestration updates", () => { |
| 444 | provider: "codex", | 549 | provider: "codex", |
| 445 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 550 | model: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 446 | }, | 551 | }, |
| 552 | createdAt: "2026-02-27T00:00:00.000Z", | ||
| 553 | updatedAt: "2026-02-27T00:00:00.000Z", | ||
| 447 | scripts: [], | 554 | scripts: [], |
| 448 | }, | 555 | }, |
| 449 | ], | ||
| 450 | threads: [thread], | ||
| 451 | sidebarThreadsById: {}, | ||
| 452 | threadIdsByProjectId: { | ||
| 453 | [originalProjectId]: [threadId], | ||
| 454 | }, | 556 | }, |
| 455 | bootstrapComplete: true, | ||
| 456 | }; | 557 | }; |
| 457 | 558 | ||
| 458 | const next = applyOrchestrationEvent( | 559 | const next = applyOrchestrationEvent( |
| ... | @@ -474,8 +575,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -474,8 +575,8 @@ describe("incremental orchestration updates", () => { |
| 474 | }), | 575 | }), |
| 475 | ); | 576 | ); |
| 476 | 577 | ||
| 477 | expect(next.threads).toHaveLength(1); | 578 | expect(threadsOf(next)).toHaveLength(1); |
| 478 | expect(next.threads[0]?.projectId).toBe(recreatedProjectId); | 579 | expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); |
| 479 | expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); | 580 | expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); |
| 480 | expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); | 581 | expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); |
| 481 | }); | 582 | }); |
| ... | @@ -498,7 +599,73 @@ describe("incremental orchestration updates", () => { | ... | @@ -498,7 +599,73 @@ describe("incremental orchestration updates", () => { |
| 498 | const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); | 599 | const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); |
| 499 | const state: AppState = { | 600 | const state: AppState = { |
| 500 | ...makeState(thread1), | 601 | ...makeState(thread1), |
| 501 | threads: [thread1, thread2], | 602 | threadIds: [thread1.id, thread2.id], |
| 603 | threadShellById: { | ||
| 604 | ...makeState(thread1).threadShellById, | ||
| 605 | [thread2.id]: { | ||
| 606 | id: thread2.id, | ||
| 607 | codexThreadId: thread2.codexThreadId, | ||
| 608 | projectId: thread2.projectId, | ||
| 609 | title: thread2.title, | ||
| 610 | modelSelection: thread2.modelSelection, | ||
| 611 | runtimeMode: thread2.runtimeMode, | ||
| 612 | interactionMode: thread2.interactionMode, | ||
| 613 | error: thread2.error, | ||
| 614 | createdAt: thread2.createdAt, | ||
| 615 | archivedAt: thread2.archivedAt, | ||
| 616 | updatedAt: thread2.updatedAt, | ||
| 617 | branch: thread2.branch, | ||
| 618 | worktreePath: thread2.worktreePath, | ||
| 619 | }, | ||
| 620 | }, | ||
| 621 | threadSessionById: { | ||
| 622 | ...makeState(thread1).threadSessionById, | ||
| 623 | [thread2.id]: thread2.session, | ||
| 624 | }, | ||
| 625 | threadTurnStateById: { | ||
| 626 | ...makeState(thread1).threadTurnStateById, | ||
| 627 | [thread2.id]: { | ||
| 628 | latestTurn: thread2.latestTurn, | ||
| 629 | }, | ||
| 630 | }, | ||
| 631 | messageIdsByThreadId: { | ||
| 632 | ...makeState(thread1).messageIdsByThreadId, | ||
| 633 | [thread2.id]: [], | ||
| 634 | }, | ||
| 635 | messageByThreadId: { | ||
| 636 | ...makeState(thread1).messageByThreadId, | ||
| 637 | [thread2.id]: {}, | ||
| 638 | }, | ||
| 639 | activityIdsByThreadId: { | ||
| 640 | ...makeState(thread1).activityIdsByThreadId, | ||
| 641 | [thread2.id]: [], | ||
| 642 | }, | ||
| 643 | activityByThreadId: { | ||
| 644 | ...makeState(thread1).activityByThreadId, | ||
| 645 | [thread2.id]: {}, | ||
| 646 | }, | ||
| 647 | proposedPlanIdsByThreadId: { | ||
| 648 | ...makeState(thread1).proposedPlanIdsByThreadId, | ||
| 649 | [thread2.id]: [], | ||
| 650 | }, | ||
| 651 | proposedPlanByThreadId: { | ||
| 652 | ...makeState(thread1).proposedPlanByThreadId, | ||
| 653 | [thread2.id]: {}, | ||
| 654 | }, | ||
| 655 | turnDiffIdsByThreadId: { | ||
| 656 | ...makeState(thread1).turnDiffIdsByThreadId, | ||
| 657 | [thread2.id]: [], | ||
| 658 | }, | ||
| 659 | turnDiffSummaryByThreadId: { | ||
| 660 | ...makeState(thread1).turnDiffSummaryByThreadId, | ||
| 661 | [thread2.id]: {}, | ||
| 662 | }, | ||
| 663 | sidebarThreadSummaryById: { | ||
| 664 | ...makeState(thread1).sidebarThreadSummaryById, | ||
| 665 | }, | ||
| 666 | threadIdsByProjectId: { | ||
| 667 | [thread1.projectId]: [thread1.id, thread2.id], | ||
| 668 | }, | ||
| 502 | }; | 669 | }; |
| 503 | 670 | ||
| 504 | const next = applyOrchestrationEvent( | 671 | const next = applyOrchestrationEvent( |
| ... | @@ -515,9 +682,12 @@ describe("incremental orchestration updates", () => { | ... | @@ -515,9 +682,12 @@ describe("incremental orchestration updates", () => { |
| 515 | }), | 682 | }), |
| 516 | ); | 683 | ); |
| 517 | 684 | ||
| 518 | expect(next.threads[0]?.messages[0]?.text).toBe("hello world"); | 685 | expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); |
| 519 | expect(next.threads[0]?.latestTurn?.state).toBe("running"); | 686 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); |
| 520 | expect(next.threads[1]).toBe(thread2); | 687 | expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); |
| 688 | expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); | ||
| 689 | expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); | ||
| 690 | expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); | ||
| 521 | }); | 691 | }); |
| 522 | 692 | ||
| 523 | it("applies replay batches in sequence and updates session state", () => { | 693 | it("applies replay batches in sequence and updates session state", () => { |
| ... | @@ -566,9 +736,9 @@ describe("incremental orchestration updates", () => { | ... | @@ -566,9 +736,9 @@ describe("incremental orchestration updates", () => { |
| 566 | ), | 736 | ), |
| 567 | ]); | 737 | ]); |
| 568 | 738 | ||
| 569 | expect(next.threads[0]?.session?.status).toBe("running"); | 739 | expect(threadsOf(next)[0]?.session?.status).toBe("running"); |
| 570 | expect(next.threads[0]?.latestTurn?.state).toBe("completed"); | 740 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); |
| 571 | expect(next.threads[0]?.messages).toHaveLength(1); | 741 | expect(threadsOf(next)[0]?.messages).toHaveLength(1); |
| 572 | }); | 742 | }); |
| 573 | 743 | ||
| 574 | it("does not regress latestTurn when an older turn diff completes late", () => { | 744 | it("does not regress latestTurn when an older turn diff completes late", () => { |
| ... | @@ -599,8 +769,8 @@ describe("incremental orchestration updates", () => { | ... | @@ -599,8 +769,8 @@ describe("incremental orchestration updates", () => { |
| 599 | }), | 769 | }), |
| 600 | ); | 770 | ); |
| 601 | 771 | ||
| 602 | expect(next.threads[0]?.turnDiffSummaries).toHaveLength(1); | 772 | expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); |
| 603 | expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn); | 773 | expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); |
| 604 | }); | 774 | }); |
| 605 | 775 | ||
| 606 | it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { | 776 | it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { |
| ... | @@ -643,10 +813,10 @@ describe("incremental orchestration updates", () => { | ... | @@ -643,10 +813,10 @@ describe("incremental orchestration updates", () => { |
| 643 | }), | 813 | }), |
| 644 | ); | 814 | ); |
| 645 | 815 | ||
| 646 | expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( | 816 | expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( |
| 647 | MessageId.makeUnsafe("assistant-real"), | 817 | MessageId.makeUnsafe("assistant-real"), |
| 648 | ); | 818 | ); |
| 649 | expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe( | 819 | expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( |
| 650 | MessageId.makeUnsafe("assistant-real"), | 820 | MessageId.makeUnsafe("assistant-real"), |
| 651 | ); | 821 | ); |
| 652 | }); | 822 | }); |
| ... | @@ -752,15 +922,15 @@ describe("incremental orchestration updates", () => { | ... | @@ -752,15 +922,15 @@ describe("incremental orchestration updates", () => { |
| 752 | }), | 922 | }), |
| 753 | ); | 923 | ); |
| 754 | 924 | ||
| 755 | expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([ | 925 | expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ |
| 756 | "user-1", | 926 | "user-1", |
| 757 | "assistant-1", | 927 | "assistant-1", |
| 758 | ]); | 928 | ]); |
| 759 | expect(next.threads[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); | 929 | expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); |
| 760 | expect(next.threads[0]?.activities.map((activity) => activity.id)).toEqual([ | 930 | expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ |
| 761 | EventId.makeUnsafe("activity-1"), | 931 | EventId.makeUnsafe("activity-1"), |
| 762 | ]); | 932 | ]); |
| 763 | expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ | 933 | expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ |
| 764 | TurnId.makeUnsafe("turn-1"), | 934 | TurnId.makeUnsafe("turn-1"), |
| 765 | ]); | 935 | ]); |
| 766 | }); | 936 | }); |
| ... | @@ -810,7 +980,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -810,7 +980,7 @@ describe("incremental orchestration updates", () => { |
| 810 | }), | 980 | }), |
| 811 | ); | 981 | ); |
| 812 | 982 | ||
| 813 | expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined(); | 983 | expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); |
| 814 | 984 | ||
| 815 | const next = applyOrchestrationEvent( | 985 | const next = applyOrchestrationEvent( |
| 816 | reverted, | 986 | reverted, |
| ... | @@ -828,10 +998,10 @@ describe("incremental orchestration updates", () => { | ... | @@ -828,10 +998,10 @@ describe("incremental orchestration updates", () => { |
| 828 | }), | 998 | }), |
| 829 | ); | 999 | ); |
| 830 | 1000 | ||
| 831 | expect(next.threads[0]?.latestTurn).toMatchObject({ | 1001 | expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ |
| 832 | turnId: TurnId.makeUnsafe("turn-3"), | 1002 | turnId: TurnId.makeUnsafe("turn-3"), |
| 833 | state: "running", | 1003 | state: "running", |
| 834 | }); | 1004 | }); |
| 835 | expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); | 1005 | expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); |
| 836 | }); | 1006 | }); |
| 837 | }); | 1007 | }); |
apps/web/src/store.ts+649-259| ... | @@ -1,84 +1,101 @@ | ... | @@ -1,84 +1,101 @@ |
| 1 | import { | 1 | import { |
| 2 | type MessageId, | ||
| 3 | type OrchestrationCheckpointSummary, | ||
| 2 | type OrchestrationEvent, | 4 | type OrchestrationEvent, |
| 3 | type OrchestrationMessage, | 5 | type OrchestrationMessage, |
| 4 | type OrchestrationProposedPlan, | 6 | type OrchestrationProposedPlan, |
| 5 | type ProjectId, | ||
| 6 | type ProviderKind, | ||
| 7 | ThreadId, | ||
| 8 | type OrchestrationReadModel, | 7 | type OrchestrationReadModel, |
| 9 | type OrchestrationSession, | 8 | type OrchestrationSession, |
| 10 | type OrchestrationCheckpointSummary, | ||
| 11 | type OrchestrationThread, | ||
| 12 | type OrchestrationSessionStatus, | 9 | type OrchestrationSessionStatus, |
| 10 | type OrchestrationThread, | ||
| 11 | type OrchestrationThreadActivity, | ||
| 12 | type ProjectId, | ||
| 13 | type ProviderKind, | ||
| 14 | ThreadId, | ||
| 15 | type TurnId, | ||
| 13 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 14 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; | 17 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; |
| 15 | import { create } from "zustand"; | 18 | import { create } from "zustand"; |
| 16 | import { | 19 | import { |
| 17 | findLatestProposedPlan, | ||
| 18 | hasActionableProposedPlan, | ||
| 19 | derivePendingApprovals, | 20 | derivePendingApprovals, |
| 20 | derivePendingUserInputs, | 21 | derivePendingUserInputs, |
| 22 | findLatestProposedPlan, | ||
| 23 | hasActionableProposedPlan, | ||
| 21 | } from "./session-logic"; | 24 | } from "./session-logic"; |
| 25 | import { | ||
| 26 | type ChatMessage, | ||
| 27 | type Project, | ||
| 28 | type ProposedPlan, | ||
| 29 | type SidebarThreadSummary, | ||
| 30 | type Thread, | ||
| 31 | type ThreadSession, | ||
| 32 | type ThreadShell, | ||
| 33 | type ThreadTurnState, | ||
| 34 | type TurnDiffSummary, | ||
| 35 | } from "./types"; | ||
| 22 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; | 36 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 23 | import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types"; | ||
| 24 | |||
| 25 | // ── State ──────────────────────────────────────────────────────────── | ||
| 26 | 37 | ||
| 27 | export interface AppState { | 38 | export interface AppState { |
| 28 | projects: Project[]; | 39 | projectIds: ProjectId[]; |
| 29 | threads: Thread[]; | 40 | projectById: Record<ProjectId, Project>; |
| 30 | sidebarThreadsById: Record<string, SidebarThreadSummary>; | 41 | threadIds: ThreadId[]; |
| 31 | threadIdsByProjectId: Record<string, ThreadId[]>; | 42 | threadIdsByProjectId: Record<ProjectId, ThreadId[]>; |
| 43 | threadShellById: Record<ThreadId, ThreadShell>; | ||
| 44 | threadSessionById: Record<ThreadId, ThreadSession | null>; | ||
| 45 | threadTurnStateById: Record<ThreadId, ThreadTurnState>; | ||
| 46 | messageIdsByThreadId: Record<ThreadId, MessageId[]>; | ||
| 47 | messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>>; | ||
| 48 | activityIdsByThreadId: Record<ThreadId, string[]>; | ||
| 49 | activityByThreadId: Record<ThreadId, Record<string, OrchestrationThreadActivity>>; | ||
| 50 | proposedPlanIdsByThreadId: Record<ThreadId, string[]>; | ||
| 51 | proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>>; | ||
| 52 | turnDiffIdsByThreadId: Record<ThreadId, TurnId[]>; | ||
| 53 | turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>>; | ||
| 54 | sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary>; | ||
| 32 | bootstrapComplete: boolean; | 55 | bootstrapComplete: boolean; |
| 33 | } | 56 | } |
| 34 | 57 | ||
| 35 | const initialState: AppState = { | 58 | const initialState: AppState = { |
| 36 | projects: [], | 59 | projectIds: [], |
| 37 | threads: [], | 60 | projectById: {}, |
| 38 | sidebarThreadsById: {}, | 61 | threadIds: [], |
| 39 | threadIdsByProjectId: {}, | 62 | threadIdsByProjectId: {}, |
| 63 | threadShellById: {}, | ||
| 64 | threadSessionById: {}, | ||
| 65 | threadTurnStateById: {}, | ||
| 66 | messageIdsByThreadId: {}, | ||
| 67 | messageByThreadId: {}, | ||
| 68 | activityIdsByThreadId: {}, | ||
| 69 | activityByThreadId: {}, | ||
| 70 | proposedPlanIdsByThreadId: {}, | ||
| 71 | proposedPlanByThreadId: {}, | ||
| 72 | turnDiffIdsByThreadId: {}, | ||
| 73 | turnDiffSummaryByThreadId: {}, | ||
| 74 | sidebarThreadSummaryById: {}, | ||
| 40 | bootstrapComplete: false, | 75 | bootstrapComplete: false, |
| 41 | }; | 76 | }; |
| 77 | |||
| 42 | const MAX_THREAD_MESSAGES = 2_000; | 78 | const MAX_THREAD_MESSAGES = 2_000; |
| 43 | const MAX_THREAD_CHECKPOINTS = 500; | 79 | const MAX_THREAD_CHECKPOINTS = 500; |
| 44 | const MAX_THREAD_PROPOSED_PLANS = 200; | 80 | const MAX_THREAD_PROPOSED_PLANS = 200; |
| 45 | const MAX_THREAD_ACTIVITIES = 500; | 81 | const MAX_THREAD_ACTIVITIES = 500; |
| 46 | const EMPTY_THREAD_IDS: ThreadId[] = []; | 82 | const EMPTY_THREAD_IDS: ThreadId[] = []; |
| 47 | 83 | const EMPTY_MESSAGE_IDS: MessageId[] = []; | |
| 48 | // ── Pure helpers ────────────────────────────────────────────────────── | 84 | const EMPTY_ACTIVITY_IDS: string[] = []; |
| 49 | 85 | const EMPTY_PROPOSED_PLAN_IDS: string[] = []; | |
| 50 | function updateThread( | 86 | const EMPTY_TURN_IDS: TurnId[] = []; |
| 51 | threads: Thread[], | 87 | const EMPTY_MESSAGES: ChatMessage[] = []; |
| 52 | threadId: ThreadId, | 88 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 53 | updater: (t: Thread) => Thread, | 89 | const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; |
| 54 | ): Thread[] { | 90 | const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; |
| 55 | let changed = false; | 91 | const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {}; |
| 56 | const next = threads.map((t) => { | 92 | const EMPTY_ACTIVITY_MAP: Record<string, OrchestrationThreadActivity> = {}; |
| 57 | if (t.id !== threadId) return t; | 93 | const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {}; |
| 58 | const updated = updater(t); | 94 | const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {}; |
| 59 | if (updated !== t) changed = true; | 95 | const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null }); |
| 60 | return updated; | 96 | |
| 61 | }); | 97 | function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { |
| 62 | return changed ? next : threads; | 98 | return left.length === right.length && left.every((value, index) => value === right[index]); |
| 63 | } | ||
| 64 | |||
| 65 | function updateProject( | ||
| 66 | projects: Project[], | ||
| 67 | projectId: Project["id"], | ||
| 68 | updater: (project: Project) => Project, | ||
| 69 | ): Project[] { | ||
| 70 | let changed = false; | ||
| 71 | const next = projects.map((project) => { | ||
| 72 | if (project.id !== projectId) { | ||
| 73 | return project; | ||
| 74 | } | ||
| 75 | const updated = updater(project); | ||
| 76 | if (updated !== project) { | ||
| 77 | changed = true; | ||
| 78 | } | ||
| 79 | return updated; | ||
| 80 | }); | ||
| 81 | return changed ? next : projects; | ||
| 82 | } | 99 | } |
| 83 | 100 | ||
| 84 | function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>( | 101 | function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>( |
| ... | @@ -94,7 +111,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): | ... | @@ -94,7 +111,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): |
| 94 | return scripts.map((script) => ({ ...script })); | 111 | return scripts.map((script) => ({ ...script })); |
| 95 | } | 112 | } |
| 96 | 113 | ||
| 97 | function mapSession(session: OrchestrationSession): Thread["session"] { | 114 | function mapSession(session: OrchestrationSession): ThreadSession { |
| 98 | return { | 115 | return { |
| 99 | provider: toLegacyProvider(session.providerName), | 116 | provider: toLegacyProvider(session.providerName), |
| 100 | status: toLegacySessionStatus(session.status), | 117 | status: toLegacySessionStatus(session.status), |
| ... | @@ -128,7 +145,7 @@ function mapMessage(message: OrchestrationMessage): ChatMessage { | ... | @@ -128,7 +145,7 @@ function mapMessage(message: OrchestrationMessage): ChatMessage { |
| 128 | }; | 145 | }; |
| 129 | } | 146 | } |
| 130 | 147 | ||
| 131 | function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] { | 148 | function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { |
| 132 | return { | 149 | return { |
| 133 | id: proposedPlan.id, | 150 | id: proposedPlan.id, |
| 134 | turnId: proposedPlan.turnId, | 151 | turnId: proposedPlan.turnId, |
| ... | @@ -140,9 +157,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["propo | ... | @@ -140,9 +157,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["propo |
| 140 | }; | 157 | }; |
| 141 | } | 158 | } |
| 142 | 159 | ||
| 143 | function mapTurnDiffSummary( | 160 | function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { |
| 144 | checkpoint: OrchestrationCheckpointSummary, | ||
| 145 | ): Thread["turnDiffSummaries"][number] { | ||
| 146 | return { | 161 | return { |
| 147 | turnId: checkpoint.turnId, | 162 | turnId: checkpoint.turnId, |
| 148 | completedAt: checkpoint.completedAt, | 163 | completedAt: checkpoint.completedAt, |
| ... | @@ -154,6 +169,20 @@ function mapTurnDiffSummary( | ... | @@ -154,6 +169,20 @@ function mapTurnDiffSummary( |
| 154 | }; | 169 | }; |
| 155 | } | 170 | } |
| 156 | 171 | ||
| 172 | function mapProject(project: OrchestrationReadModel["projects"][number]): Project { | ||
| 173 | return { | ||
| 174 | id: project.id, | ||
| 175 | name: project.title, | ||
| 176 | cwd: project.workspaceRoot, | ||
| 177 | defaultModelSelection: project.defaultModelSelection | ||
| 178 | ? normalizeModelSelection(project.defaultModelSelection) | ||
| 179 | : null, | ||
| 180 | createdAt: project.createdAt, | ||
| 181 | updatedAt: project.updatedAt, | ||
| 182 | scripts: mapProjectScripts(project.scripts), | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | |||
| 157 | function mapThread(thread: OrchestrationThread): Thread { | 186 | function mapThread(thread: OrchestrationThread): Thread { |
| 158 | return { | 187 | return { |
| 159 | id: thread.id, | 188 | id: thread.id, |
| ... | @@ -179,25 +208,35 @@ function mapThread(thread: OrchestrationThread): Thread { | ... | @@ -179,25 +208,35 @@ function mapThread(thread: OrchestrationThread): Thread { |
| 179 | }; | 208 | }; |
| 180 | } | 209 | } |
| 181 | 210 | ||
| 182 | function mapProject(project: OrchestrationReadModel["projects"][number]): Project { | 211 | function toThreadShell(thread: Thread): ThreadShell { |
| 183 | return { | 212 | return { |
| 184 | id: project.id, | 213 | id: thread.id, |
| 185 | name: project.title, | 214 | codexThreadId: thread.codexThreadId, |
| 186 | cwd: project.workspaceRoot, | 215 | projectId: thread.projectId, |
| 187 | defaultModelSelection: project.defaultModelSelection | 216 | title: thread.title, |
| 188 | ? normalizeModelSelection(project.defaultModelSelection) | 217 | modelSelection: thread.modelSelection, |
| 189 | : null, | 218 | runtimeMode: thread.runtimeMode, |
| 190 | createdAt: project.createdAt, | 219 | interactionMode: thread.interactionMode, |
| 191 | updatedAt: project.updatedAt, | 220 | error: thread.error, |
| 192 | scripts: mapProjectScripts(project.scripts), | 221 | createdAt: thread.createdAt, |
| 222 | archivedAt: thread.archivedAt, | ||
| 223 | updatedAt: thread.updatedAt, | ||
| 224 | branch: thread.branch, | ||
| 225 | worktreePath: thread.worktreePath, | ||
| 193 | }; | 226 | }; |
| 194 | } | 227 | } |
| 195 | 228 | ||
| 196 | function getLatestUserMessageAt( | 229 | function toThreadTurnState(thread: Thread): ThreadTurnState { |
| 197 | messages: ReadonlyArray<Thread["messages"][number]>, | 230 | return { |
| 198 | ): string | null { | 231 | latestTurn: thread.latestTurn, |
| 199 | let latestUserMessageAt: string | null = null; | 232 | ...(thread.pendingSourceProposedPlan |
| 233 | ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } | ||
| 234 | : {}), | ||
| 235 | }; | ||
| 236 | } | ||
| 200 | 237 | ||
| 238 | function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | null { | ||
| 239 | let latestUserMessageAt: string | null = null; | ||
| 201 | for (const message of messages) { | 240 | for (const message of messages) { |
| 202 | if (message.role !== "user") { | 241 | if (message.role !== "user") { |
| 203 | continue; | 242 | continue; |
| ... | @@ -206,7 +245,6 @@ function getLatestUserMessageAt( | ... | @@ -206,7 +245,6 @@ function getLatestUserMessageAt( |
| 206 | latestUserMessageAt = message.createdAt; | 245 | latestUserMessageAt = message.createdAt; |
| 207 | } | 246 | } |
| 208 | } | 247 | } |
| 209 | |||
| 210 | return latestUserMessageAt; | 248 | return latestUserMessageAt; |
| 211 | } | 249 | } |
| 212 | 250 | ||
| ... | @@ -256,62 +294,378 @@ function sidebarThreadSummariesEqual( | ... | @@ -256,62 +294,378 @@ function sidebarThreadSummariesEqual( |
| 256 | ); | 294 | ); |
| 257 | } | 295 | } |
| 258 | 296 | ||
| 259 | function appendThreadIdByProjectId( | 297 | function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { |
| 260 | threadIdsByProjectId: Record<string, ThreadId[]>, | 298 | return ( |
| 261 | projectId: ProjectId, | 299 | left !== undefined && |
| 262 | threadId: ThreadId, | 300 | left.id === right.id && |
| 263 | ): Record<string, ThreadId[]> { | 301 | left.codexThreadId === right.codexThreadId && |
| 264 | const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; | 302 | left.projectId === right.projectId && |
| 265 | if (existingThreadIds.includes(threadId)) { | 303 | left.title === right.title && |
| 266 | return threadIdsByProjectId; | 304 | left.modelSelection === right.modelSelection && |
| 267 | } | 305 | left.runtimeMode === right.runtimeMode && |
| 306 | left.interactionMode === right.interactionMode && | ||
| 307 | left.error === right.error && | ||
| 308 | left.createdAt === right.createdAt && | ||
| 309 | left.archivedAt === right.archivedAt && | ||
| 310 | left.updatedAt === right.updatedAt && | ||
| 311 | left.branch === right.branch && | ||
| 312 | left.worktreePath === right.worktreePath | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | |||
| 316 | function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { | ||
| 317 | return ( | ||
| 318 | left !== undefined && | ||
| 319 | left.latestTurn === right.latestTurn && | ||
| 320 | left.pendingSourceProposedPlan === right.pendingSourceProposedPlan | ||
| 321 | ); | ||
| 322 | } | ||
| 323 | |||
| 324 | function appendId<T extends string>(ids: readonly T[], id: T): T[] { | ||
| 325 | return ids.includes(id) ? [...ids] : [...ids, id]; | ||
| 326 | } | ||
| 327 | |||
| 328 | function removeId<T extends string>(ids: readonly T[], id: T): T[] { | ||
| 329 | return ids.filter((value) => value !== id); | ||
| 330 | } | ||
| 331 | |||
| 332 | function buildMessageSlice(thread: Thread): { | ||
| 333 | ids: MessageId[]; | ||
| 334 | byId: Record<MessageId, ChatMessage>; | ||
| 335 | } { | ||
| 336 | return { | ||
| 337 | ids: thread.messages.map((message) => message.id), | ||
| 338 | byId: Object.fromEntries( | ||
| 339 | thread.messages.map((message) => [message.id, message] as const), | ||
| 340 | ) as Record<MessageId, ChatMessage>, | ||
| 341 | }; | ||
| 342 | } | ||
| 343 | |||
| 344 | function buildActivitySlice(thread: Thread): { | ||
| 345 | ids: string[]; | ||
| 346 | byId: Record<string, OrchestrationThreadActivity>; | ||
| 347 | } { | ||
| 348 | return { | ||
| 349 | ids: thread.activities.map((activity) => activity.id), | ||
| 350 | byId: Object.fromEntries( | ||
| 351 | thread.activities.map((activity) => [activity.id, activity] as const), | ||
| 352 | ) as Record<string, OrchestrationThreadActivity>, | ||
| 353 | }; | ||
| 354 | } | ||
| 355 | |||
| 356 | function buildProposedPlanSlice(thread: Thread): { | ||
| 357 | ids: string[]; | ||
| 358 | byId: Record<string, ProposedPlan>; | ||
| 359 | } { | ||
| 360 | return { | ||
| 361 | ids: thread.proposedPlans.map((plan) => plan.id), | ||
| 362 | byId: Object.fromEntries( | ||
| 363 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), | ||
| 364 | ) as Record<string, ProposedPlan>, | ||
| 365 | }; | ||
| 366 | } | ||
| 367 | |||
| 368 | function buildTurnDiffSlice(thread: Thread): { | ||
| 369 | ids: TurnId[]; | ||
| 370 | byId: Record<TurnId, TurnDiffSummary>; | ||
| 371 | } { | ||
| 268 | return { | 372 | return { |
| 269 | ...threadIdsByProjectId, | 373 | ids: thread.turnDiffSummaries.map((summary) => summary.turnId), |
| 270 | [projectId]: [...existingThreadIds, threadId], | 374 | byId: Object.fromEntries( |
| 375 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), | ||
| 376 | ) as Record<TurnId, TurnDiffSummary>, | ||
| 271 | }; | 377 | }; |
| 272 | } | 378 | } |
| 273 | 379 | ||
| 274 | function removeThreadIdByProjectId( | 380 | function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { |
| 275 | threadIdsByProjectId: Record<string, ThreadId[]>, | 381 | const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; |
| 276 | projectId: ProjectId, | 382 | const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; |
| 383 | if (ids.length === 0) { | ||
| 384 | return EMPTY_MESSAGES; | ||
| 385 | } | ||
| 386 | return ids.flatMap((id) => { | ||
| 387 | const message = byId[id]; | ||
| 388 | return message ? [message] : []; | ||
| 389 | }); | ||
| 390 | } | ||
| 391 | |||
| 392 | function selectThreadActivities( | ||
| 393 | state: AppState, | ||
| 277 | threadId: ThreadId, | 394 | threadId: ThreadId, |
| 278 | ): Record<string, ThreadId[]> { | 395 | ): OrchestrationThreadActivity[] { |
| 279 | const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; | 396 | const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; |
| 280 | if (!existingThreadIds.includes(threadId)) { | 397 | const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP; |
| 281 | return threadIdsByProjectId; | 398 | if (ids.length === 0) { |
| 399 | return EMPTY_ACTIVITIES; | ||
| 282 | } | 400 | } |
| 283 | const nextThreadIds = existingThreadIds.filter( | 401 | return ids.flatMap((id) => { |
| 284 | (existingThreadId) => existingThreadId !== threadId, | 402 | const activity = byId[id]; |
| 285 | ); | 403 | return activity ? [activity] : []; |
| 286 | if (nextThreadIds.length === existingThreadIds.length) { | 404 | }); |
| 287 | return threadIdsByProjectId; | 405 | } |
| 406 | |||
| 407 | function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { | ||
| 408 | const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; | ||
| 409 | const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; | ||
| 410 | if (ids.length === 0) { | ||
| 411 | return EMPTY_PROPOSED_PLANS; | ||
| 288 | } | 412 | } |
| 289 | if (nextThreadIds.length === 0) { | 413 | return ids.flatMap((id) => { |
| 290 | const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; | 414 | const plan = byId[id]; |
| 291 | delete nextThreadIdsByProjectId[projectId]; | 415 | return plan ? [plan] : []; |
| 292 | return nextThreadIdsByProjectId; | 416 | }); |
| 417 | } | ||
| 418 | |||
| 419 | function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { | ||
| 420 | const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; | ||
| 421 | const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; | ||
| 422 | if (ids.length === 0) { | ||
| 423 | return EMPTY_TURN_DIFF_SUMMARIES; | ||
| 293 | } | 424 | } |
| 425 | return ids.flatMap((id) => { | ||
| 426 | const summary = byId[id]; | ||
| 427 | return summary ? [summary] : []; | ||
| 428 | }); | ||
| 429 | } | ||
| 430 | |||
| 431 | function getThread(state: AppState, threadId: ThreadId): Thread | undefined { | ||
| 432 | const shell = state.threadShellById[threadId]; | ||
| 433 | if (!shell) { | ||
| 434 | return undefined; | ||
| 435 | } | ||
| 436 | const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE; | ||
| 294 | return { | 437 | return { |
| 295 | ...threadIdsByProjectId, | 438 | ...shell, |
| 296 | [projectId]: nextThreadIds, | 439 | session: state.threadSessionById[threadId] ?? null, |
| 440 | latestTurn: turnState.latestTurn, | ||
| 441 | pendingSourceProposedPlan: turnState.pendingSourceProposedPlan, | ||
| 442 | messages: selectThreadMessages(state, threadId), | ||
| 443 | activities: selectThreadActivities(state, threadId), | ||
| 444 | proposedPlans: selectThreadProposedPlans(state, threadId), | ||
| 445 | turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId), | ||
| 297 | }; | 446 | }; |
| 298 | } | 447 | } |
| 299 | 448 | ||
| 300 | function buildThreadIdsByProjectId(threads: ReadonlyArray<Thread>): Record<string, ThreadId[]> { | 449 | function getProjects(state: AppState): Project[] { |
| 301 | const threadIdsByProjectId: Record<string, ThreadId[]> = {}; | 450 | return state.projectIds.flatMap((projectId) => { |
| 302 | for (const thread of threads) { | 451 | const project = state.projectById[projectId]; |
| 303 | const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; | 452 | return project ? [project] : []; |
| 304 | threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; | 453 | }); |
| 454 | } | ||
| 455 | |||
| 456 | function getThreads(state: AppState): Thread[] { | ||
| 457 | return state.threadIds.flatMap((threadId) => { | ||
| 458 | const thread = getThread(state, threadId); | ||
| 459 | return thread ? [thread] : []; | ||
| 460 | }); | ||
| 461 | } | ||
| 462 | |||
| 463 | function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { | ||
| 464 | const nextShell = toThreadShell(nextThread); | ||
| 465 | const nextTurnState = toThreadTurnState(nextThread); | ||
| 466 | const previousShell = state.threadShellById[nextThread.id]; | ||
| 467 | const previousTurnState = state.threadTurnStateById[nextThread.id]; | ||
| 468 | const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; | ||
| 469 | const nextSummary = buildSidebarThreadSummary(nextThread); | ||
| 470 | |||
| 471 | let nextState = state; | ||
| 472 | |||
| 473 | if (!state.threadIds.includes(nextThread.id)) { | ||
| 474 | nextState = { | ||
| 475 | ...nextState, | ||
| 476 | threadIds: [...nextState.threadIds, nextThread.id], | ||
| 477 | }; | ||
| 478 | } | ||
| 479 | |||
| 480 | const previousProjectId = previousThread?.projectId; | ||
| 481 | const nextProjectId = nextThread.projectId; | ||
| 482 | if (previousProjectId !== nextProjectId) { | ||
| 483 | let threadIdsByProjectId = nextState.threadIdsByProjectId; | ||
| 484 | if (previousProjectId) { | ||
| 485 | const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; | ||
| 486 | const nextIds = removeId(previousIds, nextThread.id); | ||
| 487 | if (nextIds.length === 0) { | ||
| 488 | const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; | ||
| 489 | threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>; | ||
| 490 | } else if (!arraysEqual(previousIds, nextIds)) { | ||
| 491 | threadIdsByProjectId = { | ||
| 492 | ...threadIdsByProjectId, | ||
| 493 | [previousProjectId]: nextIds, | ||
| 494 | }; | ||
| 495 | } | ||
| 496 | } | ||
| 497 | const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; | ||
| 498 | const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id); | ||
| 499 | if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { | ||
| 500 | threadIdsByProjectId = { | ||
| 501 | ...threadIdsByProjectId, | ||
| 502 | [nextProjectId]: nextProjectThreadIds, | ||
| 503 | }; | ||
| 504 | } | ||
| 505 | if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { | ||
| 506 | nextState = { | ||
| 507 | ...nextState, | ||
| 508 | threadIdsByProjectId, | ||
| 509 | }; | ||
| 510 | } | ||
| 511 | } | ||
| 512 | |||
| 513 | if (!threadShellsEqual(previousShell, nextShell)) { | ||
| 514 | nextState = { | ||
| 515 | ...nextState, | ||
| 516 | threadShellById: { | ||
| 517 | ...nextState.threadShellById, | ||
| 518 | [nextThread.id]: nextShell, | ||
| 519 | }, | ||
| 520 | }; | ||
| 521 | } | ||
| 522 | |||
| 523 | if ((previousThread?.session ?? null) !== nextThread.session) { | ||
| 524 | nextState = { | ||
| 525 | ...nextState, | ||
| 526 | threadSessionById: { | ||
| 527 | ...nextState.threadSessionById, | ||
| 528 | [nextThread.id]: nextThread.session, | ||
| 529 | }, | ||
| 530 | }; | ||
| 531 | } | ||
| 532 | |||
| 533 | if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { | ||
| 534 | nextState = { | ||
| 535 | ...nextState, | ||
| 536 | threadTurnStateById: { | ||
| 537 | ...nextState.threadTurnStateById, | ||
| 538 | [nextThread.id]: nextTurnState, | ||
| 539 | }, | ||
| 540 | }; | ||
| 541 | } | ||
| 542 | |||
| 543 | if (previousThread?.messages !== nextThread.messages) { | ||
| 544 | const nextMessageSlice = buildMessageSlice(nextThread); | ||
| 545 | nextState = { | ||
| 546 | ...nextState, | ||
| 547 | messageIdsByThreadId: { | ||
| 548 | ...nextState.messageIdsByThreadId, | ||
| 549 | [nextThread.id]: nextMessageSlice.ids, | ||
| 550 | }, | ||
| 551 | messageByThreadId: { | ||
| 552 | ...nextState.messageByThreadId, | ||
| 553 | [nextThread.id]: nextMessageSlice.byId, | ||
| 554 | }, | ||
| 555 | }; | ||
| 556 | } | ||
| 557 | |||
| 558 | if (previousThread?.activities !== nextThread.activities) { | ||
| 559 | const nextActivitySlice = buildActivitySlice(nextThread); | ||
| 560 | nextState = { | ||
| 561 | ...nextState, | ||
| 562 | activityIdsByThreadId: { | ||
| 563 | ...nextState.activityIdsByThreadId, | ||
| 564 | [nextThread.id]: nextActivitySlice.ids, | ||
| 565 | }, | ||
| 566 | activityByThreadId: { | ||
| 567 | ...nextState.activityByThreadId, | ||
| 568 | [nextThread.id]: nextActivitySlice.byId, | ||
| 569 | }, | ||
| 570 | }; | ||
| 571 | } | ||
| 572 | |||
| 573 | if (previousThread?.proposedPlans !== nextThread.proposedPlans) { | ||
| 574 | const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); | ||
| 575 | nextState = { | ||
| 576 | ...nextState, | ||
| 577 | proposedPlanIdsByThreadId: { | ||
| 578 | ...nextState.proposedPlanIdsByThreadId, | ||
| 579 | [nextThread.id]: nextProposedPlanSlice.ids, | ||
| 580 | }, | ||
| 581 | proposedPlanByThreadId: { | ||
| 582 | ...nextState.proposedPlanByThreadId, | ||
| 583 | [nextThread.id]: nextProposedPlanSlice.byId, | ||
| 584 | }, | ||
| 585 | }; | ||
| 586 | } | ||
| 587 | |||
| 588 | if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { | ||
| 589 | const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); | ||
| 590 | nextState = { | ||
| 591 | ...nextState, | ||
| 592 | turnDiffIdsByThreadId: { | ||
| 593 | ...nextState.turnDiffIdsByThreadId, | ||
| 594 | [nextThread.id]: nextTurnDiffSlice.ids, | ||
| 595 | }, | ||
| 596 | turnDiffSummaryByThreadId: { | ||
| 597 | ...nextState.turnDiffSummaryByThreadId, | ||
| 598 | [nextThread.id]: nextTurnDiffSlice.byId, | ||
| 599 | }, | ||
| 600 | }; | ||
| 305 | } | 601 | } |
| 306 | return threadIdsByProjectId; | 602 | |
| 603 | if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { | ||
| 604 | nextState = { | ||
| 605 | ...nextState, | ||
| 606 | sidebarThreadSummaryById: { | ||
| 607 | ...nextState.sidebarThreadSummaryById, | ||
| 608 | [nextThread.id]: nextSummary, | ||
| 609 | }, | ||
| 610 | }; | ||
| 611 | } | ||
| 612 | |||
| 613 | return nextState; | ||
| 307 | } | 614 | } |
| 308 | 615 | ||
| 309 | function buildSidebarThreadsById( | 616 | function removeThreadState(state: AppState, threadId: ThreadId): AppState { |
| 310 | threads: ReadonlyArray<Thread>, | 617 | const shell = state.threadShellById[threadId]; |
| 311 | ): Record<string, SidebarThreadSummary> { | 618 | if (!shell) { |
| 312 | return Object.fromEntries( | 619 | return state; |
| 313 | threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), | 620 | } |
| 314 | ); | 621 | |
| 622 | const nextThreadIds = removeId(state.threadIds, threadId); | ||
| 623 | const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; | ||
| 624 | const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); | ||
| 625 | const nextThreadIdsByProjectId = | ||
| 626 | nextProjectThreadIds.length === 0 | ||
| 627 | ? (() => { | ||
| 628 | const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; | ||
| 629 | return rest as Record<ProjectId, ThreadId[]>; | ||
| 630 | })() | ||
| 631 | : { | ||
| 632 | ...state.threadIdsByProjectId, | ||
| 633 | [shell.projectId]: nextProjectThreadIds, | ||
| 634 | }; | ||
| 635 | |||
| 636 | const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; | ||
| 637 | const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; | ||
| 638 | const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; | ||
| 639 | const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; | ||
| 640 | const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; | ||
| 641 | const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; | ||
| 642 | const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; | ||
| 643 | const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = | ||
| 644 | state.proposedPlanIdsByThreadId; | ||
| 645 | const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; | ||
| 646 | const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; | ||
| 647 | const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = | ||
| 648 | state.turnDiffSummaryByThreadId; | ||
| 649 | const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = | ||
| 650 | state.sidebarThreadSummaryById; | ||
| 651 | |||
| 652 | return { | ||
| 653 | ...state, | ||
| 654 | threadIds: nextThreadIds, | ||
| 655 | threadIdsByProjectId: nextThreadIdsByProjectId, | ||
| 656 | threadShellById, | ||
| 657 | threadSessionById, | ||
| 658 | threadTurnStateById, | ||
| 659 | messageIdsByThreadId, | ||
| 660 | messageByThreadId, | ||
| 661 | activityIdsByThreadId, | ||
| 662 | activityByThreadId, | ||
| 663 | proposedPlanIdsByThreadId, | ||
| 664 | proposedPlanByThreadId, | ||
| 665 | turnDiffIdsByThreadId, | ||
| 666 | turnDiffSummaryByThreadId, | ||
| 667 | sidebarThreadSummaryById, | ||
| 668 | }; | ||
| 315 | } | 669 | } |
| 316 | 670 | ||
| 317 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { | 671 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { |
| ... | @@ -367,10 +721,10 @@ function buildLatestTurn(params: { | ... | @@ -367,10 +721,10 @@ function buildLatestTurn(params: { |
| 367 | } | 721 | } |
| 368 | 722 | ||
| 369 | function rebindTurnDiffSummariesForAssistantMessage( | 723 | function rebindTurnDiffSummariesForAssistantMessage( |
| 370 | turnDiffSummaries: ReadonlyArray<Thread["turnDiffSummaries"][number]>, | 724 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>, |
| 371 | turnId: Thread["turnDiffSummaries"][number]["turnId"], | 725 | turnId: TurnId, |
| 372 | assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"], | 726 | assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"], |
| 373 | ): Thread["turnDiffSummaries"] { | 727 | ): TurnDiffSummary[] { |
| 374 | let changed = false; | 728 | let changed = false; |
| 375 | const nextSummaries = turnDiffSummaries.map((summary) => { | 729 | const nextSummaries = turnDiffSummaries.map((summary) => { |
| 376 | if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { | 730 | if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { |
| ... | @@ -457,18 +811,18 @@ function retainThreadMessagesAfterRevert( | ... | @@ -457,18 +811,18 @@ function retainThreadMessagesAfterRevert( |
| 457 | } | 811 | } |
| 458 | 812 | ||
| 459 | function retainThreadActivitiesAfterRevert( | 813 | function retainThreadActivitiesAfterRevert( |
| 460 | activities: ReadonlyArray<Thread["activities"][number]>, | 814 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 461 | retainedTurnIds: ReadonlySet<string>, | 815 | retainedTurnIds: ReadonlySet<string>, |
| 462 | ): Thread["activities"] { | 816 | ): OrchestrationThreadActivity[] { |
| 463 | return activities.filter( | 817 | return activities.filter( |
| 464 | (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), | 818 | (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), |
| 465 | ); | 819 | ); |
| 466 | } | 820 | } |
| 467 | 821 | ||
| 468 | function retainThreadProposedPlansAfterRevert( | 822 | function retainThreadProposedPlansAfterRevert( |
| 469 | proposedPlans: ReadonlyArray<Thread["proposedPlans"][number]>, | 823 | proposedPlans: ReadonlyArray<ProposedPlan>, |
| 470 | retainedTurnIds: ReadonlySet<string>, | 824 | retainedTurnIds: ReadonlySet<string>, |
| 471 | ): Thread["proposedPlans"] { | 825 | ): ProposedPlan[] { |
| 472 | return proposedPlans.filter( | 826 | return proposedPlans.filter( |
| 473 | (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), | 827 | (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), |
| 474 | ); | 828 | ); |
| ... | @@ -537,56 +891,113 @@ function updateThreadState( | ... | @@ -537,56 +891,113 @@ function updateThreadState( |
| 537 | threadId: ThreadId, | 891 | threadId: ThreadId, |
| 538 | updater: (thread: Thread) => Thread, | 892 | updater: (thread: Thread) => Thread, |
| 539 | ): AppState { | 893 | ): AppState { |
| 540 | let updatedThread: Thread | null = null; | 894 | const currentThread = getThread(state, threadId); |
| 541 | const threads = updateThread(state.threads, threadId, (thread) => { | 895 | if (!currentThread) { |
| 542 | const nextThread = updater(thread); | 896 | return state; |
| 543 | if (nextThread !== thread) { | 897 | } |
| 544 | updatedThread = nextThread; | 898 | const nextThread = updater(currentThread); |
| 545 | } | 899 | if (nextThread === currentThread) { |
| 546 | return nextThread; | ||
| 547 | }); | ||
| 548 | if (threads === state.threads || updatedThread === null) { | ||
| 549 | return state; | 900 | return state; |
| 550 | } | 901 | } |
| 902 | return writeThreadState(state, nextThread, currentThread); | ||
| 903 | } | ||
| 551 | 904 | ||
| 552 | const nextSummary = buildSidebarThreadSummary(updatedThread); | 905 | function buildProjectState( |
| 553 | const previousSummary = state.sidebarThreadsById[threadId]; | 906 | projects: ReadonlyArray<Project>, |
| 554 | const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) | 907 | ): Pick<AppState, "projectIds" | "projectById"> { |
| 555 | ? state.sidebarThreadsById | 908 | return { |
| 556 | : { | 909 | projectIds: projects.map((project) => project.id), |
| 557 | ...state.sidebarThreadsById, | 910 | projectById: Object.fromEntries( |
| 558 | [threadId]: nextSummary, | 911 | projects.map((project) => [project.id, project] as const), |
| 559 | }; | 912 | ) as Record<ProjectId, Project>, |
| 913 | }; | ||
| 914 | } | ||
| 560 | 915 | ||
| 561 | if (sidebarThreadsById === state.sidebarThreadsById) { | 916 | function buildThreadState( |
| 562 | return { | 917 | threads: ReadonlyArray<Thread>, |
| 563 | ...state, | 918 | ): Pick< |
| 564 | threads, | 919 | AppState, |
| 565 | }; | 920 | | "threadIds" |
| 921 | | "threadIdsByProjectId" | ||
| 922 | | "threadShellById" | ||
| 923 | | "threadSessionById" | ||
| 924 | | "threadTurnStateById" | ||
| 925 | | "messageIdsByThreadId" | ||
| 926 | | "messageByThreadId" | ||
| 927 | | "activityIdsByThreadId" | ||
| 928 | | "activityByThreadId" | ||
| 929 | | "proposedPlanIdsByThreadId" | ||
| 930 | | "proposedPlanByThreadId" | ||
| 931 | | "turnDiffIdsByThreadId" | ||
| 932 | | "turnDiffSummaryByThreadId" | ||
| 933 | | "sidebarThreadSummaryById" | ||
| 934 | > { | ||
| 935 | const threadIds: ThreadId[] = []; | ||
| 936 | const threadIdsByProjectId: Record<ProjectId, ThreadId[]> = {}; | ||
| 937 | const threadShellById: Record<ThreadId, ThreadShell> = {}; | ||
| 938 | const threadSessionById: Record<ThreadId, ThreadSession | null> = {}; | ||
| 939 | const threadTurnStateById: Record<ThreadId, ThreadTurnState> = {}; | ||
| 940 | const messageIdsByThreadId: Record<ThreadId, MessageId[]> = {}; | ||
| 941 | const messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>> = {}; | ||
| 942 | const activityIdsByThreadId: Record<ThreadId, string[]> = {}; | ||
| 943 | const activityByThreadId: Record<ThreadId, Record<string, OrchestrationThreadActivity>> = {}; | ||
| 944 | const proposedPlanIdsByThreadId: Record<ThreadId, string[]> = {}; | ||
| 945 | const proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>> = {}; | ||
| 946 | const turnDiffIdsByThreadId: Record<ThreadId, TurnId[]> = {}; | ||
| 947 | const turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>> = {}; | ||
| 948 | const sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary> = {}; | ||
| 949 | |||
| 950 | for (const thread of threads) { | ||
| 951 | threadIds.push(thread.id); | ||
| 952 | threadIdsByProjectId[thread.projectId] = [ | ||
| 953 | ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS), | ||
| 954 | thread.id, | ||
| 955 | ]; | ||
| 956 | threadShellById[thread.id] = toThreadShell(thread); | ||
| 957 | threadSessionById[thread.id] = thread.session; | ||
| 958 | threadTurnStateById[thread.id] = toThreadTurnState(thread); | ||
| 959 | const messageSlice = buildMessageSlice(thread); | ||
| 960 | messageIdsByThreadId[thread.id] = messageSlice.ids; | ||
| 961 | messageByThreadId[thread.id] = messageSlice.byId; | ||
| 962 | const activitySlice = buildActivitySlice(thread); | ||
| 963 | activityIdsByThreadId[thread.id] = activitySlice.ids; | ||
| 964 | activityByThreadId[thread.id] = activitySlice.byId; | ||
| 965 | const proposedPlanSlice = buildProposedPlanSlice(thread); | ||
| 966 | proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids; | ||
| 967 | proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId; | ||
| 968 | const turnDiffSlice = buildTurnDiffSlice(thread); | ||
| 969 | turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; | ||
| 970 | turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; | ||
| 971 | sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); | ||
| 566 | } | 972 | } |
| 567 | 973 | ||
| 568 | return { | 974 | return { |
| 569 | ...state, | 975 | threadIds, |
| 570 | threads, | 976 | threadIdsByProjectId, |
| 571 | sidebarThreadsById, | 977 | threadShellById, |
| 978 | threadSessionById, | ||
| 979 | threadTurnStateById, | ||
| 980 | messageIdsByThreadId, | ||
| 981 | messageByThreadId, | ||
| 982 | activityIdsByThreadId, | ||
| 983 | activityByThreadId, | ||
| 984 | proposedPlanIdsByThreadId, | ||
| 985 | proposedPlanByThreadId, | ||
| 986 | turnDiffIdsByThreadId, | ||
| 987 | turnDiffSummaryByThreadId, | ||
| 988 | sidebarThreadSummaryById, | ||
| 572 | }; | 989 | }; |
| 573 | } | 990 | } |
| 574 | 991 | ||
| 575 | // ── Pure state transition functions ──────────────────────────────────── | ||
| 576 | |||
| 577 | export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { | 992 | export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { |
| 578 | const projects = readModel.projects | 993 | const projects = readModel.projects |
| 579 | .filter((project) => project.deletedAt === null) | 994 | .filter((project) => project.deletedAt === null) |
| 580 | .map(mapProject); | 995 | .map(mapProject); |
| 581 | const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); | 996 | const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); |
| 582 | const sidebarThreadsById = buildSidebarThreadsById(threads); | ||
| 583 | const threadIdsByProjectId = buildThreadIdsByProjectId(threads); | ||
| 584 | return { | 997 | return { |
| 585 | ...state, | 998 | ...state, |
| 586 | projects, | 999 | ...buildProjectState(projects), |
| 587 | threads, | 1000 | ...buildThreadState(threads), |
| 588 | sidebarThreadsById, | ||
| 589 | threadIdsByProjectId, | ||
| 590 | bootstrapComplete: true, | 1001 | bootstrapComplete: true, |
| 591 | }; | 1002 | }; |
| 592 | } | 1003 | } |
| ... | @@ -594,10 +1005,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea | ... | @@ -594,10 +1005,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea |
| 594 | export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { | 1005 | export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { |
| 595 | switch (event.type) { | 1006 | switch (event.type) { |
| 596 | case "project.created": { | 1007 | case "project.created": { |
| 597 | const existingIndex = state.projects.findIndex( | ||
| 598 | (project) => | ||
| 599 | project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, | ||
| 600 | ); | ||
| 601 | const nextProject = mapProject({ | 1008 | const nextProject = mapProject({ |
| 602 | id: event.payload.projectId, | 1009 | id: event.payload.projectId, |
| 603 | title: event.payload.title, | 1010 | title: event.payload.title, |
| ... | @@ -608,17 +1015,48 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -608,17 +1015,48 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 608 | updatedAt: event.payload.updatedAt, | 1015 | updatedAt: event.payload.updatedAt, |
| 609 | deletedAt: null, | 1016 | deletedAt: null, |
| 610 | }); | 1017 | }); |
| 611 | const projects = | 1018 | const existingProjectId = |
| 612 | existingIndex >= 0 | 1019 | state.projectIds.find( |
| 613 | ? state.projects.map((project, index) => | 1020 | (projectId) => |
| 614 | index === existingIndex ? nextProject : project, | 1021 | projectId === event.payload.projectId || |
| 615 | ) | 1022 | state.projectById[projectId]?.cwd === event.payload.workspaceRoot, |
| 616 | : [...state.projects, nextProject]; | 1023 | ) ?? null; |
| 617 | return { ...state, projects }; | 1024 | let projectById = state.projectById; |
| 1025 | let projectIds = state.projectIds; | ||
| 1026 | |||
| 1027 | if (existingProjectId !== null && existingProjectId !== nextProject.id) { | ||
| 1028 | const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; | ||
| 1029 | projectById = { | ||
| 1030 | ...restProjectById, | ||
| 1031 | [nextProject.id]: nextProject, | ||
| 1032 | }; | ||
| 1033 | projectIds = state.projectIds.map((projectId) => | ||
| 1034 | projectId === existingProjectId ? nextProject.id : projectId, | ||
| 1035 | ); | ||
| 1036 | } else { | ||
| 1037 | projectById = { | ||
| 1038 | ...state.projectById, | ||
| 1039 | [nextProject.id]: nextProject, | ||
| 1040 | }; | ||
| 1041 | projectIds = | ||
| 1042 | existingProjectId === null && !state.projectIds.includes(nextProject.id) | ||
| 1043 | ? [...state.projectIds, nextProject.id] | ||
| 1044 | : state.projectIds; | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | return { | ||
| 1048 | ...state, | ||
| 1049 | projectById, | ||
| 1050 | projectIds, | ||
| 1051 | }; | ||
| 618 | } | 1052 | } |
| 619 | 1053 | ||
| 620 | case "project.meta-updated": { | 1054 | case "project.meta-updated": { |
| 621 | const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ | 1055 | const project = state.projectById[event.payload.projectId]; |
| 1056 | if (!project) { | ||
| 1057 | return state; | ||
| 1058 | } | ||
| 1059 | const nextProject: Project = { | ||
| 622 | ...project, | 1060 | ...project, |
| 623 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), | 1061 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), |
| 624 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), | 1062 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), |
| ... | @@ -633,17 +1071,30 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -633,17 +1071,30 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 633 | ? { scripts: mapProjectScripts(event.payload.scripts) } | 1071 | ? { scripts: mapProjectScripts(event.payload.scripts) } |
| 634 | : {}), | 1072 | : {}), |
| 635 | updatedAt: event.payload.updatedAt, | 1073 | updatedAt: event.payload.updatedAt, |
| 636 | })); | 1074 | }; |
| 637 | return projects === state.projects ? state : { ...state, projects }; | 1075 | return { |
| 1076 | ...state, | ||
| 1077 | projectById: { | ||
| 1078 | ...state.projectById, | ||
| 1079 | [event.payload.projectId]: nextProject, | ||
| 1080 | }, | ||
| 1081 | }; | ||
| 638 | } | 1082 | } |
| 639 | 1083 | ||
| 640 | case "project.deleted": { | 1084 | case "project.deleted": { |
| 641 | const projects = state.projects.filter((project) => project.id !== event.payload.projectId); | 1085 | if (!state.projectById[event.payload.projectId]) { |
| 642 | return projects.length === state.projects.length ? state : { ...state, projects }; | 1086 | return state; |
| 1087 | } | ||
| 1088 | const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; | ||
| 1089 | return { | ||
| 1090 | ...state, | ||
| 1091 | projectById, | ||
| 1092 | projectIds: removeId(state.projectIds, event.payload.projectId), | ||
| 1093 | }; | ||
| 643 | } | 1094 | } |
| 644 | 1095 | ||
| 645 | case "thread.created": { | 1096 | case "thread.created": { |
| 646 | const existing = state.threads.find((thread) => thread.id === event.payload.threadId); | 1097 | const previousThread = getThread(state, event.payload.threadId); |
| 647 | const nextThread = mapThread({ | 1098 | const nextThread = mapThread({ |
| 648 | id: event.payload.threadId, | 1099 | id: event.payload.threadId, |
| 649 | projectId: event.payload.projectId, | 1100 | projectId: event.payload.projectId, |
| ... | @@ -664,74 +1115,27 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -664,74 +1115,27 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 664 | checkpoints: [], | 1115 | checkpoints: [], |
| 665 | session: null, | 1116 | session: null, |
| 666 | }); | 1117 | }); |
| 667 | const threads = existing | 1118 | return writeThreadState(state, nextThread, previousThread); |
| 668 | ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) | ||
| 669 | : [...state.threads, nextThread]; | ||
| 670 | const nextSummary = buildSidebarThreadSummary(nextThread); | ||
| 671 | const previousSummary = state.sidebarThreadsById[nextThread.id]; | ||
| 672 | const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) | ||
| 673 | ? state.sidebarThreadsById | ||
| 674 | : { | ||
| 675 | ...state.sidebarThreadsById, | ||
| 676 | [nextThread.id]: nextSummary, | ||
| 677 | }; | ||
| 678 | const nextThreadIdsByProjectId = | ||
| 679 | existing !== undefined && existing.projectId !== nextThread.projectId | ||
| 680 | ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) | ||
| 681 | : state.threadIdsByProjectId; | ||
| 682 | const threadIdsByProjectId = appendThreadIdByProjectId( | ||
| 683 | nextThreadIdsByProjectId, | ||
| 684 | nextThread.projectId, | ||
| 685 | nextThread.id, | ||
| 686 | ); | ||
| 687 | return { | ||
| 688 | ...state, | ||
| 689 | threads, | ||
| 690 | sidebarThreadsById, | ||
| 691 | threadIdsByProjectId, | ||
| 692 | }; | ||
| 693 | } | 1119 | } |
| 694 | 1120 | ||
| 695 | case "thread.deleted": { | 1121 | case "thread.deleted": |
| 696 | const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); | 1122 | return removeThreadState(state, event.payload.threadId); |
| 697 | if (threads.length === state.threads.length) { | ||
| 698 | return state; | ||
| 699 | } | ||
| 700 | const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); | ||
| 701 | const sidebarThreadsById = { ...state.sidebarThreadsById }; | ||
| 702 | delete sidebarThreadsById[event.payload.threadId]; | ||
| 703 | const threadIdsByProjectId = deletedThread | ||
| 704 | ? removeThreadIdByProjectId( | ||
| 705 | state.threadIdsByProjectId, | ||
| 706 | deletedThread.projectId, | ||
| 707 | deletedThread.id, | ||
| 708 | ) | ||
| 709 | : state.threadIdsByProjectId; | ||
| 710 | return { | ||
| 711 | ...state, | ||
| 712 | threads, | ||
| 713 | sidebarThreadsById, | ||
| 714 | threadIdsByProjectId, | ||
| 715 | }; | ||
| 716 | } | ||
| 717 | 1123 | ||
| 718 | case "thread.archived": { | 1124 | case "thread.archived": |
| 719 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1125 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 720 | ...thread, | 1126 | ...thread, |
| 721 | archivedAt: event.payload.archivedAt, | 1127 | archivedAt: event.payload.archivedAt, |
| 722 | updatedAt: event.payload.updatedAt, | 1128 | updatedAt: event.payload.updatedAt, |
| 723 | })); | 1129 | })); |
| 724 | } | ||
| 725 | 1130 | ||
| 726 | case "thread.unarchived": { | 1131 | case "thread.unarchived": |
| 727 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1132 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 728 | ...thread, | 1133 | ...thread, |
| 729 | archivedAt: null, | 1134 | archivedAt: null, |
| 730 | updatedAt: event.payload.updatedAt, | 1135 | updatedAt: event.payload.updatedAt, |
| 731 | })); | 1136 | })); |
| 732 | } | ||
| 733 | 1137 | ||
| 734 | case "thread.meta-updated": { | 1138 | case "thread.meta-updated": |
| 735 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1139 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 736 | ...thread, | 1140 | ...thread, |
| 737 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | 1141 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), |
| ... | @@ -744,25 +1148,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -744,25 +1148,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 744 | : {}), | 1148 | : {}), |
| 745 | updatedAt: event.payload.updatedAt, | 1149 | updatedAt: event.payload.updatedAt, |
| 746 | })); | 1150 | })); |
| 747 | } | ||
| 748 | 1151 | ||
| 749 | case "thread.runtime-mode-set": { | 1152 | case "thread.runtime-mode-set": |
| 750 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1153 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 751 | ...thread, | 1154 | ...thread, |
| 752 | runtimeMode: event.payload.runtimeMode, | 1155 | runtimeMode: event.payload.runtimeMode, |
| 753 | updatedAt: event.payload.updatedAt, | 1156 | updatedAt: event.payload.updatedAt, |
| 754 | })); | 1157 | })); |
| 755 | } | ||
| 756 | 1158 | ||
| 757 | case "thread.interaction-mode-set": { | 1159 | case "thread.interaction-mode-set": |
| 758 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1160 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 759 | ...thread, | 1161 | ...thread, |
| 760 | interactionMode: event.payload.interactionMode, | 1162 | interactionMode: event.payload.interactionMode, |
| 761 | updatedAt: event.payload.updatedAt, | 1163 | updatedAt: event.payload.updatedAt, |
| 762 | })); | 1164 | })); |
| 763 | } | ||
| 764 | 1165 | ||
| 765 | case "thread.turn-start-requested": { | 1166 | case "thread.turn-start-requested": |
| 766 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1167 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 767 | ...thread, | 1168 | ...thread, |
| 768 | ...(event.payload.modelSelection !== undefined | 1169 | ...(event.payload.modelSelection !== undefined |
| ... | @@ -773,7 +1174,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -773,7 +1174,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 773 | pendingSourceProposedPlan: event.payload.sourceProposedPlan, | 1174 | pendingSourceProposedPlan: event.payload.sourceProposedPlan, |
| 774 | updatedAt: event.occurredAt, | 1175 | updatedAt: event.occurredAt, |
| 775 | })); | 1176 | })); |
| 776 | } | ||
| 777 | 1177 | ||
| 778 | case "thread.turn-interrupt-requested": { | 1178 | case "thread.turn-interrupt-requested": { |
| 779 | if (event.payload.turnId === undefined) { | 1179 | if (event.payload.turnId === undefined) { |
| ... | @@ -800,7 +1200,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -800,7 +1200,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 800 | }); | 1200 | }); |
| 801 | } | 1201 | } |
| 802 | 1202 | ||
| 803 | case "thread.message-sent": { | 1203 | case "thread.message-sent": |
| 804 | return updateThreadState(state, event.payload.threadId, (thread) => { | 1204 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 805 | const message = mapMessage({ | 1205 | const message = mapMessage({ |
| 806 | id: event.payload.messageId, | 1206 | id: event.payload.messageId, |
| ... | @@ -889,9 +1289,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -889,9 +1289,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 889 | updatedAt: event.occurredAt, | 1289 | updatedAt: event.occurredAt, |
| 890 | }; | 1290 | }; |
| 891 | }); | 1291 | }); |
| 892 | } | ||
| 893 | 1292 | ||
| 894 | case "thread.session-set": { | 1293 | case "thread.session-set": |
| 895 | return updateThreadState(state, event.payload.threadId, (thread) => ({ | 1294 | return updateThreadState(state, event.payload.threadId, (thread) => ({ |
| 896 | ...thread, | 1295 | ...thread, |
| 897 | session: mapSession(event.payload.session), | 1296 | session: mapSession(event.payload.session), |
| ... | @@ -920,9 +1319,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -920,9 +1319,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 920 | : thread.latestTurn, | 1319 | : thread.latestTurn, |
| 921 | updatedAt: event.occurredAt, | 1320 | updatedAt: event.occurredAt, |
| 922 | })); | 1321 | })); |
| 923 | } | ||
| 924 | 1322 | ||
| 925 | case "thread.session-stop-requested": { | 1323 | case "thread.session-stop-requested": |
| 926 | return updateThreadState(state, event.payload.threadId, (thread) => | 1324 | return updateThreadState(state, event.payload.threadId, (thread) => |
| 927 | thread.session === null | 1325 | thread.session === null |
| 928 | ? thread | 1326 | ? thread |
| ... | @@ -938,9 +1336,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -938,9 +1336,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 938 | updatedAt: event.occurredAt, | 1336 | updatedAt: event.occurredAt, |
| 939 | }, | 1337 | }, |
| 940 | ); | 1338 | ); |
| 941 | } | ||
| 942 | 1339 | ||
| 943 | case "thread.proposed-plan-upserted": { | 1340 | case "thread.proposed-plan-upserted": |
| 944 | return updateThreadState(state, event.payload.threadId, (thread) => { | 1341 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 945 | const proposedPlan = mapProposedPlan(event.payload.proposedPlan); | 1342 | const proposedPlan = mapProposedPlan(event.payload.proposedPlan); |
| 946 | const proposedPlans = [ | 1343 | const proposedPlans = [ |
| ... | @@ -958,9 +1355,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -958,9 +1355,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 958 | updatedAt: event.occurredAt, | 1355 | updatedAt: event.occurredAt, |
| 959 | }; | 1356 | }; |
| 960 | }); | 1357 | }); |
| 961 | } | ||
| 962 | 1358 | ||
| 963 | case "thread.turn-diff-completed": { | 1359 | case "thread.turn-diff-completed": |
| 964 | return updateThreadState(state, event.payload.threadId, (thread) => { | 1360 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 965 | const checkpoint = mapTurnDiffSummary({ | 1361 | const checkpoint = mapTurnDiffSummary({ |
| 966 | turnId: event.payload.turnId, | 1362 | turnId: event.payload.turnId, |
| ... | @@ -1007,9 +1403,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -1007,9 +1403,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 1007 | updatedAt: event.occurredAt, | 1403 | updatedAt: event.occurredAt, |
| 1008 | }; | 1404 | }; |
| 1009 | }); | 1405 | }); |
| 1010 | } | ||
| 1011 | 1406 | ||
| 1012 | case "thread.reverted": { | 1407 | case "thread.reverted": |
| 1013 | return updateThreadState(state, event.payload.threadId, (thread) => { | 1408 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 1014 | const turnDiffSummaries = thread.turnDiffSummaries | 1409 | const turnDiffSummaries = thread.turnDiffSummaries |
| 1015 | .filter( | 1410 | .filter( |
| ... | @@ -1059,9 +1454,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -1059,9 +1454,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 1059 | updatedAt: event.occurredAt, | 1454 | updatedAt: event.occurredAt, |
| 1060 | }; | 1455 | }; |
| 1061 | }); | 1456 | }); |
| 1062 | } | ||
| 1063 | 1457 | ||
| 1064 | case "thread.activity-appended": { | 1458 | case "thread.activity-appended": |
| 1065 | return updateThreadState(state, event.payload.threadId, (thread) => { | 1459 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 1066 | const activities = [ | 1460 | const activities = [ |
| 1067 | ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), | 1461 | ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), |
| ... | @@ -1075,7 +1469,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -1075,7 +1469,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 1075 | updatedAt: event.occurredAt, | 1469 | updatedAt: event.occurredAt, |
| 1076 | }; | 1470 | }; |
| 1077 | }); | 1471 | }); |
| 1078 | } | ||
| 1079 | 1472 | ||
| 1080 | case "thread.approval-response-requested": | 1473 | case "thread.approval-response-requested": |
| 1081 | case "thread.user-input-response-requested": | 1474 | case "thread.user-input-response-requested": |
| ... | @@ -1095,30 +1488,29 @@ export function applyOrchestrationEvents( | ... | @@ -1095,30 +1488,29 @@ export function applyOrchestrationEvents( |
| 1095 | return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); | 1488 | return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); |
| 1096 | } | 1489 | } |
| 1097 | 1490 | ||
| 1491 | export const selectProjects = (state: AppState): Project[] => getProjects(state); | ||
| 1492 | export const selectThreads = (state: AppState): Thread[] => getThreads(state); | ||
| 1098 | export const selectProjectById = | 1493 | export const selectProjectById = |
| 1099 | (projectId: Project["id"] | null | undefined) => | 1494 | (projectId: Project["id"] | null | undefined) => |
| 1100 | (state: AppState): Project | undefined => | 1495 | (state: AppState): Project | undefined => |
| 1101 | projectId ? state.projects.find((project) => project.id === projectId) : undefined; | 1496 | projectId ? state.projectById[projectId] : undefined; |
| 1102 | |||
| 1103 | export const selectThreadById = | 1497 | export const selectThreadById = |
| 1104 | (threadId: ThreadId | null | undefined) => | 1498 | (threadId: ThreadId | null | undefined) => |
| 1105 | (state: AppState): Thread | undefined => | 1499 | (state: AppState): Thread | undefined => |
| 1106 | threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; | 1500 | threadId ? getThread(state, threadId) : undefined; |
| 1107 | |||
| 1108 | export const selectSidebarThreadSummaryById = | 1501 | export const selectSidebarThreadSummaryById = |
| 1109 | (threadId: ThreadId | null | undefined) => | 1502 | (threadId: ThreadId | null | undefined) => |
| 1110 | (state: AppState): SidebarThreadSummary | undefined => | 1503 | (state: AppState): SidebarThreadSummary | undefined => |
| 1111 | threadId ? state.sidebarThreadsById[threadId] : undefined; | 1504 | threadId ? state.sidebarThreadSummaryById[threadId] : undefined; |
| 1112 | |||
| 1113 | export const selectThreadIdsByProjectId = | 1505 | export const selectThreadIdsByProjectId = |
| 1114 | (projectId: ProjectId | null | undefined) => | 1506 | (projectId: ProjectId | null | undefined) => |
| 1115 | (state: AppState): ThreadId[] => | 1507 | (state: AppState): ThreadId[] => |
| 1116 | projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; | 1508 | projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; |
| 1117 | 1509 | ||
| 1118 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { | 1510 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { |
| 1119 | return updateThreadState(state, threadId, (t) => { | 1511 | return updateThreadState(state, threadId, (thread) => { |
| 1120 | if (t.error === error) return t; | 1512 | if (thread.error === error) return thread; |
| 1121 | return { ...t, error }; | 1513 | return { ...thread, error }; |
| 1122 | }); | 1514 | }); |
| 1123 | } | 1515 | } |
| 1124 | 1516 | ||
| ... | @@ -1128,11 +1520,11 @@ export function setThreadBranch( | ... | @@ -1128,11 +1520,11 @@ export function setThreadBranch( |
| 1128 | branch: string | null, | 1520 | branch: string | null, |
| 1129 | worktreePath: string | null, | 1521 | worktreePath: string | null, |
| 1130 | ): AppState { | 1522 | ): AppState { |
| 1131 | return updateThreadState(state, threadId, (t) => { | 1523 | return updateThreadState(state, threadId, (thread) => { |
| 1132 | if (t.branch === branch && t.worktreePath === worktreePath) return t; | 1524 | if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; |
| 1133 | const cwdChanged = t.worktreePath !== worktreePath; | 1525 | const cwdChanged = thread.worktreePath !== worktreePath; |
| 1134 | return { | 1526 | return { |
| 1135 | ...t, | 1527 | ...thread, |
| 1136 | branch, | 1528 | branch, |
| 1137 | worktreePath, | 1529 | worktreePath, |
| 1138 | ...(cwdChanged ? { session: null } : {}), | 1530 | ...(cwdChanged ? { session: null } : {}), |
| ... | @@ -1140,8 +1532,6 @@ export function setThreadBranch( | ... | @@ -1140,8 +1532,6 @@ export function setThreadBranch( |
| 1140 | }); | 1532 | }); |
| 1141 | } | 1533 | } |
| 1142 | 1534 | ||
| 1143 | // ── Zustand store ──────────────────────────────────────────────────── | ||
| 1144 | |||
| 1145 | interface AppStore extends AppState { | 1535 | interface AppStore extends AppState { |
| 1146 | syncServerReadModel: (readModel: OrchestrationReadModel) => void; | 1536 | syncServerReadModel: (readModel: OrchestrationReadModel) => void; |
| 1147 | applyOrchestrationEvent: (event: OrchestrationEvent) => void; | 1537 | applyOrchestrationEvent: (event: OrchestrationEvent) => void; |
apps/web/src/storeSelectors.ts+139-19| ... | @@ -1,26 +1,146 @@ | ... | @@ -1,26 +1,146 @@ |
| 1 | import { type ThreadId } from "@t3tools/contracts"; | 1 | import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; |
| 2 | import { useMemo } from "react"; | 2 | import { type AppState } from "./store"; |
| 3 | import { | 3 | import { |
| 4 | selectProjectById, | 4 | type ChatMessage, |
| 5 | selectSidebarThreadSummaryById, | 5 | type Project, |
| 6 | selectThreadById, | 6 | type ProposedPlan, |
| 7 | useStore, | 7 | type SidebarThreadSummary, |
| 8 | } from "./store"; | 8 | type Thread, |
| 9 | import { type Project, type SidebarThreadSummary, type Thread } from "./types"; | 9 | type ThreadSession, |
| 10 | 10 | type ThreadTurnState, | |
| 11 | export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { | 11 | type TurnDiffSummary, |
| 12 | const selector = useMemo(() => selectProjectById(projectId), [projectId]); | 12 | } from "./types"; |
| 13 | return useStore(selector); | 13 | |
| 14 | const EMPTY_MESSAGES: ChatMessage[] = []; | ||
| 15 | const EMPTY_ACTIVITIES: Thread["activities"] = []; | ||
| 16 | const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; | ||
| 17 | const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; | ||
| 18 | |||
| 19 | function collectByIds<TKey extends string, TValue>( | ||
| 20 | ids: readonly TKey[] | undefined, | ||
| 21 | byId: Record<TKey, TValue> | undefined, | ||
| 22 | ): TValue[] { | ||
| 23 | if (!ids || ids.length === 0 || !byId) { | ||
| 24 | return []; | ||
| 25 | } | ||
| 26 | |||
| 27 | return ids.flatMap((id) => { | ||
| 28 | const value = byId[id]; | ||
| 29 | return value ? [value] : []; | ||
| 30 | }); | ||
| 14 | } | 31 | } |
| 15 | 32 | ||
| 16 | export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined { | 33 | export function createProjectSelector( |
| 17 | const selector = useMemo(() => selectThreadById(threadId), [threadId]); | 34 | projectId: ProjectId | null | undefined, |
| 18 | return useStore(selector); | 35 | ): (state: AppState) => Project | undefined { |
| 36 | return (state) => (projectId ? state.projectById[projectId] : undefined); | ||
| 37 | } | ||
| 38 | |||
| 39 | export function createSidebarThreadSummarySelector( | ||
| 40 | threadId: ThreadId | null | undefined, | ||
| 41 | ): (state: AppState) => SidebarThreadSummary | undefined { | ||
| 42 | return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); | ||
| 19 | } | 43 | } |
| 20 | 44 | ||
| 21 | export function useSidebarThreadSummaryById( | 45 | export function createThreadSelector( |
| 22 | threadId: ThreadId | null | undefined, | 46 | threadId: ThreadId | null | undefined, |
| 23 | ): SidebarThreadSummary | undefined { | 47 | ): (state: AppState) => Thread | undefined { |
| 24 | const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); | 48 | let previousShell: AppState["threadShellById"][ThreadId] | undefined; |
| 25 | return useStore(selector); | 49 | let previousSession: ThreadSession | null | undefined; |
| 50 | let previousTurnState: ThreadTurnState | undefined; | ||
| 51 | let previousMessageIds: MessageId[] | undefined; | ||
| 52 | let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; | ||
| 53 | let previousActivityIds: string[] | undefined; | ||
| 54 | let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; | ||
| 55 | let previousProposedPlanIds: string[] | undefined; | ||
| 56 | let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; | ||
| 57 | let previousTurnDiffIds: TurnId[] | undefined; | ||
| 58 | let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; | ||
| 59 | let previousThread: Thread | undefined; | ||
| 60 | |||
| 61 | return (state) => { | ||
| 62 | if (!threadId) { | ||
| 63 | return undefined; | ||
| 64 | } | ||
| 65 | |||
| 66 | const shell = state.threadShellById[threadId]; | ||
| 67 | if (!shell) { | ||
| 68 | return undefined; | ||
| 69 | } | ||
| 70 | |||
| 71 | const session = state.threadSessionById[threadId] ?? null; | ||
| 72 | const turnState = state.threadTurnStateById[threadId]; | ||
| 73 | const messageIds = state.messageIdsByThreadId[threadId]; | ||
| 74 | const messageById = state.messageByThreadId[threadId]; | ||
| 75 | const activityIds = state.activityIdsByThreadId[threadId]; | ||
| 76 | const activityById = state.activityByThreadId[threadId]; | ||
| 77 | const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; | ||
| 78 | const proposedPlanById = state.proposedPlanByThreadId[threadId]; | ||
| 79 | const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; | ||
| 80 | const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; | ||
| 81 | |||
| 82 | if ( | ||
| 83 | previousThread && | ||
| 84 | previousShell === shell && | ||
| 85 | previousSession === session && | ||
| 86 | previousTurnState === turnState && | ||
| 87 | previousMessageIds === messageIds && | ||
| 88 | previousMessagesById === messageById && | ||
| 89 | previousActivityIds === activityIds && | ||
| 90 | previousActivitiesById === activityById && | ||
| 91 | previousProposedPlanIds === proposedPlanIds && | ||
| 92 | previousProposedPlansById === proposedPlanById && | ||
| 93 | previousTurnDiffIds === turnDiffIds && | ||
| 94 | previousTurnDiffsById === turnDiffById | ||
| 95 | ) { | ||
| 96 | return previousThread; | ||
| 97 | } | ||
| 98 | |||
| 99 | const nextThread: Thread = { | ||
| 100 | ...shell, | ||
| 101 | session, | ||
| 102 | latestTurn: turnState?.latestTurn ?? null, | ||
| 103 | pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, | ||
| 104 | messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[] | ||
| 105 | ? ChatMessage[] | ||
| 106 | : never, | ||
| 107 | activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array< | ||
| 108 | infer _ | ||
| 109 | > | ||
| 110 | ? Thread["activities"] | ||
| 111 | : never, | ||
| 112 | proposedPlans: collectByIds( | ||
| 113 | proposedPlanIds, | ||
| 114 | proposedPlanById, | ||
| 115 | ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never, | ||
| 116 | turnDiffSummaries: collectByIds( | ||
| 117 | turnDiffIds, | ||
| 118 | turnDiffById, | ||
| 119 | ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never, | ||
| 120 | }; | ||
| 121 | |||
| 122 | previousShell = shell; | ||
| 123 | previousSession = session; | ||
| 124 | previousTurnState = turnState; | ||
| 125 | previousMessageIds = messageIds; | ||
| 126 | previousMessagesById = messageById; | ||
| 127 | previousActivityIds = activityIds; | ||
| 128 | previousActivitiesById = activityById; | ||
| 129 | previousProposedPlanIds = proposedPlanIds; | ||
| 130 | previousProposedPlansById = proposedPlanById; | ||
| 131 | previousTurnDiffIds = turnDiffIds; | ||
| 132 | previousTurnDiffsById = turnDiffById; | ||
| 133 | previousThread = { | ||
| 134 | ...nextThread, | ||
| 135 | messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages, | ||
| 136 | activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities, | ||
| 137 | proposedPlans: | ||
| 138 | nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans, | ||
| 139 | turnDiffSummaries: | ||
| 140 | nextThread.turnDiffSummaries.length === 0 | ||
| 141 | ? EMPTY_TURN_DIFF_SUMMARIES | ||
| 142 | : nextThread.turnDiffSummaries, | ||
| 143 | }; | ||
| 144 | return previousThread; | ||
| 145 | }; | ||
| 26 | } | 146 | } |
apps/web/src/types.ts+21| ... | @@ -111,6 +111,27 @@ export interface Thread { | ... | @@ -111,6 +111,27 @@ export interface Thread { |
| 111 | activities: OrchestrationThreadActivity[]; | 111 | activities: OrchestrationThreadActivity[]; |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | export interface ThreadShell { | ||
| 115 | id: ThreadId; | ||
| 116 | codexThreadId: string | null; | ||
| 117 | projectId: ProjectId; | ||
| 118 | title: string; | ||
| 119 | modelSelection: ModelSelection; | ||
| 120 | runtimeMode: RuntimeMode; | ||
| 121 | interactionMode: ProviderInteractionMode; | ||
| 122 | error: string | null; | ||
| 123 | createdAt: string; | ||
| 124 | archivedAt: string | null; | ||
| 125 | updatedAt?: string | undefined; | ||
| 126 | branch: string | null; | ||
| 127 | worktreePath: string | null; | ||
| 128 | } | ||
| 129 | |||
| 130 | export interface ThreadTurnState { | ||
| 131 | latestTurn: OrchestrationLatestTurn | null; | ||
| 132 | pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; | ||
| 133 | } | ||
| 134 | |||
| 114 | export interface SidebarThreadSummary { | 135 | export interface SidebarThreadSummary { |
| 115 | id: ThreadId; | 136 | id: ThreadId; |
| 116 | projectId: ProjectId; | 137 | projectId: ProjectId; |