History

Optimize chat composer context updates

Cursor Agent committed 4 months ago

Commit 487295d1 parent b489ea55 files · +211 −25Browse files

Unified · Split

5 files
  1. apps/web/src/components/ChatView.tsx +3 −2
  2. apps/web/src/components/chat/ChatComposer.tsx +20 −23
  3. apps/web/src/lib/contextWindow.ts +29 −0
  4. apps/web/src/storeSelectors.test.ts +108 −0
  5. apps/web/src/storeSelectors.ts +51 −0
apps/web/src/components/ChatView.tsx+3−2
@@ -4903,7 +4903,9 @@ function ChatViewContent(props: ChatViewProps) { · 4902 unmodified lines
49034903 draftId={draftId}
49044904 activeThreadId={activeThreadId}
49054905 activeThreadEnvironmentId={activeThread?.environmentId}
4906− activeThread={activeThread}
4906+ activeThreadSessionProviderInstanceId={
4907+ activeThread?.session?.providerInstanceId
4908+ }
49074909 isServerThread={isServerThread}
49084910 isLocalDraftThread={isLocalDraftThread}
49094911 phase={phase}
@@ -4932,7 +4934,6 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines
49324934 providerStatuses={providerStatuses as ServerProvider[]}
49334935 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
49344936 activeThreadModelSelection={activeThread?.modelSelection}
4935− activeThreadActivities={activeThread?.activities}
49364937 resolvedTheme={resolvedTheme}
49374938 settings={settings}
49384939 keybindings={keybindings}
apps/web/src/components/chat/ChatComposer.tsx+20−23
@@ -111,14 +111,13 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; · 110 unmodified lines
111111 import type { SessionPhase, Thread } from "../../types";
112112 import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
113113 import type { PendingApproval, PendingUserInput } from "../../session-logic";
114−import {
115− deriveLatestContextWindowSnapshot,
116− formatProviderDisplayName,
117−} from "../../lib/contextWindow";
114+import { type ContextWindowSnapshot, formatProviderDisplayName } from "../../lib/contextWindow";
118115 import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
119116 import { searchProviderSkills } from "../../providerSkillSearch";
120117 import { useMediaQuery } from "../../hooks/useMediaQuery";
121118 import type { ReviewCommentContext } from "../../reviewCommentContext";
119+import { useStore } from "../../store";
120+import { createContextWindowSnapshotSelectorByRef } from "../../storeSelectors";
122121
123122 const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
124123
@@ -323,7 +322,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop · 198 unmodified lines
323322
324323 const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
325324 compact: boolean;
326− activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
325+ activeContextWindow: ContextWindowSnapshot | null;
327326 activeThreadProviderDisplayName: string | null;
328327 isPreparingWorktree: boolean;
329328 pendingAction: {
@@ -432,7 +431,7 @@ export interface ChatComposerProps { · 102 unmodified lines
432431 // Thread context
433432 activeThreadId: ThreadId | null;
434433 activeThreadEnvironmentId: EnvironmentId | undefined;
435− activeThread: Thread | undefined;
434+ activeThreadSessionProviderInstanceId: ProviderInstanceId | undefined;
436435 isServerThread: boolean;
437436 isLocalDraftThread: boolean;
438437
@@ -481,9 +480,6 @@ export interface ChatComposerProps { · 42 unmodified lines
481480 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
482481 activeThreadModelSelection: ModelSelection | null | undefined;
483482
484− // Context window
485− activeThreadActivities: Thread["activities"] | undefined;
486−
487483 // Misc
488484 resolvedTheme: "light" | "dark";
489485 settings: UnifiedSettings;
@@ -547,7 +543,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 57 unmodified lines
547543 draftId,
548544 activeThreadId,
549545 activeThreadEnvironmentId: _activeThreadEnvironmentId,
550− activeThread,
546+ activeThreadSessionProviderInstanceId,
551547 isServerThread: _isServerThread,
552548 isLocalDraftThread: _isLocalDraftThread,
553549 phase,
@@ -576,7 +572,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 22 unmodified lines
576572 providerStatuses,
577573 activeProjectDefaultModelSelection,
578574 activeThreadModelSelection,
579− activeThreadActivities,
580575 resolvedTheme,
581576 settings,
582577 keybindings,
@@ -663,7 +658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 80 unmodified lines
663658 );
664659 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
665660 const threadProvider =
666− activeThread?.session?.providerInstanceId ??
661+ activeThreadSessionProviderInstanceId ??
667662 activeThreadModelSelection?.instanceId ??
668663 activeProjectDefaultModelSelection?.instanceId ??
669664 null;
@@ -677,16 +672,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines
677672 ) ?? ProviderDriverKind.make("codex");
678673 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
679674 const lockedContinuationGroupKey = useMemo((): string | null => {
680− if (!lockedProvider || !activeThread) return null;
675+ if (!lockedProvider || !activeThreadId) return null;
681676 const lockedInstanceId =
682− activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
677+ activeThreadSessionProviderInstanceId ?? activeThreadModelSelection?.instanceId;
683678 if (!lockedInstanceId) return null;
684679 return (
685680 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
686681 ?.continuationGroupKey ?? null
687682 );
688683 }, [
689− activeThread,
684+ activeThreadId,
685+ activeThreadSessionProviderInstanceId,
690686 activeThreadModelSelection?.instanceId,
691687 lockedProvider,
692688 providerInstanceEntries,
@@ -705,7 +701,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines
705701 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
706702 const candidates: Array<string | null | undefined> = [
707703 composerDraft.activeProvider,
708− activeThread?.session?.providerInstanceId,
704+ activeThreadSessionProviderInstanceId,
709705 activeThreadModelSelection?.instanceId,
710706 activeProjectDefaultModelSelection?.instanceId,
711707 ];
@@ -747,7 +743,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 35 unmodified lines
747743 );
748744 }, [
749745 activeProjectDefaultModelSelection?.instanceId,
750− activeThread?.session?.providerInstanceId,
746+ activeThreadSessionProviderInstanceId,
751747 activeThreadModelSelection?.instanceId,
752748 composerDraft.activeProvider,
753749 explicitSelectedInstanceId,
@@ -841,10 +837,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 87 unmodified lines
841837 // ------------------------------------------------------------------
842838 // Context window
843839 // ------------------------------------------------------------------
844− const activeContextWindow = useMemo(
845− () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
846− [activeThreadActivities],
840+ const contextWindowSelector = useMemo(
841+ () => createContextWindowSnapshotSelectorByRef(routeThreadRef),
842+ [routeThreadRef],
847843 );
844+ const activeContextWindow = useStore(contextWindowSelector);
848845 const activeThreadProviderDisplayName = useMemo(() => {
849846 if (!activeThreadModelSelection) return null;
850847 const entry = providerStatuses.find(
@@ -1969,7 +1966,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1118 unmodified lines
19691966 );
19701967 },
19711968 addTerminalContext: (selection: TerminalContextSelection) => {
1972− if (!activeThread) return;
1969+ if (!activeThreadId) return;
19731970 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
19741971 value: promptRef.current,
19751972 cursor: composerCursor,
@@ -1989,7 +1986,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 13 unmodified lines
19891986 insertion.prompt,
19901987 {
19911988 id: randomUUID(),
1992− threadId: activeThread.id,
1989+ threadId: activeThreadId,
19931990 createdAt: new Date().toISOString(),
19941991 ...selection,
19951992 },
@@ -2019,7 +2016,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines
20192016 }),
20202017 }),
20212018 [
2022− activeThread,
2019+ activeThreadId,
20232020 composerDraftTarget,
20242021 composerCursor,
20252022 composerTerminalContexts,
apps/web/src/lib/contextWindow.ts+29−0
@@ -95,6 +95,35 @@ export function deriveLatestContextWindowSnapshot( · 94 unmodified lines
9595 return null;
9696 }
9797
98+export function contextWindowSnapshotsEqual(
99+ left: ContextWindowSnapshot | null,
100+ right: ContextWindowSnapshot | null,
101+): boolean {
102+ if (left === right) return true;
103+ if (left === null || right === null) return false;
104+ return (
105+ left.usedTokens === right.usedTokens &&
106+ left.totalProcessedTokens === right.totalProcessedTokens &&
107+ left.maxTokens === right.maxTokens &&
108+ left.remainingTokens === right.remainingTokens &&
109+ left.usedPercentage === right.usedPercentage &&
110+ left.remainingPercentage === right.remainingPercentage &&
111+ left.inputTokens === right.inputTokens &&
112+ left.cachedInputTokens === right.cachedInputTokens &&
113+ left.outputTokens === right.outputTokens &&
114+ left.reasoningOutputTokens === right.reasoningOutputTokens &&
115+ left.lastUsedTokens === right.lastUsedTokens &&
116+ left.lastInputTokens === right.lastInputTokens &&
117+ left.lastCachedInputTokens === right.lastCachedInputTokens &&
118+ left.lastOutputTokens === right.lastOutputTokens &&
119+ left.lastReasoningOutputTokens === right.lastReasoningOutputTokens &&
120+ left.toolUses === right.toolUses &&
121+ left.durationMs === right.durationMs &&
122+ left.compactsAutomatically === right.compactsAutomatically &&
123+ left.updatedAt === right.updatedAt
124+ );
125+}
126+
98127 export function formatContextWindowTokens(value: number | null): string {
99128 if (value === null || !Number.isFinite(value)) {
100129 return "0";
apps/web/src/storeSelectors.test.ts+108−0
@@ -0,0 +1,108 @@
1+import {
2+ EnvironmentId,
3+ EventId,
4+ type OrchestrationThreadActivity,
5+ ThreadId,
6+ TurnId,
7+} from "@t3tools/contracts";
8+import { describe, expect, it } from "vite-plus/test";
9+
10+import { type AppState, type EnvironmentState } from "./store";
11+import { createContextWindowSnapshotSelectorByRef } from "./storeSelectors";
12+
13+const environmentId = EnvironmentId.make("environment-local");
14+const threadId = ThreadId.make("thread-1");
15+const threadRef = { environmentId, threadId };
16+
17+function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity {
18+ return {
19+ id: EventId.make(id),
20+ tone: "info",
21+ kind,
22+ summary: kind,
23+ payload,
24+ turnId: TurnId.make("turn-1"),
25+ createdAt: `2026-06-17T16:00:${id.padStart(2, "0")}.000Z`,
26+ };
27+}
28+
29+function makeState(activities: ReadonlyArray<OrchestrationThreadActivity>): AppState {
30+ const environmentState: EnvironmentState = {
31+ projectIds: [],
32+ projectById: {},
33+ threadIds: [threadId],
34+ threadIdsByProjectId: {},
35+ threadShellById: {},
36+ threadSessionById: {},
37+ threadTurnStateById: {},
38+ messageIdsByThreadId: {},
39+ messageByThreadId: {},
40+ activityIdsByThreadId: {
41+ [threadId]: activities.map((activity) => activity.id),
42+ },
43+ activityByThreadId: {
44+ [threadId]: Object.fromEntries(
45+ activities.map((activity) => [activity.id, activity] as const),
46+ ) as Record<string, OrchestrationThreadActivity>,
47+ },
48+ proposedPlanIdsByThreadId: {},
49+ proposedPlanByThreadId: {},
50+ turnDiffIdsByThreadId: {},
51+ turnDiffSummaryByThreadId: {},
52+ sidebarThreadSummaryById: {},
53+ bootstrapComplete: true,
54+ };
55+ return {
56+ activeEnvironmentId: environmentId,
57+ environmentStateById: {
58+ [environmentId]: environmentState,
59+ },
60+ };
61+}
62+
63+describe("storeSelectors", () => {
64+ it("reuses the context window snapshot when unrelated activities append", () => {
65+ const contextActivity = makeActivity("1", "context-window.updated", {
66+ usedTokens: 42_000,
67+ maxTokens: 258_000,
68+ totalProcessedTokens: 90_000,
69+ });
70+ const selector = createContextWindowSnapshotSelectorByRef(threadRef);
71+
72+ const first = selector(makeState([contextActivity]));
73+ const second = selector(
74+ makeState([contextActivity, makeActivity("2", "tool.completed", { toolName: "profile" })]),
75+ );
76+
77+ expect(first).not.toBeNull();
78+ expect(second).toBe(first);
79+ });
80+
81+ it("returns a new context window snapshot when context usage changes", () => {
82+ const selector = createContextWindowSnapshotSelectorByRef(threadRef);
83+ const first = selector(
84+ makeState([
85+ makeActivity("1", "context-window.updated", {
86+ usedTokens: 42_000,
87+ maxTokens: 258_000,
88+ }),
89+ ]),
90+ );
91+ const second = selector(
92+ makeState([
93+ makeActivity("1", "context-window.updated", {
94+ usedTokens: 42_000,
95+ maxTokens: 258_000,
96+ }),
97+ makeActivity("2", "context-window.updated", {
98+ usedTokens: 43_000,
99+ maxTokens: 258_000,
100+ }),
101+ ]),
102+ );
103+
104+ expect(first).not.toBeNull();
105+ expect(second).not.toBe(first);
106+ expect(second?.usedTokens).toBe(43_000);
107+ });
108+});
apps/web/src/storeSelectors.ts+51−0
@@ -1,4 +1,9 @@
11 import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
2+import {
3+ contextWindowSnapshotsEqual,
4+ deriveLatestContextWindowSnapshot,
5+ type ContextWindowSnapshot,
6+} from "./lib/contextWindow";
27 import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
38 import { type Project, type Thread } from "./types";
49 import { getThreadFromEnvironmentState } from "./threadDerivation";
@@ -45,6 +50,52 @@ export function createThreadSelectorByRef( · 40 unmodified lines
4550 return createScopedThreadSelector(() => ref);
4651 }
4752
53+export function createContextWindowSnapshotSelectorByRef(
54+ ref: ScopedThreadRef | null | undefined,
55+): (state: AppState) => ContextWindowSnapshot | null {
56+ let previousEnvironmentState: EnvironmentState | undefined;
57+ let previousThreadId: ThreadId | undefined;
58+ let previousActivityIds: readonly string[] | undefined;
59+ let previousSnapshot: ContextWindowSnapshot | null = null;
60+
61+ return (state) => {
62+ if (!ref) {
63+ previousEnvironmentState = undefined;
64+ previousThreadId = undefined;
65+ previousActivityIds = undefined;
66+ previousSnapshot = null;
67+ return null;
68+ }
69+
70+ const environmentState = selectEnvironmentState(state, ref.environmentId);
71+ const activityIds = environmentState.activityIdsByThreadId[ref.threadId];
72+ if (
73+ previousEnvironmentState === environmentState &&
74+ previousThreadId === ref.threadId &&
75+ previousActivityIds === activityIds
76+ ) {
77+ return previousSnapshot;
78+ }
79+
80+ const activities =
81+ activityIds?.flatMap((activityId) => {
82+ const activity = environmentState.activityByThreadId[ref.threadId]?.[activityId];
83+ return activity ? [activity] : [];
84+ }) ?? [];
85+ const nextSnapshot = deriveLatestContextWindowSnapshot(activities);
86+ previousEnvironmentState = environmentState;
87+ previousThreadId = ref.threadId;
88+ previousActivityIds = activityIds;
89+
90+ if (contextWindowSnapshotsEqual(previousSnapshot, nextSnapshot)) {
91+ return previousSnapshot;
92+ }
93+
94+ previousSnapshot = nextSnapshot;
95+ return nextSnapshot;
96+ };
97+}
98+
4899 export function createThreadSelectorAcrossEnvironments(
49100 threadId: ThreadId | null | undefined,
50101 ): (state: AppState) => Thread | undefined {