authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 19:06:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 19:06:10-07:00
logb80e8476b86b6ce2b39c7c5564fed92fd80b4890
tree334deb24da319eeb01fa53656e967d37cccba9bc
parente0e01b4a545d9a8de9d1ec3e5abe98fa8dd4575e
signature Signed by PGP key B5690EEEBB952194

Memoize derived thread reads (#1908)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

6 files changed, 335 insertions(+), 222 deletions(-)

apps/web/src/components/ChatView.tsx+9-3
......@@ -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
571573export 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+26-10
......@@ -17,7 +17,7 @@ import {
1717 stripDiffSearchParams,
1818} from "../diffRouteSearch";
1919import { useMediaQuery } from "../hooks/useMediaQuery";
20import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
20import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
2121import { createThreadSelectorByRef } from "../storeSelectors";
2222import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
2323import { 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+124
......@@ -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
250describe("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
248372function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
249373 return {
250374 id: ThreadId.make("thread-1"),
apps/web/src/store.ts+14-89
......@@ -38,6 +38,7 @@ import {
3838} from "./types";
3939import { resolveEnvironmentHttpUrl } from "./environments/runtime";
4040import { sanitizeThreadErrorMessage } from "./rpc/transportError";
41import { getThreadFromEnvironmentState } from "./threadDerivation";
4142
4243export interface EnvironmentState {
4344 projectIds: ProjectId[];
......@@ -94,19 +95,6 @@ const MAX_THREAD_CHECKPOINTS = 500;
9495const MAX_THREAD_PROPOSED_PLANS = 200;
9596const MAX_THREAD_ACTIVITIES = 500;
9697const EMPTY_THREAD_IDS: ThreadId[] = [];
97const EMPTY_MESSAGE_IDS: MessageId[] = [];
98const EMPTY_ACTIVITY_IDS: string[] = [];
99const EMPTY_PROPOSED_PLAN_IDS: string[] = [];
100const EMPTY_TURN_IDS: TurnId[] = [];
101const EMPTY_MESSAGES: ChatMessage[] = [];
102const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
103const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
104const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
105const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {};
106const EMPTY_ACTIVITY_MAP: Record<string, OrchestrationThreadActivity> = {};
107const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {};
108const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {};
109const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null });
11098
11199function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
112100 return left.length === right.length && left.every((value, index) => value === right[index]);
......@@ -403,78 +391,6 @@ function buildTurnDiffSlice(thread: Thread): {
403391 };
404392}
405393
406function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] {
407 const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS;
408 const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP;
409 if (ids.length === 0) {
410 return EMPTY_MESSAGES;
411 }
412 return ids.flatMap((id) => {
413 const message = byId[id];
414 return message ? [message] : [];
415 });
416}
417
418function selectThreadActivities(
419 state: EnvironmentState,
420 threadId: ThreadId,
421): OrchestrationThreadActivity[] {
422 const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS;
423 const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP;
424 if (ids.length === 0) {
425 return EMPTY_ACTIVITIES;
426 }
427 return ids.flatMap((id) => {
428 const activity = byId[id];
429 return activity ? [activity] : [];
430 });
431}
432
433function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] {
434 const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS;
435 const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP;
436 if (ids.length === 0) {
437 return EMPTY_PROPOSED_PLANS;
438 }
439 return ids.flatMap((id) => {
440 const plan = byId[id];
441 return plan ? [plan] : [];
442 });
443}
444
445function selectThreadTurnDiffSummaries(
446 state: EnvironmentState,
447 threadId: ThreadId,
448): TurnDiffSummary[] {
449 const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS;
450 const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP;
451 if (ids.length === 0) {
452 return EMPTY_TURN_DIFF_SUMMARIES;
453 }
454 return ids.flatMap((id) => {
455 const summary = byId[id];
456 return summary ? [summary] : [];
457 });
458}
459
460function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined {
461 const shell = state.threadShellById[threadId];
462 if (!shell) {
463 return undefined;
464 }
465 const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE;
466 return {
467 ...shell,
468 session: state.threadSessionById[threadId] ?? null,
469 latestTurn: turnState.latestTurn,
470 pendingSourceProposedPlan: turnState.pendingSourceProposedPlan,
471 messages: selectThreadMessages(state, threadId),
472 activities: selectThreadActivities(state, threadId),
473 proposedPlans: selectThreadProposedPlans(state, threadId),
474 turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId),
475 };
476}
477
478394function getProjects(state: EnvironmentState): Project[] {
479395 return state.projectIds.flatMap((projectId) => {
480396 const project = state.projectById[projectId];
......@@ -484,7 +400,7 @@ function getProjects(state: EnvironmentState): Project[] {
484400
485401function getThreads(state: EnvironmentState): Thread[] {
486402 return state.threadIds.flatMap((threadId) => {
487 const thread = getThread(state, threadId);
403 const thread = getThreadFromEnvironmentState(state, threadId);
488404 return thread ? [thread] : [];
489405 });
490406}
......@@ -896,7 +812,7 @@ function updateThreadState(
896812 threadId: ThreadId,
897813 updater: (thread: Thread) => Thread,
898814): EnvironmentState {
899 const currentThread = getThread(state, threadId);
815 const currentThread = getThreadFromEnvironmentState(state, threadId);
900816 if (!currentThread) {
901817 return state;
902818 }
......@@ -1163,7 +1079,7 @@ function applyEnvironmentOrchestrationEvent(
11631079 }
11641080
11651081 case "thread.created": {
1166 const previousThread = getThread(state, event.payload.threadId);
1082 const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId);
11671083 const nextThread = mapThread(
11681084 {
11691085 id: event.payload.threadId,
......@@ -1669,10 +1585,19 @@ export function selectThreadByRef(
16691585 ref: ScopedThreadRef | null | undefined,
16701586): Thread | undefined {
16711587 return ref
1672 ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId)
1588 ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId)
16731589 : undefined;
16741590}
16751591
1592export function selectThreadExistsByRef(
1593 state: AppState,
1594 ref: ScopedThreadRef | null | undefined,
1595): boolean {
1596 return ref
1597 ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined
1598 : false;
1599}
1600
16761601export function selectSidebarThreadSummaryByRef(
16771602 state: AppState,
16781603 ref: ScopedThreadRef | null | undefined,
apps/web/src/storeSelectors.ts+10-120
......@@ -1,40 +1,7 @@
1import {
2 type MessageId,
3 type ScopedProjectRef,
4 type ScopedThreadRef,
5 type ThreadId,
6 type TurnId,
7} from "@t3tools/contracts";
1import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
82import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
9import {
10 type ChatMessage,
11 type Project,
12 type ProposedPlan,
13 type SidebarThreadSummary,
14 type Thread,
15 type ThreadSession,
16 type ThreadTurnState,
17 type TurnDiffSummary,
18} from "./types";
19
20const EMPTY_MESSAGES: ChatMessage[] = [];
21const EMPTY_ACTIVITIES: Thread["activities"] = [];
22const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
23const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
24
25function collectByIds<TKey extends string, TValue>(
26 ids: readonly TKey[] | undefined,
27 byId: Record<TKey, TValue> | undefined,
28): TValue[] {
29 if (!ids || ids.length === 0 || !byId) {
30 return [];
31 }
32
33 return ids.flatMap((id) => {
34 const value = byId[id];
35 return value ? [value] : [];
36 });
37}
3import { type Project, type SidebarThreadSummary, type Thread } from "./types";
4import { getThreadFromEnvironmentState } from "./threadDerivation";
385
396export function createProjectSelectorByRef(
407 ref: ScopedProjectRef | null | undefined,
......@@ -55,17 +22,8 @@ export function createSidebarThreadSummarySelectorByRef(
5522function createScopedThreadSelector(
5623 resolveRef: (state: AppState) => ScopedThreadRef | null | undefined,
5724): (state: AppState) => Thread | undefined {
58 let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined;
59 let previousSession: ThreadSession | null | undefined;
60 let previousTurnState: ThreadTurnState | undefined;
61 let previousMessageIds: MessageId[] | undefined;
62 let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined;
63 let previousActivityIds: string[] | undefined;
64 let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined;
65 let previousProposedPlanIds: string[] | undefined;
66 let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined;
67 let previousTurnDiffIds: TurnId[] | undefined;
68 let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined;
25 let previousEnvironmentState: EnvironmentState | undefined;
26 let previousThreadId: ThreadId | undefined;
6927 let previousThread: Thread | undefined;
7028
7129 return (state) => {
......@@ -75,85 +33,17 @@ function createScopedThreadSelector(
7533 }
7634
7735 const environmentState = selectEnvironmentState(state, ref.environmentId);
78 const threadId = ref.threadId;
79 const shell = environmentState.threadShellById[threadId];
80 if (!shell) {
81 return undefined;
82 }
83
84 const session = environmentState.threadSessionById[threadId] ?? null;
85 const turnState = environmentState.threadTurnStateById[threadId];
86 const messageIds = environmentState.messageIdsByThreadId[threadId];
87 const messageById = environmentState.messageByThreadId[threadId];
88 const activityIds = environmentState.activityIdsByThreadId[threadId];
89 const activityById = environmentState.activityByThreadId[threadId];
90 const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
91 const proposedPlanById = environmentState.proposedPlanByThreadId[threadId];
92 const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId];
93 const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId];
94
9536 if (
9637 previousThread &&
97 previousShell === shell &&
98 previousSession === session &&
99 previousTurnState === turnState &&
100 previousMessageIds === messageIds &&
101 previousMessagesById === messageById &&
102 previousActivityIds === activityIds &&
103 previousActivitiesById === activityById &&
104 previousProposedPlanIds === proposedPlanIds &&
105 previousProposedPlansById === proposedPlanById &&
106 previousTurnDiffIds === turnDiffIds &&
107 previousTurnDiffsById === turnDiffById
38 previousEnvironmentState === environmentState &&
39 previousThreadId === ref.threadId
10840 ) {
10941 return previousThread;
11042 }
11143
112 const nextThread: Thread = {
113 ...shell,
114 session,
115 latestTurn: turnState?.latestTurn ?? null,
116 pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan,
117 messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[]
118 ? ChatMessage[]
119 : never,
120 activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array<
121 infer _
122 >
123 ? Thread["activities"]
124 : never,
125 proposedPlans: collectByIds(
126 proposedPlanIds,
127 proposedPlanById,
128 ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never,
129 turnDiffSummaries: collectByIds(
130 turnDiffIds,
131 turnDiffById,
132 ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never,
133 };
134
135 previousShell = shell;
136 previousSession = session;
137 previousTurnState = turnState;
138 previousMessageIds = messageIds;
139 previousMessagesById = messageById;
140 previousActivityIds = activityIds;
141 previousActivitiesById = activityById;
142 previousProposedPlanIds = proposedPlanIds;
143 previousProposedPlansById = proposedPlanById;
144 previousTurnDiffIds = turnDiffIds;
145 previousTurnDiffsById = turnDiffById;
146 previousThread = {
147 ...nextThread,
148 messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages,
149 activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities,
150 proposedPlans:
151 nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans,
152 turnDiffSummaries:
153 nextThread.turnDiffSummaries.length === 0
154 ? EMPTY_TURN_DIFF_SUMMARIES
155 : nextThread.turnDiffSummaries,
156 };
44 previousEnvironmentState = environmentState;
45 previousThreadId = ref.threadId;
46 previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId);
15747 return previousThread;
15848 };
15949}
apps/web/src/threadDerivation.ts created+152
......@@ -0,0 +1,152 @@
1import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts";
2import type { EnvironmentState } from "./store";
3import type {
4 ChatMessage,
5 ProposedPlan,
6 Thread,
7 ThreadSession,
8 ThreadShell,
9 ThreadTurnState,
10 TurnDiffSummary,
11} from "./types";
12
13const EMPTY_MESSAGES: ChatMessage[] = [];
14const EMPTY_ACTIVITIES: Thread["activities"] = [];
15const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
16const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
17const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {};
18const EMPTY_ACTIVITY_MAP: Record<string, Thread["activities"][number]> = {};
19const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {};
20const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {};
21
22const collectedByIdsCache = new WeakMap<readonly string[], WeakMap<object, readonly unknown[]>>();
23const threadCache = new WeakMap<
24 ThreadShell,
25 {
26 session: ThreadSession | null;
27 turnState: ThreadTurnState | undefined;
28 messages: Thread["messages"];
29 activities: Thread["activities"];
30 proposedPlans: Thread["proposedPlans"];
31 turnDiffSummaries: Thread["turnDiffSummaries"];
32 thread: Thread;
33 }
34>();
35
36function collectByIds<TKey extends string, TValue>(
37 ids: readonly TKey[] | undefined,
38 byId: Record<TKey, TValue> | undefined,
39 emptyValue: TValue[],
40): TValue[] {
41 if (!ids || ids.length === 0 || !byId) {
42 return emptyValue;
43 }
44
45 const cachedByRecord = collectedByIdsCache.get(ids);
46 const cached = cachedByRecord?.get(byId);
47 if (cached) {
48 return cached as TValue[];
49 }
50
51 const nextValues = ids.flatMap((id) => {
52 const value = byId[id];
53 return value ? [value] : [];
54 });
55 const nextCachedByRecord = cachedByRecord ?? new WeakMap<object, readonly unknown[]>();
56 nextCachedByRecord.set(byId, nextValues);
57 if (!cachedByRecord) {
58 collectedByIdsCache.set(ids, nextCachedByRecord);
59 }
60 return nextValues;
61}
62
63function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] {
64 return collectByIds(
65 state.messageIdsByThreadId[threadId],
66 state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP,
67 EMPTY_MESSAGES,
68 );
69}
70
71function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] {
72 return collectByIds(
73 state.activityIdsByThreadId[threadId],
74 state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP,
75 EMPTY_ACTIVITIES,
76 );
77}
78
79function selectThreadProposedPlans(
80 state: EnvironmentState,
81 threadId: ThreadId,
82): Thread["proposedPlans"] {
83 return collectByIds(
84 state.proposedPlanIdsByThreadId[threadId],
85 state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP,
86 EMPTY_PROPOSED_PLANS,
87 );
88}
89
90function selectThreadTurnDiffSummaries(
91 state: EnvironmentState,
92 threadId: ThreadId,
93): Thread["turnDiffSummaries"] {
94 return collectByIds(
95 state.turnDiffIdsByThreadId[threadId],
96 state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP,
97 EMPTY_TURN_DIFF_SUMMARIES,
98 );
99}
100
101export function getThreadFromEnvironmentState(
102 state: EnvironmentState,
103 threadId: ThreadId,
104): Thread | undefined {
105 const shell = state.threadShellById[threadId];
106 if (!shell) {
107 return undefined;
108 }
109
110 const session = state.threadSessionById[threadId] ?? null;
111 const turnState = state.threadTurnStateById[threadId];
112 const messages = selectThreadMessages(state, threadId);
113 const activities = selectThreadActivities(state, threadId);
114 const proposedPlans = selectThreadProposedPlans(state, threadId);
115 const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId);
116 const cached = threadCache.get(shell);
117
118 if (
119 cached &&
120 cached.session === session &&
121 cached.turnState === turnState &&
122 cached.messages === messages &&
123 cached.activities === activities &&
124 cached.proposedPlans === proposedPlans &&
125 cached.turnDiffSummaries === turnDiffSummaries
126 ) {
127 return cached.thread;
128 }
129
130 const thread: Thread = {
131 ...shell,
132 session,
133 latestTurn: turnState?.latestTurn ?? null,
134 pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan,
135 messages,
136 activities,
137 proposedPlans,
138 turnDiffSummaries,
139 };
140
141 threadCache.set(shell, {
142 session,
143 turnState,
144 messages,
145 activities,
146 proposedPlans,
147 turnDiffSummaries,
148 thread,
149 });
150
151 return thread;
152}