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