Skip to content

Commit b80e847

Browse files
Memoize derived thread reads (#1908)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent e0e01b4 commit b80e847

6 files changed

Lines changed: 335 additions & 222 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,12 +310,14 @@ type ChatViewProps =
310310
| {
311311
environmentId: EnvironmentId;
312312
threadId: ThreadId;
313+
onDiffPanelOpen?: () => void;
313314
routeKind: "server";
314315
draftId?: never;
315316
}
316317
| {
317318
environmentId: EnvironmentId;
318319
threadId: ThreadId;
320+
onDiffPanelOpen?: () => void;
319321
routeKind: "draft";
320322
draftId: DraftId;
321323
};
@@ -569,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
569571
});
570572

571573
export default function ChatView(props: ChatViewProps) {
572-
const { environmentId, threadId, routeKind } = props;
574+
const { environmentId, threadId, routeKind, onDiffPanelOpen } = props;
573575
const draftId = routeKind === "draft" ? props.draftId : null;
574576
const routeThreadRef = useMemo(
575577
() => scopeThreadRef(environmentId, threadId),
@@ -1470,6 +1472,9 @@ export default function ChatView(props: ChatViewProps) {
14701472
if (!isServerThread) {
14711473
return;
14721474
}
1475+
if (!diffOpen) {
1476+
onDiffPanelOpen?.();
1477+
}
14731478
void navigate({
14741479
to: "/$environmentId/$threadId",
14751480
params: {
@@ -1482,7 +1487,7 @@ export default function ChatView(props: ChatViewProps) {
14821487
return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
14831488
},
14841489
});
1485-
}, [diffOpen, environmentId, isServerThread, navigate, threadId]);
1490+
}, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]);
14861491

14871492
const envLocked = Boolean(
14881493
activeThread &&
@@ -3245,6 +3250,7 @@ export default function ChatView(props: ChatViewProps) {
32453250
if (!isServerThread) {
32463251
return;
32473252
}
3253+
onDiffPanelOpen?.();
32483254
void navigate({
32493255
to: "/$environmentId/$threadId",
32503256
params: {
@@ -3259,7 +3265,7 @@ export default function ChatView(props: ChatViewProps) {
32593265
},
32603266
});
32613267
},
3262-
[environmentId, isServerThread, navigate, threadId],
3268+
[environmentId, isServerThread, navigate, onDiffPanelOpen, threadId],
32633269
);
32643270
const onRevertUserMessage = useCallback(
32653271
(messageId: MessageId) => {

apps/web/src/routes/_chat.$environmentId.$threadId.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
stripDiffSearchParams,
1818
} from "../diffRouteSearch";
1919
import { useMediaQuery } from "../hooks/useMediaQuery";
20-
import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
20+
import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
2121
import { createThreadSelectorByRef } from "../storeSelectors";
2222
import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
2323
import { Sheet, SheetPopup } from "../components/ui/sheet";
@@ -172,7 +172,7 @@ function ChatThreadRouteView() {
172172
(store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete,
173173
);
174174
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
175-
const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined);
175+
const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef));
176176
const environmentHasServerThreads = useStore(
177177
(store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0,
178178
);
@@ -193,7 +193,26 @@ function ChatThreadRouteView() {
193193
const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
194194
const diffOpen = search.diff === "1";
195195
const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
196-
const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
196+
const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null;
197+
const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({
198+
threadKey: currentThreadKey,
199+
hasOpenedDiff: diffOpen,
200+
}));
201+
const hasOpenedDiff =
202+
diffPanelMountState.threadKey === currentThreadKey
203+
? diffPanelMountState.hasOpenedDiff
204+
: diffOpen;
205+
const markDiffOpened = useCallback(() => {
206+
setDiffPanelMountState((previous) => {
207+
if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) {
208+
return previous;
209+
}
210+
return {
211+
threadKey: currentThreadKey,
212+
hasOpenedDiff: true,
213+
};
214+
});
215+
}, [currentThreadKey]);
197216
const closeDiff = useCallback(() => {
198217
if (!threadRef) {
199218
return;
@@ -208,6 +227,7 @@ function ChatThreadRouteView() {
208227
if (!threadRef) {
209228
return;
210229
}
230+
markDiffOpened();
211231
void navigate({
212232
to: "/$environmentId/$threadId",
213233
params: buildThreadRouteParams(threadRef),
@@ -216,13 +236,7 @@ function ChatThreadRouteView() {
216236
return { ...rest, diff: "1" };
217237
},
218238
});
219-
}, [navigate, threadRef]);
220-
221-
useEffect(() => {
222-
if (diffOpen) {
223-
setHasOpenedDiff(true);
224-
}
225-
}, [diffOpen]);
239+
}, [markDiffOpened, navigate, threadRef]);
226240

