Skip to content

Commit e786266

Browse files
perf(web): cache thread plan catalog lookups (#2)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fed28e2 commit e786266

3 files changed

Lines changed: 186 additions & 115 deletions

File tree

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
buildExpiredTerminalContextToastCopy,
1717
canStartThreadTurn,
1818
createLocalDispatchSnapshot,
19+
createThreadPlanCatalogSelector,
1920
deriveComposerSendState,
2021
hasServerAcknowledgedLocalDispatch,
2122
reconcileMountedTerminalThreadIds,
@@ -247,6 +248,7 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => {
247248

248249
const makeThread = (input?: {
249250
id?: ThreadId;
251+
proposedPlans?: Thread["proposedPlans"];
250252
latestTurn?: {
251253
turnId: TurnId;
252254
state: "running" | "completed";
@@ -266,7 +268,7 @@ const makeThread = (input?: {
266268
interactionMode: "default" as const,
267269
session: null,
268270
messages: [],
269-
proposedPlans: [],
271+
proposedPlans: input?.proposedPlans ?? [],
270272
error: null,
271273
createdAt: "2026-03-29T00:00:00.000Z",
272274
archivedAt: null,
@@ -396,6 +398,46 @@ afterEach(() => {
396398
setStoreThreads([]);
397399
});
398400

401+
describe("createThreadPlanCatalogSelector", () => {
402+
it("reuses the resolved environment for stable thread lookups", () => {
403+
const threadId = ThreadId.make("thread-with-plan");
404+
const proposedPlan: Thread["proposedPlans"][number] = {
405+
id: "plan-1",
406+
turnId: TurnId.make("turn-1"),
407+
planMarkdown: "# Plan",
408+
implementedAt: null,
409+
implementationThreadId: null,
410+
createdAt: "2026-03-29T00:00:00.000Z",
411+
updatedAt: "2026-03-29T00:00:00.000Z",
412+
};
413+
setStoreThreads([
414+
makeThread({
415+
id: threadId,
416+
proposedPlans: [proposedPlan],
417+
}),
418+
]);
419+
const selector = createThreadPlanCatalogSelector([threadId]);
420+
const firstResult = selector(useStore.getState());
421+
const throwingEnvironmentState = {
422+
...useStore.getState().environmentStateById[localEnvironmentId],
423+
get threadShellById(): EnvironmentState["threadShellById"] {
424+
throw new Error("unrelated environment should not be scanned");
425+
},
426+
} as EnvironmentState;
427+
428+
const nextResult = selector({
429+
...useStore.getState(),
430+
environmentStateById: {
431+
[EnvironmentId.make("environment-unrelated")]: throwingEnvironmentState,
432+
...useStore.getState().environmentStateById,
433+
},
434+
});
435+
436+
expect(firstResult).toEqual([{ id: threadId, proposedPlans: [proposedPlan] }]);
437+
expect(nextResult).toBe(firstResult);
438+
});
439+
});
440+
399441
describe("waitForStartedServerThread", () => {
400442
it("resolves immediately when the thread is already started", async () => {
401443
const threadId = ThreadId.make("thread-started");

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

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
1212
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1313
import { Schema } from "effect";
14-
import { selectThreadByRef, useStore } from "../store";
14+
import { type AppState, type EnvironmentState, selectThreadByRef, useStore } from "../store";
1515
import {
1616
filterTerminalContextsWithText,
1717
stripInlineTerminalContextPlaceholders,
@@ -24,6 +24,145 @@ export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
2424

2525
export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2626

27+
const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
28+
29+
export type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
30+
31+
type CachedThreadPlanCatalogEntry = {
32+
environmentId: EnvironmentId | null;
33+
shell: object | null;
34+
proposedPlanIds: readonly string[] | undefined;
35+
proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
36+
entry: ThreadPlanCatalogEntry;
37+
};
38+
39+
function findThreadPlanCatalogSource(
40+
state: AppState,
41+
threadId: ThreadId,
42+
previous: CachedThreadPlanCatalogEntry | undefined,
43+
):
44+
| {
45+
environmentId: EnvironmentId;
46+
environmentState: EnvironmentState;
47+
shell: object;
48+
}
49+
| undefined {
50+
if (previous?.environmentId) {
51+
const environmentState = state.environmentStateById[previous.environmentId];
52+
const shell = environmentState?.threadShellById[threadId];
53+
if (shell) {
54+
return {
55+
environmentId: previous.environmentId,
56+
environmentState,
57+
shell,
58+
};
59+
}
60+
}
61+
62+
for (const [environmentId, environmentState] of Object.entries(
63+
state.environmentStateById,
64+
) as Array<[EnvironmentId, EnvironmentState]>) {
65+
const shell = environmentState.threadShellById[threadId];
66+
if (shell) {
67+
return {
68+
environmentId,
69+
environmentState,
70+
shell,
71+
};
72+
}
73+
}
74+
75+
return undefined;
76+
}
77+
78+
export function createThreadPlanCatalogSelector(
79+
threadIds: readonly ThreadId[],
80+
): (state: AppState) => ThreadPlanCatalogEntry[] {
81+
let previousThreadIds: readonly ThreadId[] = [];
82+
let previousResult: ThreadPlanCatalogEntry[] = [];
83+
let previousEntries = new Map<ThreadId, CachedThreadPlanCatalogEntry>();
84+
85+
return (state) => {
86+
const sameThreadIds =
87+
previousThreadIds.length === threadIds.length &&
88+
previousThreadIds.every((id, index) => id === threadIds[index]);
89+
const nextEntries = new Map<ThreadId, CachedThreadPlanCatalogEntry>();
90+
const nextResult: ThreadPlanCatalogEntry[] = [];
91+
let changed = !sameThreadIds;
92+
93+
for (const threadId of threadIds) {
94+
const previous = previousEntries.get(threadId);
95+
const source = findThreadPlanCatalogSource(state, threadId, previous);
96+
97+
if (!source) {
98+
if (
99+
previous &&
100+
previous.environmentId === null &&
101+
previous.shell === null &&
102+
previous.proposedPlanIds === undefined &&
103+
previous.proposedPlansById === undefined
104+
) {
105+
nextEntries.set(threadId, previous);
106+
continue;
107+
}
108+
changed = true;
109+
nextEntries.set(threadId, {
110+
environmentId: null,
111+
shell: null,
112+
proposedPlanIds: undefined,
113+
proposedPlansById: undefined,
114+
entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS },
115+
});
116+
continue;
117+
}
118+
119+
const proposedPlanIds = source.environmentState.proposedPlanIdsByThreadId[threadId];
120+
const proposedPlansById = source.environmentState.proposedPlanByThreadId[threadId] as
121+
| Record<string, Thread["proposedPlans"][number]>
122+
| undefined;
123+
124+
if (
125+
previous &&
126+
previous.environmentId === source.environmentId &&
127+
previous.shell === source.shell &&
128+
previous.proposedPlanIds === proposedPlanIds &&
129+
previous.proposedPlansById === proposedPlansById
130+
) {
131+
nextEntries.set(threadId, previous);
132+
nextResult.push(previous.entry);
133+
continue;
134+
}
135+
136+
changed = true;
137+
const proposedPlans =
138+
proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById
139+
? proposedPlanIds.flatMap((planId) => {
140+
const proposedPlan = proposedPlansById[planId];
141+
return proposedPlan ? [proposedPlan] : [];
142+
})
143+
: EMPTY_PROPOSED_PLANS;
144+
const entry = { id: threadId, proposedPlans };
145+
nextEntries.set(threadId, {
146+
environmentId: source.environmentId,
147+
shell: source.shell,
148+
proposedPlanIds,
149+
proposedPlansById,
150+
entry,
151+
});
152+
nextResult.push(entry);
153+
}
154+
155+
if (!changed && previousResult.length === nextResult.length) {
156+
return previousResult;
157+
}
158+
159+
previousThreadIds = threadIds;
160+
previousEntries = nextEntries;
161+
previousResult = nextResult;
162+
return nextResult;
163+
};
164+
}
165+
27166
export function buildLocalDraftThread(
28167
threadId: ThreadId,
29168
draftThread: DraftThreadState,

apps/web/src/components/ChatView.tsx

Lines changed: 3 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ import {
172172
canStartThreadTurn,
173173
collectUserMessageBlobPreviewUrls,
174174
createLocalDispatchSnapshot,
175+
createThreadPlanCatalogSelector,
175176
deriveComposerSendState,
176177
hasServerAcknowledgedLocalDispatch,
177178
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
@@ -186,6 +187,7 @@ import {
186187
revokeBlobPreviewUrl,
187188
revokeUserMessagePreviewUrls,
188189
shouldWriteThreadErrorToCurrentServerThread,
190+
type ThreadPlanCatalogEntry,
189191
waitForStartedServerThread,
190192
} from "./ChatView.logic";
191193
import { useLocalStorage } from "~/hooks/useLocalStorage";
@@ -212,15 +214,12 @@ import { useChatFindHighlight } from "./chat/useChatFindHighlight";
212214
const IMAGE_ONLY_BOOTSTRAP_PROMPT =
213215
"[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
214216
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
215-
const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
216217
const EMPTY_PROVIDERS: ServerProvider[] = [];
217218
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
218219
const EMPTY_CHAT_FIND_ROWS: ChatFindRow[] = [];
219220
const EMPTY_CHAT_FIND_MATCHES: ChatFindMatch[] = [];
220221
const TIMELINE_ROW_ESTIMATED_SIZE_PX = 90;
221222

222-
type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
223-
224223
function escapeAttributeSelectorValue(value: string): string {
225224
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
226225
return CSS.escape(value);
@@ -341,116 +340,7 @@ export function getCopilotResumeCommand(
341340
}
342341

343342
function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
344-
return useStore(
345-
useMemo(() => {
346-
let previousThreadIds: readonly ThreadId[] = [];
347-
let previousResult: ThreadPlanCatalogEntry[] = [];
348-
let previousEntries = new Map<
349-
ThreadId,
350-
{
351-
shell: object | null;
352-
proposedPlanIds: readonly string[] | undefined;
353-
proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
354-
entry: ThreadPlanCatalogEntry;
355-
}
356-
>();
357-
358-
return (state) => {
359-
const sameThreadIds =
360-
previousThreadIds.length === threadIds.length &&
361-
previousThreadIds.every((id, index) => id === threadIds[index]);
362-
const nextEntries = new Map<
363-
ThreadId,
364-
{
365-
shell: object | null;
366-
proposedPlanIds: readonly string[] | undefined;
367-
proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
368-
entry: ThreadPlanCatalogEntry;
369-
}
370-
>();
371-
const nextResult: ThreadPlanCatalogEntry[] = [];
372-
let changed = !sameThreadIds;
373-
374-
for (const threadId of threadIds) {
375-
let shell: object | undefined;
376-
let proposedPlanIds: readonly string[] | undefined;
377-
let proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
378-
379-
for (const environmentState of Object.values(state.environmentStateById)) {
380-
const matchedShell = environmentState.threadShellById[threadId];
381-
if (!matchedShell) {
382-
continue;
383-
}
384-
shell = matchedShell;
385-
proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
386-
proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as
387-
| Record<string, Thread["proposedPlans"][number]>
388-
| undefined;
389-
break;
390-
}
391-
392-
if (!shell) {
393-
const previous = previousEntries.get(threadId);
394-
if (
395-
previous &&
396-
previous.shell === null &&
397-
previous.proposedPlanIds === undefined &&
398-
previous.proposedPlansById === undefined
399-
) {
400-
nextEntries.set(threadId, previous);
401-
continue;
402-
}
403-
changed = true;
404-
nextEntries.set(threadId, {
405-
shell: null,
406-
proposedPlanIds: undefined,
407-
proposedPlansById: undefined,
408-
entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS },
409-
});
410-
continue;
411-
}
412-
413-
const previous = previousEntries.get(threadId);
414-
if (
415-
previous &&
416-
previous.shell === shell &&
417-
previous.proposedPlanIds === proposedPlanIds &&
418-
previous.proposedPlansById === proposedPlansById
419-
) {
420-
nextEntries.set(threadId, previous);
421-
nextResult.push(previous.entry);
422-
continue;
423-
}
424-
425-
changed = true;
426-
const proposedPlans =
427-
proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById
428-
? proposedPlanIds.flatMap((planId) => {
429-
const proposedPlan = proposedPlansById?.[planId];
430-
return proposedPlan ? [proposedPlan] : [];
431-
})
432-
: EMPTY_PROPOSED_PLANS;
433-
const entry = { id: threadId, proposedPlans };
434-
nextEntries.set(threadId, {
435-
shell,
436-
proposedPlanIds,
437-
proposedPlansById,
438-
entry,
439-
});
440-
nextResult.push(entry);
441-
}
442-
443-
if (!changed && previousResult.length === nextResult.length) {
444-
return previousResult;
445-
}
446-
447-
previousThreadIds = threadIds;
448-
previousEntries = nextEntries;
449-
previousResult = nextResult;
450-
return nextResult;
451-
};
452-
}, [threadIds]),
453-
);
343+
return useStore(useMemo(() => createThreadPlanCatalogSelector(threadIds), [threadIds]));
454344
}
455345

456346
function formatOutgoingPrompt(params: {

0 commit comments

Comments
 (0)