Skip to content

Commit 1ec346c

Browse files
authored
Refactor web stores into atomic slices ready to split ChatView (#1708)
1 parent 28e481e commit 1ec346c

21 files changed

Lines changed: 1329 additions & 544 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
AGENTS.md
1+
AGENTS.md

apps/web/src/components/BranchToolbar.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,15 @@ export default function BranchToolbar({
3434
onCheckoutPullRequestRequest,
3535
onComposerFocusRequest,
3636
}: BranchToolbarProps) {
37-
const threads = useStore((store) => store.threads);
38-
const projects = useStore((store) => store.projects);
37+
const serverThread = useStore((store) => store.threadShellById[threadId]);
38+
const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null);
3939
const setThreadBranchAction = useStore((store) => store.setThreadBranch);
4040
const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
4141
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
42-
43-
const serverThread = threads.find((thread) => thread.id === threadId);
4442
const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
45-
const activeProject = projects.find((project) => project.id === activeProjectId);
43+
const activeProject = useStore((store) =>
44+
activeProjectId ? store.projectById[activeProjectId] : undefined,
45+
);
4646
const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
4747
const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
4848
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
@@ -60,7 +60,7 @@ export default function BranchToolbar({
6060
const api = readNativeApi();
6161
// If the effective cwd is about to change, stop the running session so the
6262
// next message creates a new one with the correct cwd.
63-
if (serverThread?.session && worktreePath !== activeWorktreePath && api) {
63+
if (serverSession && worktreePath !== activeWorktreePath && api) {
6464
void api.orchestration
6565
.dispatchCommand({
6666
type: "thread.session.stop",
@@ -96,7 +96,7 @@ export default function BranchToolbar({
9696
},
9797
[
9898
activeThreadId,
99-
serverThread?.session,
99+
serverSession,
100100
activeWorktreePath,
101101
hasServerThread,
102102
setThreadBranchAction,

apps/web/src/components/ChatView.browser.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,8 +1176,22 @@ describe("ChatView timeline estimator parity (full app)", () => {
11761176
stickyActiveProvider: null,
11771177
});
11781178
useStore.setState({
1179-
projects: [],
1180-
threads: [],
1179+
projectIds: [],
1180+
projectById: {},
1181+
threadIds: [],
1182+
threadIdsByProjectId: {},
1183+
threadShellById: {},
1184+
threadSessionById: {},
1185+
threadTurnStateById: {},
1186+
messageIdsByThreadId: {},
1187+
messageByThreadId: {},
1188+
activityIdsByThreadId: {},
1189+
activityByThreadId: {},
1190+
proposedPlanIdsByThreadId: {},
1191+
proposedPlanByThreadId: {},
1192+
turnDiffIdsByThreadId: {},
1193+
turnDiffSummaryByThreadId: {},
1194+
sidebarThreadSummaryById: {},
11811195
bootstrapComplete: false,
11821196
});
11831197
useTerminalStateStore.persist.clearStorage();

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 140 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
22
import { afterEach, describe, expect, it, vi } from "vitest";
33
import { useStore } from "../store";
4+
import { type Thread } from "../types";
45

56
import {
67
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
@@ -178,7 +179,7 @@ const makeThread = (input?: {
178179
startedAt: string | null;
179180
completedAt: string | null;
180181
} | null;
181-
}) => ({
182+
}): Thread => ({
182183
id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
183184
codexThreadId: null,
184185
projectId: ProjectId.makeUnsafe("project-1"),
@@ -205,94 +206,172 @@ const makeThread = (input?: {
205206
activities: [],
206207
});
207208

209+
function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {
210+
const projectId = ProjectId.makeUnsafe("project-1");
211+
useStore.setState({
212+
projectIds: [projectId],
213+
projectById: {
214+
[projectId]: {
215+
id: projectId,
216+
name: "Project",
217+
cwd: "/tmp/project",
218+
defaultModelSelection: {
219+
provider: "codex",
220+
model: "gpt-5.4",
221+
},
222+
createdAt: "2026-03-29T00:00:00.000Z",
223+
updatedAt: "2026-03-29T00:00:00.000Z",
224+
scripts: [],
225+
},
226+
},
227+
threadIds: threads.map((thread) => thread.id),
228+
threadIdsByProjectId: {
229+
[projectId]: threads.map((thread) => thread.id),
230+
},
231+
threadShellById: Object.fromEntries(
232+
threads.map((thread) => [
233+
thread.id,
234+
{
235+
id: thread.id,
236+
codexThreadId: thread.codexThreadId,
237+
projectId: thread.projectId,
238+
title: thread.title,
239+
modelSelection: thread.modelSelection,
240+
runtimeMode: thread.runtimeMode,
241+
interactionMode: thread.interactionMode,
242+
error: thread.error,
243+
createdAt: thread.createdAt,
244+
archivedAt: thread.archivedAt,
245+
updatedAt: thread.updatedAt,
246+
branch: thread.branch,
247+
worktreePath: thread.worktreePath,
248+
},
249+
]),
250+
),
251+
threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])),
252+
threadTurnStateById: Object.fromEntries(
253+
threads.map((thread) => [
254+
thread.id,
255+
{
256+
latestTurn: thread.latestTurn,
257+
...(thread.pendingSourceProposedPlan
258+
? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan }
259+
: {}),
260+
},
261+
]),
262+
),
263+
messageIdsByThreadId: Object.fromEntries(
264+
threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]),
265+
),
266+
messageByThreadId: Object.fromEntries(
267+
threads.map((thread) => [
268+
thread.id,
269+
Object.fromEntries(thread.messages.map((message) => [message.id, message])),
270+
]),
271+
),
272+
activityIdsByThreadId: Object.fromEntries(
273+
threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]),
274+
),
275+
activityByThreadId: Object.fromEntries(
276+
threads.map((thread) => [
277+
thread.id,
278+
Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])),
279+
]),
280+
),
281+
proposedPlanIdsByThreadId: Object.fromEntries(
282+
threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]),
283+
),
284+
proposedPlanByThreadId: Object.fromEntries(
285+
threads.map((thread) => [
286+
thread.id,
287+
Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])),
288+
]),
289+
),
290+
turnDiffIdsByThreadId: Object.fromEntries(
291+
threads.map((thread) => [
292+
thread.id,
293+
thread.turnDiffSummaries.map((summary) => summary.turnId),
294+
]),
295+
),
296+
turnDiffSummaryByThreadId: Object.fromEntries(
297+
threads.map((thread) => [
298+
thread.id,
299+
Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])),
300+
]),
301+
),
302+
sidebarThreadSummaryById: {},
303+
bootstrapComplete: true,
304+
});
305+
}
306+
208307
afterEach(() => {
209308
vi.useRealTimers();
210309
vi.restoreAllMocks();
211-
useStore.setState((state) => ({
212-
...state,
213-
projects: [],
214-
threads: [],
215-
bootstrapComplete: true,
216-
}));
310+
setStoreThreads([]);
217311
});
218312

