Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 3 additions & 8 deletions apps/web/src/routes/_chat.$environmentId.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
stripDiffSearchParams,
} from "../diffRouteSearch";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
import { createThreadSelectorByRef } from "../storeSelectors";
import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
import { Sheet, SheetPopup } from "../components/ui/sheet";
Expand Down Expand Up @@ -172,7 +172,7 @@ function ChatThreadRouteView() {
(store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete,
);
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined);
const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef));
const environmentHasServerThreads = useStore(
(store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0,
);
Expand Down Expand Up @@ -208,6 +208,7 @@ function ChatThreadRouteView() {
if (!threadRef) {
return;
}
setHasOpenedDiff(true);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
Expand All @@ -218,12 +219,6 @@ function ChatThreadRouteView() {
});
}, [navigate, threadRef]);

useEffect(() => {
if (diffOpen) {
setHasOpenedDiff(true);
}
}, [diffOpen]);

useEffect(() => {
if (!threadRef || !bootstrapComplete) {
return;
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
applyOrchestrationEvents,
selectEnvironmentState,
selectProjectsAcrossEnvironments,
selectThreadByRef,
selectThreadExistsByRef,
setThreadBranch,
selectThreadsAcrossEnvironments,
syncServerReadModel,
Expand Down Expand Up @@ -245,6 +247,128 @@ function makeEvent<T extends OrchestrationEvent["type"]>(
} as Extract<OrchestrationEvent, { type: T }>;
}

describe("thread selection memoization", () => {
it("returns stable thread references for repeated reads of the same state", () => {
const thread = makeThread({
messages: [
{
id: MessageId.make("message-1"),
role: "user",
text: "hello",
createdAt: "2026-02-13T00:01:00.000Z",
streaming: false,
},
],
activities: [
{
id: EventId.make("activity-1"),
tone: "info",
kind: "step",
summary: "working",
payload: {},
turnId: TurnId.make("turn-1"),
createdAt: "2026-02-13T00:01:30.000Z",
},
],
proposedPlans: [
{
id: "plan-1",
turnId: null,
planMarkdown: "plan",
implementedAt: null,
implementationThreadId: null,
createdAt: "2026-02-13T00:02:00.000Z",
updatedAt: "2026-02-13T00:02:00.000Z",
},
],
turnDiffSummaries: [
{
turnId: TurnId.make("turn-1"),
completedAt: "2026-02-13T00:03:00.000Z",
files: [],
},
],
});
const state = makeState(thread);
const ref = scopeThreadRef(thread.environmentId, thread.id);

const first = selectThreadByRef(state, ref);
const second = selectThreadByRef(state, ref);

expect(first).toBeDefined();
expect(second).toBe(first);
expect(second?.messages).toBe(first?.messages);
expect(second?.activities).toBe(first?.activities);
expect(second?.proposedPlans).toBe(first?.proposedPlans);
expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries);
});

it("reuses the derived thread when the app state wrapper changes but thread data does not", () => {
const thread = makeThread({
messages: [
{
id: MessageId.make("message-1"),
role: "assistant",
text: "done",
createdAt: "2026-02-13T00:01:00.000Z",
streaming: false,
},
],
});
const state = makeState(thread);
const ref = scopeThreadRef(thread.environmentId, thread.id);
const wrappedState: AppState = {
...state,
environmentStateById: { ...state.environmentStateById },
};

const first = selectThreadByRef(state, ref);
const second = selectThreadByRef(wrappedState, ref);

expect(second).toBe(first);
});

it("updates the derived thread when the underlying thread data changes", () => {
const thread = makeThread();
const ref = scopeThreadRef(thread.environmentId, thread.id);
const firstState = makeState(thread);
const secondState = makeState({
...thread,
messages: [
{
id: MessageId.make("message-2"),
role: "user",
text: "new",
createdAt: "2026-02-13T00:04:00.000Z",
streaming: false,
},
],
});

const first = selectThreadByRef(firstState, ref);
const second = selectThreadByRef(secondState, ref);

expect(second).not.toBe(first);
expect(second?.messages).toHaveLength(1);
expect(second?.messages[0]?.text).toBe("new");
});

it("checks thread existence without materializing the full thread", () => {
const thread = makeThread();
const state = makeState(thread);
const ref = scopeThreadRef(thread.environmentId, thread.id);

expect(selectThreadExistsByRef(state, ref)).toBe(true);
expect(
selectThreadExistsByRef(
state,
scopeThreadRef(thread.environmentId, ThreadId.make("missing")),
),
).toBe(false);
expect(selectThreadExistsByRef(state, null)).toBe(false);
});
});

function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
return {
id: ThreadId.make("thread-1"),
Expand Down
103 changes: 14 additions & 89 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "./types";
import { resolveEnvironmentHttpUrl } from "./environments/runtime";
import { sanitizeThreadErrorMessage } from "./rpc/transportError";
import { getThreadFromEnvironmentState } from "./threadDerivation";

export interface EnvironmentState {
projectIds: ProjectId[];
Expand Down Expand Up @@ -94,19 +95,6 @@ const MAX_THREAD_CHECKPOINTS = 500;
const MAX_THREAD_PROPOSED_PLANS = 200;
const MAX_THREAD_ACTIVITIES = 500;
const EMPTY_THREAD_IDS: ThreadId[] = [];
const EMPTY_MESSAGE_IDS: MessageId[] = [];
const EMPTY_ACTIVITY_IDS: string[] = [];
const EMPTY_PROPOSED_PLAN_IDS: string[] = [];
const EMPTY_TURN_IDS: TurnId[] = [];
const EMPTY_MESSAGES: ChatMessage[] = [];
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {};
const EMPTY_ACTIVITY_MAP: Record<string, OrchestrationThreadActivity> = {};
const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {};
const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {};
const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null });

function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
Expand Down Expand Up @@ -403,78 +391,6 @@ function buildTurnDiffSlice(thread: Thread): {
};
}

