Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8e5c244
Refactor web store into atomic slices
justsomelegs Apr 3, 2026
0737ff4
Fix project recreation keys and update web tests
justsomelegs Apr 3, 2026
5e009d6
Merge upstream/main into web/atomic-store-refactor
justsomelegs Apr 5, 2026
a5bd205
Merge remote-tracking branch 'upstream/main' into HEAD
justsomelegs Apr 5, 2026
6ff8f71
fix(web): stabilize browser test store selectors
justsomelegs Apr 5, 2026
f108f22
Merge branch 'main' into web/atomic-store-refactor
justsomelegs Apr 6, 2026
d20a403
Merge branch 'main' into web/atomic-store-refactor
justsomelegs Apr 6, 2026
53b572f
Merge branch 'main' into web/atomic-store-refactor
justsomelegs Apr 6, 2026
8e89b1b
Merge branch 'main' into web/atomic-store-refactor
justsomelegs Apr 6, 2026
bf8820d
Merge branch 'web/atomic-store-refactor' of https://github.com/justso…
justsomelegs Apr 6, 2026
5245641
Merge branch 'main' into web/atomic-store-refactor
justsomelegs Apr 6, 2026
57bdffe
Merge branch 'web/atomic-store-refactor' of https://github.com/justso…
justsomelegs Apr 6, 2026
789b408
Merge branch 'web/atomic-store-refactor' of https://github.com/justso…
justsomelegs Apr 7, 2026
feb83c6
fix failing tests
justsomelegs Apr 7, 2026
82ce8a5
Merge branch 'main' into web/atomic-store-refactor
justsomelegs Apr 6, 2026
4a2cd66
Merge branch 'web/atomic-store-refactor' of https://github.com/justso…
justsomelegs Apr 6, 2026
530d797
Merge branch 'web/atomic-store-refactor' of https://github.com/justso…
justsomelegs Apr 7, 2026
1bc50f9
fix failing tests
justsomelegs Apr 7, 2026
025d832
Merge branch 'web/atomic-store-refactor' of https://github.com/justso…
justsomelegs Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
14 changes: 7 additions & 7 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ export default function BranchToolbar({
onCheckoutPullRequestRequest,
onComposerFocusRequest,
}: BranchToolbarProps) {
const threads = useStore((store) => store.threads);
const projects = useStore((store) => store.projects);
const serverThread = useStore((store) => store.threadShellById[threadId]);
const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null);
const setThreadBranchAction = useStore((store) => store.setThreadBranch);
const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);

const serverThread = threads.find((thread) => thread.id === threadId);
const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
const activeProject = projects.find((project) => project.id === activeProjectId);
const activeProject = useStore((store) =>
activeProjectId ? store.projectById[activeProjectId] : undefined,
);
const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
Expand All @@ -60,7 +60,7 @@ export default function BranchToolbar({
const api = readNativeApi();
// If the effective cwd is about to change, stop the running session so the
// next message creates a new one with the correct cwd.
if (serverThread?.session && worktreePath !== activeWorktreePath && api) {
if (serverSession && worktreePath !== activeWorktreePath && api) {
void api.orchestration
.dispatchCommand({
type: "thread.session.stop",
Expand Down Expand Up @@ -96,7 +96,7 @@ export default function BranchToolbar({
},
[
activeThreadId,
serverThread?.session,
serverSession,
activeWorktreePath,
hasServerThread,
setThreadBranchAction,
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1176,8 +1176,22 @@ describe("ChatView timeline estimator parity (full app)", () => {
stickyActiveProvider: null,
});
useStore.setState({
projects: [],
threads: [],
projectIds: [],
projectById: {},
threadIds: [],
threadIdsByProjectId: {},
threadShellById: {},
threadSessionById: {},
threadTurnStateById: {},
messageIdsByThreadId: {},
messageByThreadId: {},
activityIdsByThreadId: {},
activityByThreadId: {},
proposedPlanIdsByThreadId: {},
proposedPlanByThreadId: {},
turnDiffIdsByThreadId: {},
turnDiffSummaryByThreadId: {},
sidebarThreadSummaryById: {},
bootstrapComplete: false,
});
useTerminalStateStore.persist.clearStorage();
Expand Down
204 changes: 140 additions & 64 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useStore } from "../store";
import { type Thread } from "../types";

import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
Expand Down Expand Up @@ -178,7 +179,7 @@ const makeThread = (input?: {
startedAt: string | null;
completedAt: string | null;
} | null;
}) => ({
}): Thread => ({
id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
codexThreadId: null,
projectId: ProjectId.makeUnsafe("project-1"),
Expand All @@ -205,94 +206,172 @@ const makeThread = (input?: {
activities: [],
});

function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {
const projectId = ProjectId.makeUnsafe("project-1");
useStore.setState({
projectIds: [projectId],
projectById: {
[projectId]: {
id: projectId,
name: "Project",
cwd: "/tmp/project",
defaultModelSelection: {
provider: "codex",
model: "gpt-5.4",
},
createdAt: "2026-03-29T00:00:00.000Z",
updatedAt: "2026-03-29T00:00:00.000Z",
scripts: [],
},
},
threadIds: threads.map((thread) => thread.id),
threadIdsByProjectId: {
[projectId]: threads.map((thread) => thread.id),
},
threadShellById: Object.fromEntries(
threads.map((thread) => [
thread.id,
{
id: thread.id,
codexThreadId: thread.codexThreadId,
projectId: thread.projectId,
title: thread.title,
modelSelection: thread.modelSelection,
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
error: thread.error,
createdAt: thread.createdAt,
archivedAt: thread.archivedAt,
updatedAt: thread.updatedAt,
branch: thread.branch,
worktreePath: thread.worktreePath,
},
]),
),
threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])),
threadTurnStateById: Object.fromEntries(
threads.map((thread) => [
thread.id,
{
latestTurn: thread.latestTurn,
...(thread.pendingSourceProposedPlan
? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan }
: {}),
},
]),
),
messageIdsByThreadId: Object.fromEntries(
threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]),
),
messageByThreadId: Object.fromEntries(
threads.map((thread) => [
thread.id,
Object.fromEntries(thread.messages.map((message) => [message.id, message])),
]),
),
activityIdsByThreadId: Object.fromEntries(
threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]),
),
activityByThreadId: Object.fromEntries(
threads.map((thread) => [
thread.id,
Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])),
]),
),
proposedPlanIdsByThreadId: Object.fromEntries(
threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]),
),
proposedPlanByThreadId: Object.fromEntries(
threads.map((thread) => [
thread.id,
Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])),
]),
),
turnDiffIdsByThreadId: Object.fromEntries(
threads.map((thread) => [
thread.id,
thread.turnDiffSummaries.map((summary) => summary.turnId),
]),
),
turnDiffSummaryByThreadId: Object.fromEntries(
threads.map((thread) => [
thread.id,
Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])),
]),
),
sidebarThreadSummaryById: {},
bootstrapComplete: true,
});
}

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
useStore.setState((state) => ({
...state,
projects: [],
threads: [],
bootstrapComplete: true,
}));
setStoreThreads([]);
});