219313
describe("waitForStartedServerThread", () => {
220314
it("resolves immediately when the thread is already started", async () => {
221315
const threadId = ThreadId.makeUnsafe("thread-started");
222-
useStore.setState((state) => ({
223-
...state,
224-
threads: [
225-
makeThread({
226-
id: threadId,
227-
latestTurn: {
228-
turnId: TurnId.makeUnsafe("turn-started"),
229-
state: "running",
230-
requestedAt: "2026-03-29T00:00:01.000Z",
231-
startedAt: "2026-03-29T00:00:01.000Z",
232-
completedAt: null,
233-
},
234-
}),
235-
],
236-
}));
316+
setStoreThreads([
317+
makeThread({
318+
id: threadId,
319+
latestTurn: {
320+
turnId: TurnId.makeUnsafe("turn-started"),
321+
state: "running",
322+
requestedAt: "2026-03-29T00:00:01.000Z",
323+
startedAt: "2026-03-29T00:00:01.000Z",
324+
completedAt: null,
325+
},
326+
}),
327+
]);
237328

238329
await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);
239330
});
240331

241332
it("waits for the thread to start via subscription updates", async () => {
242333
const threadId = ThreadId.makeUnsafe("thread-wait");
243-
useStore.setState((state) => ({
244-
...state,
245-
threads: [makeThread({ id: threadId })],
246-
}));
334+
setStoreThreads([makeThread({ id: threadId })]);
247335

248336
const promise = waitForStartedServerThread(threadId, 500);
249337

250-
useStore.setState((state) => ({
251-
...state,
252-
threads: [
253-
makeThread({
254-
id: threadId,
255-
latestTurn: {
256-
turnId: TurnId.makeUnsafe("turn-started"),
257-
state: "running",
258-
requestedAt: "2026-03-29T00:00:01.000Z",
259-
startedAt: "2026-03-29T00:00:01.000Z",
260-
completedAt: null,
261-
},
262-
}),
263-
],
264-
}));
338+
setStoreThreads([
339+
makeThread({
340+
id: threadId,
341+
latestTurn: {
342+
turnId: TurnId.makeUnsafe("turn-started"),
343+
state: "running",
344+
requestedAt: "2026-03-29T00:00:01.000Z",
345+
startedAt: "2026-03-29T00:00:01.000Z",
346+
completedAt: null,
347+
},
348+
}),
349+
]);
265350

266351
await expect(promise).resolves.toBe(true);
267352
});
268353

269354
it("handles the thread starting between the initial read and subscription setup", async () => {
270355
const threadId = ThreadId.makeUnsafe("thread-race");
271-
useStore.setState((state) => ({
272-
...state,
273-
threads: [makeThread({ id: threadId })],
274-
}));
356+
setStoreThreads([makeThread({ id: threadId })]);
275357

276358
const originalSubscribe = useStore.subscribe.bind(useStore);
277359
let raced = false;
278360
vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {
279361
if (!raced) {
280362
raced = true;
281-
useStore.setState((state) => ({
282-
...state,
283-
threads: [
284-
makeThread({
285-
id: threadId,
286-
latestTurn: {
287-
turnId: TurnId.makeUnsafe("turn-race"),
288-
state: "running",
289-
requestedAt: "2026-03-29T00:00:01.000Z",
290-
startedAt: "2026-03-29T00:00:01.000Z",
291-
completedAt: null,
292-
},
293-
}),
294-
],
295-
}));
363+
setStoreThreads([
364+
makeThread({
365+
id: threadId,
366+
latestTurn: {
367+
turnId: TurnId.makeUnsafe("turn-race"),
368+
state: "running",
369+
requestedAt: "2026-03-29T00:00:01.000Z",
370+
startedAt: "2026-03-29T00:00:01.000Z",
371+
completedAt: null,
372+
},
373+
}),
374+
]);
296375
}
297376
return originalSubscribe(listener);
298377
});
@@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => {
304383
vi.useFakeTimers();
305384

306385
const threadId = ThreadId.makeUnsafe("thread-timeout");
307-
useStore.setState((state) => ({
308-
...state,
309-
threads: [makeThread({ id: threadId })],
310-
}));
386+
setStoreThreads([makeThread({ id: threadId })]);
311387
const promise = waitForStartedServerThread(threadId, 500);
312388

313389
await vi.advanceTimersByTimeAsync(500);

apps/web/src/components/ChatView.logic.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession }
33
import { randomUUID } from "~/lib/utils";
44
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
55
import { Schema } from "effect";
6-
import { useStore } from "../store";
6+
import { selectThreadById, useStore } from "../store";
77
import {
88
filterTerminalContextsWithText,
99
stripInlineTerminalContextPlaceholders,
@@ -202,7 +202,7 @@ export async function waitForStartedServerThread(
202202
threadId: ThreadId,
203203
timeoutMs = 1_000,
204204
): Promise<boolean> {
205-
const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId);
205+
const getThread = () => selectThreadById(threadId)(useStore.getState());
206206
const thread = getThread();
207207

208208
if (threadHasStarted(thread)) {
@@ -225,7 +225,7 @@ export async function waitForStartedServerThread(
225225
};
226226

227227
const unsubscribe = useStore.subscribe((state) => {
228-
if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) {
228+
if (!threadHasStarted(selectThreadById(threadId)(state))) {
229229
return;
230230
}
231231
finish(true);

0 commit comments

Comments
 (0)