227241
useEffect(() => {
228242
if (!threadRef || !bootstrapComplete) {
@@ -254,6 +268,7 @@ function ChatThreadRouteView() {
254268
<ChatView
255269
environmentId={threadRef.environmentId}
256270
threadId={threadRef.threadId}
271+
onDiffPanelOpen={markDiffOpened}
257272
routeKind="server"
258273
/>
259274
</SidebarInset>
@@ -273,6 +288,7 @@ function ChatThreadRouteView() {
273288
<ChatView
274289
environmentId={threadRef.environmentId}
275290
threadId={threadRef.threadId}
291+
onDiffPanelOpen={markDiffOpened}
276292
routeKind="server"
277293
/>
278294
</SidebarInset>

apps/web/src/store.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
applyOrchestrationEvents,
1919
selectEnvironmentState,
2020
selectProjectsAcrossEnvironments,
21+
selectThreadByRef,
22+
selectThreadExistsByRef,
2123
setThreadBranch,
2224
selectThreadsAcrossEnvironments,
2325
syncServerReadModel,
@@ -245,6 +247,128 @@ function makeEvent<T extends OrchestrationEvent["type"]>(
245247
} as Extract<OrchestrationEvent, { type: T }>;
246248
}
247249

250+
describe("thread selection memoization", () => {
251+
it("returns stable thread references for repeated reads of the same state", () => {
252+
const thread = makeThread({
253+
messages: [
254+
{
255+
id: MessageId.make("message-1"),
256+
role: "user",
257+
text: "hello",
258+
createdAt: "2026-02-13T00:01:00.000Z",
259+
streaming: false,
260+
},
261+
],
262+
activities: [
263+
{
264+
id: EventId.make("activity-1"),
265+
tone: "info",
266+
kind: "step",
267+
summary: "working",
268+
payload: {},
269+
turnId: TurnId.make("turn-1"),
270+
createdAt: "2026-02-13T00:01:30.000Z",
271+
},
272+
],
273+
proposedPlans: [
274+
{
275+
id: "plan-1",
276+
turnId: null,
277+
planMarkdown: "plan",
278+
implementedAt: null,
279+
implementationThreadId: null,
280+
createdAt: "2026-02-13T00:02:00.000Z",
281+
updatedAt: "2026-02-13T00:02:00.000Z",
282+
},
283+
],
284+
turnDiffSummaries: [
285+
{
286+
turnId: TurnId.make("turn-1"),
287+
completedAt: "2026-02-13T00:03:00.000Z",
288+
files: [],
289+
},
290+
],
291+
});
292+
const state = makeState(thread);
293+
const ref = scopeThreadRef(thread.environmentId, thread.id);
294+
295+
const first = selectThreadByRef(state, ref);
296+
const second = selectThreadByRef(state, ref);
297+
298+
expect(first).toBeDefined();
299+
expect(second).toBe(first);
300+
expect(second?.messages).toBe(first?.messages);
301+
expect(second?.activities).toBe(first?.activities);
302+
expect(second?.proposedPlans).toBe(first?.proposedPlans);
303+
expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries);
304+
});
305+
306+
it("reuses the derived thread when the app state wrapper changes but thread data does not", () => {
307+
const thread = makeThread({
308+
messages: [
309+
{
310+
id: MessageId.make("message-1"),
311+
role: "assistant",
312+
text: "done",
313+
createdAt: "2026-02-13T00:01:00.000Z",
314+
streaming: false,
315+
},
316+
],
317+
});
318+
const state = makeState(thread);
319+
const ref = scopeThreadRef(thread.environmentId, thread.id);
320+
const wrappedState: AppState = {
321+
...state,
322+
environmentStateById: { ...state.environmentStateById },
323+
};
324+
325+
const first = selectThreadByRef(state, ref);
326+
const second = selectThreadByRef(wrappedState, ref);
327+
328+
expect(second).toBe(first);
329+
});
330+
331+
it("updates the derived thread when the underlying thread data changes", () => {
332+
const thread = makeThread();
333+
const ref = scopeThreadRef(thread.environmentId, thread.id);
334+
const firstState = makeState(thread);
335+
const secondState = makeState({
336+
...thread,
337+
messages: [
338+
{
339+
id: MessageId.make("message-2"),
340+
role: "user",
341+
text: "new",
342+
createdAt: "2026-02-13T00:04:00.000Z",
343+
streaming: false,
344+
},
345+
],
346+
});
347+
348+
const first = selectThreadByRef(firstState, ref);
349+
const second = selectThreadByRef(secondState, ref);
350+
351+
expect(second).not.toBe(first);
352+
expect(second?.messages).toHaveLength(1);
353+
expect(second?.messages[0]?.text).toBe("new");
354+
});
355+
356+
it("checks thread existence without materializing the full thread", () => {
357+
const thread = makeThread();
358+
const state = makeState(thread);
359+
const ref = scopeThreadRef(thread.environmentId, thread.id);
360+
361+
expect(selectThreadExistsByRef(state, ref)).toBe(true);
362+
expect(
363+
selectThreadExistsByRef(
364+
state,
365+
scopeThreadRef(thread.environmentId, ThreadId.make("missing")),
366+
),
367+
).toBe(false);
368+
expect(selectThreadExistsByRef(state, null)).toBe(false);
369+
});
370+
});
371+
248372
function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
249373
return {
250374
id: ThreadId.make("thread-1"),

0 commit comments

Comments
 (0)