Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -1139,8 +1139,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,
});
});
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