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