authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-04-09 01:25:49+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-08 17:25:49-07:00
log1ec346c22e2a4df96721502c66c16c98b1f4f14d
treeb80bd7059a745aca78c308ea08bc351f39649f1a
parent28e481eb24dc7e790b6d1ea963f20024b6a2bbc4
signature Signed by PGP key B5690EEEBB952194

Refactor web stores into atomic slices ready to split ChatView (#1708)


21 files changed, 1329 insertions(+), 544 deletions(-)

CLAUDE.md+1-1
......@@ -1 +1 @@
1AGENTS.md
\ No newline at end of file
1AGENTS.md
apps/web/src/components/BranchToolbar.tsx+7-7
......@@ -34,15 +34,15 @@ export default function BranchToolbar({
3434 onCheckoutPullRequestRequest,
3535 onComposerFocusRequest,
3636}: 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);
3939 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
4040 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
4141 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
42
43 const serverThread = threads.find((thread) => thread.id === threadId);
4442 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 );
4646 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
4747 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
4848 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
......@@ -60,7 +60,7 @@ export default function BranchToolbar({
6060 const api = readNativeApi();
6161 // If the effective cwd is about to change, stop the running session so the
6262 // next message creates a new one with the correct cwd.
63 if (serverThread?.session && worktreePath !== activeWorktreePath && api) {
63 if (serverSession && worktreePath !== activeWorktreePath && api) {
6464 void api.orchestration
6565 .dispatchCommand({
6666 type: "thread.session.stop",
......@@ -96,7 +96,7 @@ export default function BranchToolbar({
9696 },
9797 [
9898 activeThreadId,
99 serverThread?.session,
99 serverSession,
100100 activeWorktreePath,
101101 hasServerThread,
102102 setThreadBranchAction,
apps/web/src/components/ChatView.browser.tsx+16-2
......@@ -1176,8 +1176,22 @@ describe("ChatView timeline estimator parity (full app)", () => {
11761176 stickyActiveProvider: null,
11771177 });
11781178 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: {},
11811195 bootstrapComplete: false,
11821196 });
11831197 useTerminalStateStore.persist.clearStorage();
apps/web/src/components/ChatView.logic.test.ts+140-64
......@@ -1,6 +1,7 @@
11import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
22import { afterEach, describe, expect, it, vi } from "vitest";
33import { useStore } from "../store";
4import { type Thread } from "../types";
45
56import {
67 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
......@@ -178,7 +179,7 @@ const makeThread = (input?: {
178179 startedAt: string | null;
179180 completedAt: string | null;
180181 } | null;
181}) => ({
182}): Thread => ({
182183 id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
183184 codexThreadId: null,
184185 projectId: ProjectId.makeUnsafe("project-1"),
......@@ -205,94 +206,172 @@ const makeThread = (input?: {
205206 activities: [],
206207});
207208
209function 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
208307afterEach(() => {
209308 vi.useRealTimers();
210309 vi.restoreAllMocks();
211 useStore.setState((state) => ({
212 ...state,
213 projects: [],
214 threads: [],
215 bootstrapComplete: true,
216 }));
310 setStoreThreads([]);
217311});
218312
219313describe("waitForStartedServerThread", () => {
220314 it("resolves immediately when the thread is already started", async () => {
221315 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 ]);
237328
238329 await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);
239330 });
240331
241332 it("waits for the thread to start via subscription updates", async () => {
242333 const threadId = ThreadId.makeUnsafe("thread-wait");
243 useStore.setState((state) => ({
244 ...state,
245 threads: [makeThread({ id: threadId })],
246 }));
334 setStoreThreads([makeThread({ id: threadId })]);
247335
248336 const promise = waitForStartedServerThread(threadId, 500);
249337
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 ]);
265350
266351 await expect(promise).resolves.toBe(true);
267352 });
268353
269354 it("handles the thread starting between the initial read and subscription setup", async () => {
270355 const threadId = ThreadId.makeUnsafe("thread-race");
271 useStore.setState((state) => ({
272 ...state,
273 threads: [makeThread({ id: threadId })],
274 }));
356 setStoreThreads([makeThread({ id: threadId })]);
275357
276358 const originalSubscribe = useStore.subscribe.bind(useStore);
277359 let raced = false;
278360 vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {
279361 if (!raced) {
280362 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 ]);
296375 }
297376 return originalSubscribe(listener);
298377 });
......@@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => {
304383 vi.useFakeTimers();
305384
306385 const threadId = ThreadId.makeUnsafe("thread-timeout");
307 useStore.setState((state) => ({
308 ...state,
309 threads: [makeThread({ id: threadId })],
310 }));
386 setStoreThreads([makeThread({ id: threadId })]);
311387 const promise = waitForStartedServerThread(threadId, 500);
312388
313389 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 }
33import { randomUUID } from "~/lib/utils";
44import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
55import { Schema } from "effect";
6import { useStore } from "../store";
6import { selectThreadById, useStore } from "../store";
77import {
88 filterTerminalContextsWithText,
99 stripInlineTerminalContextPlaceholders,
......@@ -202,7 +202,7 @@ export async function waitForStartedServerThread(
202202 threadId: ThreadId,
203203 timeoutMs = 1_000,
204204): Promise<boolean> {
205 const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId);
205 const getThread = () => selectThreadById(threadId)(useStore.getState());
206206 const thread = getThread();
207207
208208 if (threadHasStarted(thread)) {
......@@ -225,7 +225,7 @@ export async function waitForStartedServerThread(
225225 };
226226
227227 const unsubscribe = useStore.subscribe((state) => {
228 if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) {
228 if (!threadHasStarted(selectThreadById(threadId)(state))) {
229229 return;
230230 }
231231 finish(true);
apps/web/src/components/ChatView.tsx+36-78
......@@ -65,7 +65,7 @@ import {
6565 type PendingUserInputDraftAnswer,
6666} from "../pendingUserInput";
6767import { useStore } from "../store";
68import { useProjectById, useThreadById } from "../storeSelectors";
68import { createThreadSelector } from "../storeSelectors";
6969import { useUiStateStore } from "../uiStateStore";
7070import {
7171 buildPlanImplementationThreadTitle,
......@@ -83,8 +83,6 @@ import {
8383 type Thread,
8484 type TurnDiffSummary,
8585} from "../types";
86import { LRUCache } from "../lib/lruCache";
87
8886import { basenameOfPath } from "../vscode-icons";
8987import { useTheme } from "../hooks/useTheme";
9088import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
......@@ -211,77 +209,28 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
211209
212210type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
213211
214const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500;
215const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024;
216const 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
221function 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
237function 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
258212function 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);
262216
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 }
275223
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 }) ?? [];
283229
284 return useStore(selector);
230 return [{ id: threadId, proposedPlans }];
231 }),
232 [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById],
233 );
285234}
286235
287236function formatOutgoingPrompt(params: {
......@@ -429,11 +378,17 @@ function PersistentThreadTerminalDrawer({
429378 closeShortcutLabel,
430379 onAddTerminalContext,
431380}: PersistentThreadTerminalDrawerProps) {
432 const serverThread = useThreadById(threadId);
381 const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));
433382 const draftThread = useComposerDraftStore(
434383 (store) => store.draftThreadsByThreadId[threadId] ?? null,
435384 );
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 );
437392 const terminalState = useTerminalStateStore((state) =>
438393 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
439394 );
......@@ -576,7 +531,7 @@ function PersistentThreadTerminalDrawer({
576531}
577532
578533export default function ChatView({ threadId }: ChatViewProps) {
579 const serverThread = useThreadById(threadId);
534 const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));
580535 const setStoreThreadError = useStore((store) => store.setError);
581536 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
582537 const activeThreadLastVisitedAt = useUiStateStore(
......@@ -751,14 +706,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
751706 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
752707 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
753708 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
709 const serverThreadIds = useStore((state) => state.threadIds);
754710 const storeServerTerminalLaunchContext = useTerminalStateStore(
755711 (s) => s.terminalLaunchContextByThreadId[threadId] ?? null,
756712 );
757713 const storeClearTerminalLaunchContext = useTerminalStateStore(
758714 (s) => s.clearTerminalLaunchContext,
759715 );
760 const threads = useStore((state) => state.threads);
761 const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]);
762716 const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
763717 const draftThreadIds = useMemo(
764718 () => Object.keys(draftThreadsByThreadId) as ThreadId[],
......@@ -819,7 +773,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
819773 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],
820774 );
821775
822 const fallbackDraftProject = useProjectById(draftThread?.projectId);
776 const fallbackDraftProject = useStore((state) =>
777 draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined,
778 );
823779 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
824780 const localDraftThread = useMemo(
825781 () =>
......@@ -884,7 +840,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
884840 });
885841 }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]);
886842 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 );
888846
889847 const openPullRequestDialog = useCallback(
890848 (reference?: string) => {
......@@ -1603,7 +1561,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16031561 (targetThreadId: ThreadId | null, error: string | null) => {
16041562 if (!targetThreadId) return;
16051563 const nextError = sanitizeThreadErrorMessage(error);
1606 if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) {
1564 if (useStore.getState().threadShellById[targetThreadId] !== undefined) {
16071565 setStoreThreadError(targetThreadId, nextError);
16081566 return;
16091567 }
apps/web/src/components/DiffPanel.tsx+4-3
......@@ -30,6 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
3030import { resolveDiffThemeName } from "../lib/diffRendering";
3131import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
3232import { useStore } from "../store";
33import { createThreadSelector } from "../storeSelectors";
3334import { useSettings } from "../hooks/useSettings";
3435import { formatShortTimestamp } from "../timestampFormat";
3536import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
......@@ -181,12 +182,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
181182 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
182183 const diffOpen = diffSearch.diff === "1";
183184 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]),
186187 );
187188 const activeProjectId = activeThread?.projectId ?? null;
188189 const activeProject = useStore((store) =>
189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190 activeProjectId ? store.projectById[activeProjectId] : undefined,
190191 );
191192 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192193 const gitStatusQuery = useGitStatus(activeCwd ?? null);
apps/web/src/components/GitActionsControl.browser.tsx+4-4
......@@ -141,10 +141,10 @@ vi.mock("~/store", () => ({
141141 useStore: (selector: (state: unknown) => unknown) =>
142142 selector({
143143 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 },
148148 }),
149149}));
150150
apps/web/src/components/GitActionsControl.tsx+1-1
......@@ -212,7 +212,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
212212 [activeThreadId],
213213 );
214214 const activeServerThread = useStore((store) =>
215 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
215 activeThreadId ? store.threadShellById[activeThreadId] : undefined,
216216 );
217217 const setThreadBranch = useStore((store) => store.setThreadBranch);
218218 const queryClient = useQueryClient();
apps/web/src/components/KeybindingsToast.browser.tsx+16-2
......@@ -356,8 +356,22 @@ describe("Keybindings update toast", () => {
356356 projectDraftThreadIdByProjectId: {},
357357 });
358358 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: {},
361375 bootstrapComplete: false,
362376 });
363377 });
apps/web/src/components/Sidebar.tsx+12-4
......@@ -128,7 +128,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
128128import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
129129import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
130130import { useServerKeybindings } from "../rpc/serverState";
131import { useSidebarThreadSummaryById } from "../storeSelectors";
132131import type { Project } from "../types";
133132const THREAD_PREVIEW_LIMIT = 6;
134133const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
......@@ -292,7 +291,7 @@ interface SidebarThreadRowProps {
292291}
293292
294293function SidebarThreadRow(props: SidebarThreadRowProps) {
295 const thread = useSidebarThreadSummaryById(props.threadId);
294 const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]);
296295 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]);
297296 const runningTerminalIds = useTerminalStateStore(
298297 (state) =>
......@@ -674,8 +673,9 @@ function SortableProjectItem({
674673}
675674
676675export 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);
679679 const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId);
680680 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
681681 useShallow((store) => ({
......@@ -741,6 +741,14 @@ export default function Sidebar() {
741741 const platform = navigator.platform;
742742 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
743743 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 );
744752 const orderedProjects = useMemo(() => {
745753 return orderItemsByPreferredIds({
746754 items: projects,
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-1
......@@ -95,7 +95,7 @@ describe("MessagesTimeline", () => {
9595 expect(markup).toContain("Terminal 1 lines 1-5");
9696 expect(markup).toContain("lucide-terminal");
9797 expect(markup).toContain("yoo what&#x27;s ");
98 });
98 }, 10_000);
9999
100100 it("renders context compaction entries in the normal work log", async () => {
101101 const { MessagesTimeline } = await import("./MessagesTimeline");
apps/web/src/components/settings/SettingsPanels.tsx+14-5
......@@ -1479,12 +1479,21 @@ export function GeneralSettingsPanel() {
14791479}
14801480
14811481export 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);
14841486 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
14851487 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
14881497 .map((project) => ({
14891498 project,
14901499 threads: threads
......@@ -1496,7 +1505,7 @@ export function ArchivedThreadsPanel() {
14961505 }),
14971506 }))
14981507 .filter((group) => group.threads.length > 0);
1499 }, [projects, threads]);
1508 }, [projectById, projectIds, threadIds, threadShellById]);
15001509
15011510 const handleArchivedThreadContextMenu = useCallback(
15021511 async (threadId: ThreadId, position: { x: number; y: number }) => {
apps/web/src/hooks/useHandleNewThread.ts+5-3
......@@ -10,18 +10,20 @@ import {
1010import { newThreadId } from "../lib/utils";
1111import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
1212import { useStore } from "../store";
13import { useThreadById } from "../storeSelectors";
13import { createThreadSelector } from "../storeSelectors";
1414import { useUiStateStore } from "../uiStateStore";
1515
1616export function useHandleNewThread() {
17 const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id)));
17 const projectIds = useStore(useShallow((store) => store.projectIds));
1818 const projectOrder = useUiStateStore((store) => store.projectOrder);
1919 const navigate = useNavigate();
2020 const routeThreadId = useParams({
2121 strict: false,
2222 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
2323 });
24 const activeThread = useThreadById(routeThreadId);
24 const activeThread = useStore(
25 useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]),
26 );
2527 const activeDraftThread = useComposerDraftStore((store) =>
2628 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
2729 );
apps/web/src/hooks/useThreadActions.ts+7-6
......@@ -9,7 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread";
99import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
1010import { newCommandId } from "../lib/utils";
1111import { readNativeApi } from "../nativeApi";
12import { useStore } from "../store";
12import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store";
1313import { useTerminalStateStore } from "../terminalStateStore";
1414import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1515import { toastManager } from "../components/ui/toast";
......@@ -35,7 +35,7 @@ export function useThreadActions() {
3535 async (threadId: ThreadId) => {
3636 const api = readNativeApi();
3737 if (!api) return;
38 const thread = useStore.getState().threads.find((entry) => entry.id === threadId);
38 const thread = selectThreadById(threadId)(useStore.getState());
3939 if (!thread) return;
4040 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
4141 throw new Error("Cannot archive a running thread.");
......@@ -68,10 +68,11 @@ export function useThreadActions() {
6868 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
6969 const api = readNativeApi();
7070 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);
7374 if (!thread) return;
74 const threadProject = projects.find((project) => project.id === thread.projectId);
75 const threadProject = selectProjectById(thread.projectId)(state);
7576 const deletedIds = opts.deletedThreadIds;
7677 const survivingThreads =
7778 deletedIds && deletedIds.size > 0
......@@ -179,7 +180,7 @@ export function useThreadActions() {
179180 async (threadId: ThreadId) => {
180181 const api = readNativeApi();
181182 if (!api) return;
182 const thread = useStore.getState().threads.find((entry) => entry.id === threadId);
183 const thread = selectThreadById(threadId)(useStore.getState());
183184 if (!thread) return;
184185
185186 if (appSettings.confirmThreadDelete) {
apps/web/src/routes/__root.tsx+8-7
......@@ -1,7 +1,7 @@
11import {
22 OrchestrationEvent,
33 type ServerLifecycleWelcomePayload,
4 type ThreadId,
4 ThreadId,
55} from "@t3tools/contracts";
66import {
77 Outlet,
......@@ -38,7 +38,7 @@ import {
3838 clearPromotedDraftThreads,
3939 useComposerDraftStore,
4040} from "../composerDraftStore";
41import { useStore } from "../store";
41import { selectProjects, selectThreadById, selectThreads, useStore } from "../store";
4242import { useUiStateStore } from "../uiStateStore";
4343import { useTerminalStateStore } from "../terminalStateStore";
4444import { migrateLocalSettingsToServer } from "../hooks/useSettings";
......@@ -328,8 +328,9 @@ function EventRouter() {
328328 let flushPendingDomainEventsScheduled = false;
329329
330330 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);
333334 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
334335 syncThreads(
335336 threads.map((thread) => ({
......@@ -392,14 +393,14 @@ function EventRouter() {
392393
393394 applyOrchestrationEvents(uiEvents);
394395 if (needsProjectUiSync) {
395 const projects = useStore.getState().projects;
396 const projects = selectProjects(useStore.getState());
396397 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
397398 }
398399 const needsThreadUiSync = nextEvents.some(
399400 (event) => event.type === "thread.created" || event.type === "thread.deleted",
400401 );
401402 if (needsThreadUiSync) {
402 const threads = useStore.getState().threads;
403 const threads = selectThreads(useStore.getState());
403404 syncThreads(
404405 threads.map((thread) => ({
405406 id: thread.id,
......@@ -555,7 +556,7 @@ function EventRouter() {
555556 },
556557 );
557558 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());
559560 if (thread && thread.archivedAt !== null) {
560561 return;
561562 }
apps/web/src/routes/_chat.$threadId.tsx+1-1
......@@ -167,7 +167,7 @@ function ChatThreadRouteView() {
167167 select: (params) => ThreadId.makeUnsafe(params.threadId),
168168 });
169169 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);
171171 const draftThreadExists = useComposerDraftStore((store) =>
172172 Object.hasOwn(store.draftThreadsByThreadId, threadId),
173173 );
apps/web/src/store.test.ts+244-74
......@@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest";
1414import {
1515 applyOrchestrationEvent,
1616 applyOrchestrationEvents,
17 selectProjects,
18 selectThreads,
1719 syncServerReadModel,
1820 type AppState,
1921} from "./store";
......@@ -47,29 +49,125 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
4749}
4850
4951function 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 };
5065 const threadIdsByProjectId: AppState["threadIdsByProjectId"] = {
5166 [thread.projectId]: [thread.id],
5267 };
5368 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],
6874 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
140function 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: {},
69158 bootstrapComplete: true,
159 ...overrides,
70160 };
71161}
72162
163function projectsOf(state: AppState) {
164 return selectProjects(state);
165}
166
167function threadsOf(state: AppState) {
168 return selectThreads(state);
169}
170
73171function makeEvent<T extends OrchestrationEvent["type"]>(
74172 type: T,
75173 payload: Extract<OrchestrationEvent, { type: T }>["payload"],
......@@ -191,7 +289,7 @@ describe("store read model sync", () => {
191289
192290 const next = syncServerReadModel(initialState, readModel);
193291
194 expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6");
292 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6");
195293 });
196294
197295 it("resolves claude aliases when session provider is claudeAgent", () => {
......@@ -216,7 +314,7 @@ describe("store read model sync", () => {
216314
217315 const next = syncServerReadModel(initialState, readModel);
218316
219 expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
317 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
220318 });
221319
222320 it("preserves project and thread updatedAt timestamps from the read model", () => {
......@@ -229,8 +327,8 @@ describe("store read model sync", () => {
229327
230328 const next = syncServerReadModel(initialState, readModel);
231329
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");
234332 });
235333
236334 it("maps archivedAt from the read model", () => {
......@@ -245,16 +343,17 @@ describe("store read model sync", () => {
245343 ),
246344 );
247345
248 expect(next.threads[0]?.archivedAt).toBe(archivedAt);
346 expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt);
249347 });
250348
251349 it("replaces projects using snapshot order during recovery", () => {
252350 const project1 = ProjectId.makeUnsafe("project-1");
253351 const project2 = ProjectId.makeUnsafe("project-2");
254352 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]: {
258357 id: project2,
259358 name: "Project 2",
260359 cwd: "/tmp/project-2",
......@@ -262,9 +361,11 @@ describe("store read model sync", () => {
262361 provider: "codex",
263362 model: DEFAULT_MODEL_BY_PROVIDER.codex,
264363 },
364 createdAt: "2026-02-27T00:00:00.000Z",
365 updatedAt: "2026-02-27T00:00:00.000Z",
265366 scripts: [],
266367 },
267 {
368 [project1]: {
268369 id: project1,
269370 name: "Project 1",
270371 cwd: "/tmp/project-1",
......@@ -272,14 +373,12 @@ describe("store read model sync", () => {
272373 provider: "codex",
273374 model: DEFAULT_MODEL_BY_PROVIDER.codex,
274375 },
376 createdAt: "2026-02-27T00:00:00.000Z",
377 updatedAt: "2026-02-27T00:00:00.000Z",
275378 scripts: [],
276379 },
277 ],
278 threads: [],
279 sidebarThreadsById: {},
280 threadIdsByProjectId: {},
281 bootstrapComplete: true,
282 };
380 },
381 });
283382 const readModel: OrchestrationReadModel = {
284383 snapshotSequence: 2,
285384 updatedAt: "2026-02-27T00:00:00.000Z",
......@@ -305,7 +404,7 @@ describe("store read model sync", () => {
305404
306405 const next = syncServerReadModel(initialState, readModel);
307406
308 expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]);
407 expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]);
309408 });
310409});
311410
......@@ -345,8 +444,8 @@ describe("incremental orchestration updates", () => {
345444 }),
346445 );
347446
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");
350449 });
351450
352451 it("preserves state identity for no-op project and thread deletes", () => {
......@@ -375,9 +474,10 @@ describe("incremental orchestration updates", () => {
375474 it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => {
376475 const originalProjectId = ProjectId.makeUnsafe("project-1");
377476 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]: {
381481 id: originalProjectId,
382482 name: "Project",
383483 cwd: "/tmp/project",
......@@ -385,14 +485,12 @@ describe("incremental orchestration updates", () => {
385485 provider: "codex",
386486 model: DEFAULT_MODEL_BY_PROVIDER.codex,
387487 },
488 createdAt: "2026-02-27T00:00:00.000Z",
489 updatedAt: "2026-02-27T00:00:00.000Z",
388490 scripts: [],
389491 },
390 ],
391 threads: [],
392 sidebarThreadsById: {},
393 threadIdsByProjectId: {},
394 bootstrapComplete: true,
395 };
492 },
493 });
396494
397495 const next = applyOrchestrationEvent(
398496 state,
......@@ -410,10 +508,13 @@ describe("incremental orchestration updates", () => {
410508 }),
411509 );
412510
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);
417518 });
418519
419520 it("removes stale project index entries when thread.created recreates a thread under a new project", () => {
......@@ -425,8 +526,10 @@ describe("incremental orchestration updates", () => {
425526 projectId: originalProjectId,
426527 });
427528 const state: AppState = {
428 projects: [
429 {
529 ...makeState(thread),
530 projectIds: [originalProjectId, recreatedProjectId],
531 projectById: {
532 [originalProjectId]: {
430533 id: originalProjectId,
431534 name: "Project 1",
432535 cwd: "/tmp/project-1",
......@@ -434,9 +537,11 @@ describe("incremental orchestration updates", () => {
434537 provider: "codex",
435538 model: DEFAULT_MODEL_BY_PROVIDER.codex,
436539 },
540 createdAt: "2026-02-27T00:00:00.000Z",
541 updatedAt: "2026-02-27T00:00:00.000Z",
437542 scripts: [],
438543 },
439 {
544 [recreatedProjectId]: {
440545 id: recreatedProjectId,
441546 name: "Project 2",
442547 cwd: "/tmp/project-2",
......@@ -444,15 +549,11 @@ describe("incremental orchestration updates", () => {
444549 provider: "codex",
445550 model: DEFAULT_MODEL_BY_PROVIDER.codex,
446551 },
552 createdAt: "2026-02-27T00:00:00.000Z",
553 updatedAt: "2026-02-27T00:00:00.000Z",
447554 scripts: [],
448555 },
449 ],
450 threads: [thread],
451 sidebarThreadsById: {},
452 threadIdsByProjectId: {
453 [originalProjectId]: [threadId],
454556 },
455 bootstrapComplete: true,
456557 };
457558
458559 const next = applyOrchestrationEvent(
......@@ -474,8 +575,8 @@ describe("incremental orchestration updates", () => {
474575 }),
475576 );
476577
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);
479580 expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined();
480581 expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]);
481582 });
......@@ -498,7 +599,73 @@ describe("incremental orchestration updates", () => {
498599 const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") });
499600 const state: AppState = {
500601 ...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 },
502669 };
503670
504671 const next = applyOrchestrationEvent(
......@@ -515,9 +682,12 @@ describe("incremental orchestration updates", () => {
515682 }),
516683 );
517684
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]);
521691 });
522692
523693 it("applies replay batches in sequence and updates session state", () => {
......@@ -566,9 +736,9 @@ describe("incremental orchestration updates", () => {
566736 ),
567737 ]);
568738
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);
572742 });
573743
574744 it("does not regress latestTurn when an older turn diff completes late", () => {
......@@ -599,8 +769,8 @@ describe("incremental orchestration updates", () => {
599769 }),
600770 );
601771
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);
604774 });
605775
606776 it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => {
......@@ -643,10 +813,10 @@ describe("incremental orchestration updates", () => {
643813 }),
644814 );
645815
646 expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(
816 expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(
647817 MessageId.makeUnsafe("assistant-real"),
648818 );
649 expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe(
819 expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe(
650820 MessageId.makeUnsafe("assistant-real"),
651821 );
652822 });
......@@ -752,15 +922,15 @@ describe("incremental orchestration updates", () => {
752922 }),
753923 );
754924
755 expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([
925 expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([
756926 "user-1",
757927 "assistant-1",
758928 ]);
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([
761931 EventId.makeUnsafe("activity-1"),
762932 ]);
763 expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
933 expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
764934 TurnId.makeUnsafe("turn-1"),
765935 ]);
766936 });
......@@ -810,7 +980,7 @@ describe("incremental orchestration updates", () => {
810980 }),
811981 );
812982
813 expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined();
983 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();
814984
815985 const next = applyOrchestrationEvent(
816986 reverted,
......@@ -828,10 +998,10 @@ describe("incremental orchestration updates", () => {
828998 }),
829999 );
8301000
831 expect(next.threads[0]?.latestTurn).toMatchObject({
1001 expect(threadsOf(next)[0]?.latestTurn).toMatchObject({
8321002 turnId: TurnId.makeUnsafe("turn-3"),
8331003 state: "running",
8341004 });
835 expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined();
1005 expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined();
8361006 });
8371007});
apps/web/src/store.ts+649-259
......@@ -1,84 +1,101 @@
11import {
2 type MessageId,
3 type OrchestrationCheckpointSummary,
24 type OrchestrationEvent,
35 type OrchestrationMessage,
46 type OrchestrationProposedPlan,
5 type ProjectId,
6 type ProviderKind,
7 ThreadId,
87 type OrchestrationReadModel,
98 type OrchestrationSession,
10 type OrchestrationCheckpointSummary,
11 type OrchestrationThread,
129 type OrchestrationSessionStatus,
10 type OrchestrationThread,
11 type OrchestrationThreadActivity,
12 type ProjectId,
13 type ProviderKind,
14 ThreadId,
15 type TurnId,
1316} from "@t3tools/contracts";
1417import { resolveModelSlugForProvider } from "@t3tools/shared/model";
1518import { create } from "zustand";
1619import {
17 findLatestProposedPlan,
18 hasActionableProposedPlan,
1920 derivePendingApprovals,
2021 derivePendingUserInputs,
22 findLatestProposedPlan,
23 hasActionableProposedPlan,
2124} from "./session-logic";
25import {
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";
2236import { sanitizeThreadErrorMessage } from "./rpc/transportError";
23import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types";
24
25// ── State ────────────────────────────────────────────────────────────
2637
2738export 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>;
3255 bootstrapComplete: boolean;
3356}
3457
3558const initialState: AppState = {
36 projects: [],
37 threads: [],
38 sidebarThreadsById: {},
59 projectIds: [],
60 projectById: {},
61 threadIds: [],
3962 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: {},
4075 bootstrapComplete: false,
4176};
77
4278const MAX_THREAD_MESSAGES = 2_000;
4379const MAX_THREAD_CHECKPOINTS = 500;
4480const MAX_THREAD_PROPOSED_PLANS = 200;
4581const MAX_THREAD_ACTIVITIES = 500;
4682const EMPTY_THREAD_IDS: ThreadId[] = [];
47
48// ── Pure helpers ──────────────────────────────────────────────────────
49
50function 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
65function 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;
83const EMPTY_MESSAGE_IDS: MessageId[] = [];
84const EMPTY_ACTIVITY_IDS: string[] = [];
85const EMPTY_PROPOSED_PLAN_IDS: string[] = [];
86const EMPTY_TURN_IDS: TurnId[] = [];
87const EMPTY_MESSAGES: ChatMessage[] = [];
88const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
89const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
90const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
91const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {};
92const EMPTY_ACTIVITY_MAP: Record<string, OrchestrationThreadActivity> = {};
93const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {};
94const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {};
95const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null });
96
97function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
98 return left.length === right.length && left.every((value, index) => value === right[index]);
8299}
83100
84101function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>(
......@@ -94,7 +111,7 @@ function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>):
94111 return scripts.map((script) => ({ ...script }));
95112}
96113
97function mapSession(session: OrchestrationSession): Thread["session"] {
114function mapSession(session: OrchestrationSession): ThreadSession {
98115 return {
99116 provider: toLegacyProvider(session.providerName),
100117 status: toLegacySessionStatus(session.status),
......@@ -128,7 +145,7 @@ function mapMessage(message: OrchestrationMessage): ChatMessage {
128145 };
129146}
130147
131function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] {
148function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan {
132149 return {
133150 id: proposedPlan.id,
134151 turnId: proposedPlan.turnId,
......@@ -140,9 +157,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["propo
140157 };
141158}
142159
143function mapTurnDiffSummary(
144 checkpoint: OrchestrationCheckpointSummary,
145): Thread["turnDiffSummaries"][number] {
160function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary {
146161 return {
147162 turnId: checkpoint.turnId,
148163 completedAt: checkpoint.completedAt,
......@@ -154,6 +169,20 @@ function mapTurnDiffSummary(
154169 };
155170}
156171
172function 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
157186function mapThread(thread: OrchestrationThread): Thread {
158187 return {
159188 id: thread.id,
......@@ -179,25 +208,35 @@ function mapThread(thread: OrchestrationThread): Thread {
179208 };
180209}
181210
182function mapProject(project: OrchestrationReadModel["projects"][number]): Project {
211function toThreadShell(thread: Thread): ThreadShell {
183212 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,
193226 };
194227}
195228
196function getLatestUserMessageAt(
197 messages: ReadonlyArray<Thread["messages"][number]>,
198): string | null {
199 let latestUserMessageAt: string | null = null;
229function toThreadTurnState(thread: Thread): ThreadTurnState {
230 return {
231 latestTurn: thread.latestTurn,
232 ...(thread.pendingSourceProposedPlan
233 ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan }
234 : {}),
235 };
236}
200237
238function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | null {
239 let latestUserMessageAt: string | null = null;
201240 for (const message of messages) {
202241 if (message.role !== "user") {
203242 continue;
......@@ -206,7 +245,6 @@ function getLatestUserMessageAt(
206245 latestUserMessageAt = message.createdAt;
207246 }
208247 }
209
210248 return latestUserMessageAt;
211249}
212250
......@@ -256,62 +294,378 @@ function sidebarThreadSummariesEqual(
256294 );
257295}
258296
259function 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 }
297function 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
316function 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
324function appendId<T extends string>(ids: readonly T[], id: T): T[] {
325 return ids.includes(id) ? [...ids] : [...ids, id];
326}
327
328function removeId<T extends string>(ids: readonly T[], id: T): T[] {
329 return ids.filter((value) => value !== id);
330}
331
332function 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
344function 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
356function 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
368function buildTurnDiffSlice(thread: Thread): {
369 ids: TurnId[];
370 byId: Record<TurnId, TurnDiffSummary>;
371} {
268372 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>,
271377 };
272378}
273379
274function removeThreadIdByProjectId(
275 threadIdsByProjectId: Record<string, ThreadId[]>,
276 projectId: ProjectId,
380function 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
392function selectThreadActivities(
393 state: AppState,
277394 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;
282400 }
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
407function 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;
288412 }
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
419function 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;
293424 }
425 return ids.flatMap((id) => {
426 const summary = byId[id];
427 return summary ? [summary] : [];
428 });
429}
430
431function 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;
294437 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),
297446 };
298447}
299448
300function 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];
449function getProjects(state: AppState): Project[] {
450 return state.projectIds.flatMap((projectId) => {
451 const project = state.projectById[projectId];
452 return project ? [project] : [];
453 });
454}
455
456function getThreads(state: AppState): Thread[] {
457 return state.threadIds.flatMap((threadId) => {
458 const thread = getThread(state, threadId);
459 return thread ? [thread] : [];
460 });
461}
462
463function 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 };
305601 }
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;
307614}
308615
309function buildSidebarThreadsById(
310 threads: ReadonlyArray<Thread>,
311): Record<string, SidebarThreadSummary> {
312 return Object.fromEntries(
313 threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]),
314 );
616function 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 };
315669}
316670
317671function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {
......@@ -367,10 +721,10 @@ function buildLatestTurn(params: {
367721}
368722
369723function rebindTurnDiffSummariesForAssistantMessage(
370 turnDiffSummaries: ReadonlyArray<Thread["turnDiffSummaries"][number]>,
371 turnId: Thread["turnDiffSummaries"][number]["turnId"],
724 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>,
725 turnId: TurnId,
372726 assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"],
373): Thread["turnDiffSummaries"] {
727): TurnDiffSummary[] {
374728 let changed = false;
375729 const nextSummaries = turnDiffSummaries.map((summary) => {
376730 if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) {
......@@ -457,18 +811,18 @@ function retainThreadMessagesAfterRevert(
457811}
458812
459813function retainThreadActivitiesAfterRevert(
460 activities: ReadonlyArray<Thread["activities"][number]>,
814 activities: ReadonlyArray<OrchestrationThreadActivity>,
461815 retainedTurnIds: ReadonlySet<string>,
462): Thread["activities"] {
816): OrchestrationThreadActivity[] {
463817 return activities.filter(
464818 (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
465819 );
466820}
467821
468822function retainThreadProposedPlansAfterRevert(
469 proposedPlans: ReadonlyArray<Thread["proposedPlans"][number]>,
823 proposedPlans: ReadonlyArray<ProposedPlan>,
470824 retainedTurnIds: ReadonlySet<string>,
471): Thread["proposedPlans"] {
825): ProposedPlan[] {
472826 return proposedPlans.filter(
473827 (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId),
474828 );
......@@ -537,56 +891,113 @@ function updateThreadState(
537891 threadId: ThreadId,
538892 updater: (thread: Thread) => Thread,
539893): 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) {
549900 return state;
550901 }
902 return writeThreadState(state, nextThread, currentThread);
903}
551904
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 };
905function 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}
560915
561 if (sidebarThreadsById === state.sidebarThreadsById) {
562 return {
563 ...state,
564 threads,
565 };
916function 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);
566972 }
567973
568974 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,
572989 };
573990}
574991
575// ── Pure state transition functions ────────────────────────────────────
576
577992export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
578993 const projects = readModel.projects
579994 .filter((project) => project.deletedAt === null)
580995 .map(mapProject);
581996 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
582 const sidebarThreadsById = buildSidebarThreadsById(threads);
583 const threadIdsByProjectId = buildThreadIdsByProjectId(threads);
584997 return {
585998 ...state,
586 projects,
587 threads,
588 sidebarThreadsById,
589 threadIdsByProjectId,
999 ...buildProjectState(projects),
1000 ...buildThreadState(threads),
5901001 bootstrapComplete: true,
5911002 };
5921003}
......@@ -594,10 +1005,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
5941005export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState {
5951006 switch (event.type) {
5961007 case "project.created": {
597 const existingIndex = state.projects.findIndex(
598 (project) =>
599 project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot,
600 );
6011008 const nextProject = mapProject({
6021009 id: event.payload.projectId,
6031010 title: event.payload.title,
......@@ -608,17 +1015,48 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
6081015 updatedAt: event.payload.updatedAt,
6091016 deletedAt: null,
6101017 });
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 };
6181052 }
6191053
6201054 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 = {
6221060 ...project,
6231061 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),
6241062 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),
......@@ -633,17 +1071,30 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
6331071 ? { scripts: mapProjectScripts(event.payload.scripts) }
6341072 : {}),
6351073 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 };
6381082 }
6391083
6401084 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 };
6431094 }
6441095
6451096 case "thread.created": {
646 const existing = state.threads.find((thread) => thread.id === event.payload.threadId);
1097 const previousThread = getThread(state, event.payload.threadId);
6471098 const nextThread = mapThread({
6481099 id: event.payload.threadId,
6491100 projectId: event.payload.projectId,
......@@ -664,74 +1115,27 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
6641115 checkpoints: [],
6651116 session: null,
6661117 });
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);
6931119 }
6941120
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);
7171123
718 case "thread.archived": {
1124 case "thread.archived":
7191125 return updateThreadState(state, event.payload.threadId, (thread) => ({
7201126 ...thread,
7211127 archivedAt: event.payload.archivedAt,
7221128 updatedAt: event.payload.updatedAt,
7231129 }));
724 }
7251130
726 case "thread.unarchived": {
1131 case "thread.unarchived":
7271132 return updateThreadState(state, event.payload.threadId, (thread) => ({
7281133 ...thread,
7291134 archivedAt: null,
7301135 updatedAt: event.payload.updatedAt,
7311136 }));
732 }
7331137
734 case "thread.meta-updated": {
1138 case "thread.meta-updated":
7351139 return updateThreadState(state, event.payload.threadId, (thread) => ({
7361140 ...thread,
7371141 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
......@@ -744,25 +1148,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
7441148 : {}),
7451149 updatedAt: event.payload.updatedAt,
7461150 }));
747 }
7481151
749 case "thread.runtime-mode-set": {
1152 case "thread.runtime-mode-set":
7501153 return updateThreadState(state, event.payload.threadId, (thread) => ({
7511154 ...thread,
7521155 runtimeMode: event.payload.runtimeMode,
7531156 updatedAt: event.payload.updatedAt,
7541157 }));
755 }
7561158
757 case "thread.interaction-mode-set": {
1159 case "thread.interaction-mode-set":
7581160 return updateThreadState(state, event.payload.threadId, (thread) => ({
7591161 ...thread,
7601162 interactionMode: event.payload.interactionMode,
7611163 updatedAt: event.payload.updatedAt,
7621164 }));
763 }
7641165
765 case "thread.turn-start-requested": {
1166 case "thread.turn-start-requested":
7661167 return updateThreadState(state, event.payload.threadId, (thread) => ({
7671168 ...thread,
7681169 ...(event.payload.modelSelection !== undefined
......@@ -773,7 +1174,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
7731174 pendingSourceProposedPlan: event.payload.sourceProposedPlan,
7741175 updatedAt: event.occurredAt,
7751176 }));
776 }
7771177
7781178 case "thread.turn-interrupt-requested": {
7791179 if (event.payload.turnId === undefined) {
......@@ -800,7 +1200,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
8001200 });
8011201 }
8021202
803 case "thread.message-sent": {
1203 case "thread.message-sent":
8041204 return updateThreadState(state, event.payload.threadId, (thread) => {
8051205 const message = mapMessage({
8061206 id: event.payload.messageId,
......@@ -889,9 +1289,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
8891289 updatedAt: event.occurredAt,
8901290 };
8911291 });
892 }
8931292
894 case "thread.session-set": {
1293 case "thread.session-set":
8951294 return updateThreadState(state, event.payload.threadId, (thread) => ({
8961295 ...thread,
8971296 session: mapSession(event.payload.session),
......@@ -920,9 +1319,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
9201319 : thread.latestTurn,
9211320 updatedAt: event.occurredAt,
9221321 }));
923 }
9241322
925 case "thread.session-stop-requested": {
1323 case "thread.session-stop-requested":
9261324 return updateThreadState(state, event.payload.threadId, (thread) =>
9271325 thread.session === null
9281326 ? thread
......@@ -938,9 +1336,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
9381336 updatedAt: event.occurredAt,
9391337 },
9401338 );
941 }
9421339
943 case "thread.proposed-plan-upserted": {
1340 case "thread.proposed-plan-upserted":
9441341 return updateThreadState(state, event.payload.threadId, (thread) => {
9451342 const proposedPlan = mapProposedPlan(event.payload.proposedPlan);
9461343 const proposedPlans = [
......@@ -958,9 +1355,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
9581355 updatedAt: event.occurredAt,
9591356 };
9601357 });
961 }
9621358
963 case "thread.turn-diff-completed": {
1359 case "thread.turn-diff-completed":
9641360 return updateThreadState(state, event.payload.threadId, (thread) => {
9651361 const checkpoint = mapTurnDiffSummary({
9661362 turnId: event.payload.turnId,
......@@ -1007,9 +1403,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
10071403 updatedAt: event.occurredAt,
10081404 };
10091405 });
1010 }
10111406
1012 case "thread.reverted": {
1407 case "thread.reverted":
10131408 return updateThreadState(state, event.payload.threadId, (thread) => {
10141409 const turnDiffSummaries = thread.turnDiffSummaries
10151410 .filter(
......@@ -1059,9 +1454,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
10591454 updatedAt: event.occurredAt,
10601455 };
10611456 });
1062 }
10631457
1064 case "thread.activity-appended": {
1458 case "thread.activity-appended":
10651459 return updateThreadState(state, event.payload.threadId, (thread) => {
10661460 const activities = [
10671461 ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id),
......@@ -1075,7 +1469,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
10751469 updatedAt: event.occurredAt,
10761470 };
10771471 });
1078 }
10791472
10801473 case "thread.approval-response-requested":
10811474 case "thread.user-input-response-requested":
......@@ -1095,30 +1488,29 @@ export function applyOrchestrationEvents(
10951488 return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);
10961489}
10971490
1491export const selectProjects = (state: AppState): Project[] => getProjects(state);
1492export const selectThreads = (state: AppState): Thread[] => getThreads(state);
10981493export const selectProjectById =
10991494 (projectId: Project["id"] | null | undefined) =>
11001495 (state: AppState): Project | undefined =>
1101 projectId ? state.projects.find((project) => project.id === projectId) : undefined;
1102
1496 projectId ? state.projectById[projectId] : undefined;
11031497export const selectThreadById =
11041498 (threadId: ThreadId | null | undefined) =>
11051499 (state: AppState): Thread | undefined =>
1106 threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
1107
1500 threadId ? getThread(state, threadId) : undefined;
11081501export const selectSidebarThreadSummaryById =
11091502 (threadId: ThreadId | null | undefined) =>
11101503 (state: AppState): SidebarThreadSummary | undefined =>
1111 threadId ? state.sidebarThreadsById[threadId] : undefined;
1112
1504 threadId ? state.sidebarThreadSummaryById[threadId] : undefined;
11131505export const selectThreadIdsByProjectId =
11141506 (projectId: ProjectId | null | undefined) =>
11151507 (state: AppState): ThreadId[] =>
11161508 projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS;
11171509
11181510export 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 };
11221514 });
11231515}
11241516
......@@ -1128,11 +1520,11 @@ export function setThreadBranch(
11281520 branch: string | null,
11291521 worktreePath: string | null,
11301522): 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;
11341526 return {
1135 ...t,
1527 ...thread,
11361528 branch,
11371529 worktreePath,
11381530 ...(cwdChanged ? { session: null } : {}),
......@@ -1140,8 +1532,6 @@ export function setThreadBranch(
11401532 });
11411533}
11421534
1143// ── Zustand store ────────────────────────────────────────────────────
1144
11451535interface AppStore extends AppState {
11461536 syncServerReadModel: (readModel: OrchestrationReadModel) => void;
11471537 applyOrchestrationEvent: (event: OrchestrationEvent) => void;
apps/web/src/storeSelectors.ts+139-19
......@@ -1,26 +1,146 @@
1import { type ThreadId } from "@t3tools/contracts";
2import { useMemo } from "react";
1import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts";
2import { type AppState } from "./store";
33import {
4 selectProjectById,
5 selectSidebarThreadSummaryById,
6 selectThreadById,
7 useStore,
8} from "./store";
9import { type Project, type SidebarThreadSummary, type Thread } from "./types";
10
11export 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
14const EMPTY_MESSAGES: ChatMessage[] = [];
15const EMPTY_ACTIVITIES: Thread["activities"] = [];
16const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
17const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
18
19function 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 });
1431}
1532
16export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined {
17 const selector = useMemo(() => selectThreadById(threadId), [threadId]);
18 return useStore(selector);
33export function createProjectSelector(
34 projectId: ProjectId | null | undefined,
35): (state: AppState) => Project | undefined {
36 return (state) => (projectId ? state.projectById[projectId] : undefined);
37}
38
39export function createSidebarThreadSummarySelector(
40 threadId: ThreadId | null | undefined,
41): (state: AppState) => SidebarThreadSummary | undefined {
42 return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined);
1943}
2044
21export function useSidebarThreadSummaryById(
45export function createThreadSelector(
2246 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 };
26146}
apps/web/src/types.ts+21
......@@ -111,6 +111,27 @@ export interface Thread {
111111 activities: OrchestrationThreadActivity[];
112112}
113113
114export 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
130export interface ThreadTurnState {
131 latestTurn: OrchestrationLatestTurn | null;
132 pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"];
133}
134
114135export interface SidebarThreadSummary {
115136 id: ThreadId;
116137 projectId: ProjectId;