function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] {
const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS;
const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP;
if (ids.length === 0) {
return EMPTY_MESSAGES;
}
return ids.flatMap((id) => {
const message = byId[id];
return message ? [message] : [];
});
}

function selectThreadActivities(
state: EnvironmentState,
threadId: ThreadId,
): OrchestrationThreadActivity[] {
const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS;
const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP;
if (ids.length === 0) {
return EMPTY_ACTIVITIES;
}
return ids.flatMap((id) => {
const activity = byId[id];
return activity ? [activity] : [];
});
}

function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] {
const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS;
const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP;
if (ids.length === 0) {
return EMPTY_PROPOSED_PLANS;
}
return ids.flatMap((id) => {
const plan = byId[id];
return plan ? [plan] : [];
});
}

function selectThreadTurnDiffSummaries(
state: EnvironmentState,
threadId: ThreadId,
): TurnDiffSummary[] {
const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS;
const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP;
if (ids.length === 0) {
return EMPTY_TURN_DIFF_SUMMARIES;
}
return ids.flatMap((id) => {
const summary = byId[id];
return summary ? [summary] : [];
});
}

function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined {
const shell = state.threadShellById[threadId];
if (!shell) {
return undefined;
}
const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE;
return {
...shell,
session: state.threadSessionById[threadId] ?? null,
latestTurn: turnState.latestTurn,
pendingSourceProposedPlan: turnState.pendingSourceProposedPlan,
messages: selectThreadMessages(state, threadId),
activities: selectThreadActivities(state, threadId),
proposedPlans: selectThreadProposedPlans(state, threadId),
turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId),
};
}

function getProjects(state: EnvironmentState): Project[] {
return state.projectIds.flatMap((projectId) => {
const project = state.projectById[projectId];
Expand All @@ -484,7 +400,7 @@ function getProjects(state: EnvironmentState): Project[] {

function getThreads(state: EnvironmentState): Thread[] {
return state.threadIds.flatMap((threadId) => {
const thread = getThread(state, threadId);
const thread = getThreadFromEnvironmentState(state, threadId);
return thread ? [thread] : [];
});
}
Expand Down Expand Up @@ -896,7 +812,7 @@ function updateThreadState(
threadId: ThreadId,
updater: (thread: Thread) => Thread,
): EnvironmentState {
const currentThread = getThread(state, threadId);
const currentThread = getThreadFromEnvironmentState(state, threadId);
if (!currentThread) {
return state;
}
Expand Down Expand Up @@ -1163,7 +1079,7 @@ function applyEnvironmentOrchestrationEvent(
}

case "thread.created": {
const previousThread = getThread(state, event.payload.threadId);
const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId);
const nextThread = mapThread(
{
id: event.payload.threadId,
Expand Down Expand Up @@ -1669,10 +1585,19 @@ export function selectThreadByRef(
ref: ScopedThreadRef | null | undefined,
): Thread | undefined {
return ref
? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId)
? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId)
: undefined;
}

export function selectThreadExistsByRef(
state: AppState,
ref: ScopedThreadRef | null | undefined,
): boolean {
return ref
? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined
: false;
}

export function selectSidebarThreadSummaryByRef(
state: AppState,
ref: ScopedThreadRef | null | undefined,
Expand Down
Loading
Loading