describe("waitForStartedServerThread", () => {
it("resolves immediately when the thread is already started", async () => {
const threadId = ThreadId.makeUnsafe("thread-started");
useStore.setState((state) => ({
...state,
threads: [
makeThread({
id: threadId,
latestTurn: {
turnId: TurnId.makeUnsafe("turn-started"),
state: "running",
requestedAt: "2026-03-29T00:00:01.000Z",
startedAt: "2026-03-29T00:00:01.000Z",
completedAt: null,
},
}),
],
}));
setStoreThreads([
makeThread({
id: threadId,
latestTurn: {
turnId: TurnId.makeUnsafe("turn-started"),
state: "running",
requestedAt: "2026-03-29T00:00:01.000Z",
startedAt: "2026-03-29T00:00:01.000Z",
completedAt: null,
},
}),
]);

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

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

const promise = waitForStartedServerThread(threadId, 500);

useStore.setState((state) => ({
...state,
threads: [
makeThread({
id: threadId,
latestTurn: {
turnId: TurnId.makeUnsafe("turn-started"),
state: "running",
requestedAt: "2026-03-29T00:00:01.000Z",
startedAt: "2026-03-29T00:00:01.000Z",
completedAt: null,
},
}),
],
}));
setStoreThreads([
makeThread({
id: threadId,
latestTurn: {
turnId: TurnId.makeUnsafe("turn-started"),
state: "running",
requestedAt: "2026-03-29T00:00:01.000Z",
startedAt: "2026-03-29T00:00:01.000Z",
completedAt: null,
},
}),
]);

await expect(promise).resolves.toBe(true);
});

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

const originalSubscribe = useStore.subscribe.bind(useStore);
let raced = false;
vi.spyOn(useStore, "subscribe").mockImplementation((listener) => {
if (!raced) {
raced = true;
useStore.setState((state) => ({
...state,
threads: [
makeThread({
id: threadId,
latestTurn: {
turnId: TurnId.makeUnsafe("turn-race"),
state: "running",
requestedAt: "2026-03-29T00:00:01.000Z",
startedAt: "2026-03-29T00:00:01.000Z",
completedAt: null,
},
}),
],
}));
setStoreThreads([
makeThread({
id: threadId,
latestTurn: {
turnId: TurnId.makeUnsafe("turn-race"),
state: "running",
requestedAt: "2026-03-29T00:00:01.000Z",
startedAt: "2026-03-29T00:00:01.000Z",
completedAt: null,
},
}),
]);
}
return originalSubscribe(listener);
});
Expand All @@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => {
vi.useFakeTimers();

const threadId = ThreadId.makeUnsafe("thread-timeout");
useStore.setState((state) => ({
...state,
threads: [makeThread({ id: threadId })],
}));
setStoreThreads([makeThread({ id: threadId })]);
const promise = waitForStartedServerThread(threadId, 500);

await vi.advanceTimersByTimeAsync(500);
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession }
import { randomUUID } from "~/lib/utils";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
import { Schema } from "effect";
import { useStore } from "../store";
import { selectThreadById, useStore } from "../store";
import {
filterTerminalContextsWithText,
stripInlineTerminalContextPlaceholders,
Expand Down Expand Up @@ -202,7 +202,7 @@ export async function waitForStartedServerThread(
threadId: ThreadId,
timeoutMs = 1_000,
): Promise<boolean> {
const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId);
const getThread = () => selectThreadById(threadId)(useStore.getState());
const thread = getThread();

if (threadHasStarted(thread)) {
Expand All @@ -225,7 +225,7 @@ export async function waitForStartedServerThread(
};

const unsubscribe = useStore.subscribe((state) => {
if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) {
if (!threadHasStarted(selectThreadById(threadId)(state))) {
return;
}
finish(true);
Expand Down
Loading
Loading