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
4903 draftId={draftId}4903 draftId={draftId}
4904 activeThreadId={activeThreadId}4904 activeThreadId={activeThreadId}
4905 activeThreadEnvironmentId={activeThread?.environmentId}4905 activeThreadEnvironmentId={activeThread?.environmentId}
4906 activeThread={activeThread}4906 activeThreadSessionProviderInstanceId={
4907 activeThread?.session?.providerInstanceId
4908 }
4907 isServerThread={isServerThread}4909 isServerThread={isServerThread}
4908 isLocalDraftThread={isLocalDraftThread}4910 isLocalDraftThread={isLocalDraftThread}
4909 phase={phase}4911 phase={phase}
@@ -4932,7 +4934,6 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines
4932 providerStatuses={providerStatuses as ServerProvider[]}4934 providerStatuses={providerStatuses as ServerProvider[]}
4933 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}4935 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
4934 activeThreadModelSelection={activeThread?.modelSelection}4936 activeThreadModelSelection={activeThread?.modelSelection}
4935 activeThreadActivities={activeThread?.activities}
4936 resolvedTheme={resolvedTheme}4937 resolvedTheme={resolvedTheme}
4937 settings={settings}4938 settings={settings}
4938 keybindings={keybindings}4939 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
111import type { SessionPhase, Thread } from "../../types";111import type { SessionPhase, Thread } from "../../types";
112import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";112import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
113import type { PendingApproval, PendingUserInput } from "../../session-logic";113import type { PendingApproval, PendingUserInput } from "../../session-logic";
114import {114import { type ContextWindowSnapshot, formatProviderDisplayName } from "../../lib/contextWindow";
115 deriveLatestContextWindowSnapshot,
116 formatProviderDisplayName,
117} from "../../lib/contextWindow";
118import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";115import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
119import { searchProviderSkills } from "../../providerSkillSearch";116import { searchProviderSkills } from "../../providerSkillSearch";
120import { useMediaQuery } from "../../hooks/useMediaQuery";117import { useMediaQuery } from "../../hooks/useMediaQuery";
121import type { ReviewCommentContext } from "../../reviewCommentContext";118import type { ReviewCommentContext } from "../../reviewCommentContext";
119import { useStore } from "../../store";
120import { createContextWindowSnapshotSelectorByRef } from "../../storeSelectors";
122 121
123const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;122const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
124 123
@@ -323,7 +322,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop · 198 unmodified lines
323 322
324const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {323const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
325 compact: boolean;324 compact: boolean;
326 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;325 activeContextWindow: ContextWindowSnapshot | null;
327 activeThreadProviderDisplayName: string | null;326 activeThreadProviderDisplayName: string | null;
328 isPreparingWorktree: boolean;327 isPreparingWorktree: boolean;
329 pendingAction: {328 pendingAction: {
@@ -432,7 +431,7 @@ export interface ChatComposerProps { · 102 unmodified lines
432 // Thread context431 // Thread context
433 activeThreadId: ThreadId | null;432 activeThreadId: ThreadId | null;
434 activeThreadEnvironmentId: EnvironmentId | undefined;433 activeThreadEnvironmentId: EnvironmentId | undefined;
435 activeThread: Thread | undefined;434 activeThreadSessionProviderInstanceId: ProviderInstanceId | undefined;
436 isServerThread: boolean;435 isServerThread: boolean;
437 isLocalDraftThread: boolean;436 isLocalDraftThread: boolean;
438 437
@@ -481,9 +480,6 @@ export interface ChatComposerProps { · 42 unmodified lines
481 activeProjectDefaultModelSelection: ModelSelection | null | undefined;480 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
482 activeThreadModelSelection: ModelSelection | null | undefined;481 activeThreadModelSelection: ModelSelection | null | undefined;
483 482
484 // Context window
485 activeThreadActivities: Thread["activities"] | undefined;
486
487 // Misc483 // Misc
488 resolvedTheme: "light" | "dark";484 resolvedTheme: "light" | "dark";
489 settings: UnifiedSettings;485 settings: UnifiedSettings;
@@ -547,7 +543,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 57 unmodified lines
547 draftId,543 draftId,
548 activeThreadId,544 activeThreadId,
549 activeThreadEnvironmentId: _activeThreadEnvironmentId,545 activeThreadEnvironmentId: _activeThreadEnvironmentId,
550 activeThread,546 activeThreadSessionProviderInstanceId,
551 isServerThread: _isServerThread,547 isServerThread: _isServerThread,
552 isLocalDraftThread: _isLocalDraftThread,548 isLocalDraftThread: _isLocalDraftThread,
553 phase,549 phase,
@@ -576,7 +572,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 22 unmodified lines
576 providerStatuses,572 providerStatuses,
577 activeProjectDefaultModelSelection,573 activeProjectDefaultModelSelection,
578 activeThreadModelSelection,574 activeThreadModelSelection,
579 activeThreadActivities,
580 resolvedTheme,575 resolvedTheme,
581 settings,576 settings,
582 keybindings,577 keybindings,
@@ -663,7 +658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 80 unmodified lines
663 );658 );
664 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;659 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
665 const threadProvider =660 const threadProvider =
666 activeThread?.session?.providerInstanceId ??661 activeThreadSessionProviderInstanceId ??
667 activeThreadModelSelection?.instanceId ??662 activeThreadModelSelection?.instanceId ??
668 activeProjectDefaultModelSelection?.instanceId ??663 activeProjectDefaultModelSelection?.instanceId ??
669 null;664 null;
@@ -677,16 +672,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines
677 ) ?? ProviderDriverKind.make("codex");672 ) ?? ProviderDriverKind.make("codex");
678 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;673 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
679 const lockedContinuationGroupKey = useMemo((): string | null => {674 const lockedContinuationGroupKey = useMemo((): string | null => {
680 if (!lockedProvider || !activeThread) return null;675 if (!lockedProvider || !activeThreadId) return null;
681 const lockedInstanceId =676 const lockedInstanceId =
682 activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;677 activeThreadSessionProviderInstanceId ?? activeThreadModelSelection?.instanceId;
683 if (!lockedInstanceId) return null;678 if (!lockedInstanceId) return null;
684 return (679 return (
685 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)680 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
686 ?.continuationGroupKey ?? null681 ?.continuationGroupKey ?? null
687 );682 );
688 }, [683 }, [
689 activeThread,684 activeThreadId,
685 activeThreadSessionProviderInstanceId,
690 activeThreadModelSelection?.instanceId,686 activeThreadModelSelection?.instanceId,
691 lockedProvider,687 lockedProvider,
692 providerInstanceEntries,688 providerInstanceEntries,
@@ -705,7 +701,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines
705 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {701 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
706 const candidates: Array<string | null | undefined> = [702 const candidates: Array<string | null | undefined> = [
707 composerDraft.activeProvider,703 composerDraft.activeProvider,
708 activeThread?.session?.providerInstanceId,704 activeThreadSessionProviderInstanceId,
709 activeThreadModelSelection?.instanceId,705 activeThreadModelSelection?.instanceId,
710 activeProjectDefaultModelSelection?.instanceId,706 activeProjectDefaultModelSelection?.instanceId,
711 ];707 ];
@@ -747,7 +743,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 35 unmodified lines
747 );743 );
748 }, [744 }, [
749 activeProjectDefaultModelSelection?.instanceId,745 activeProjectDefaultModelSelection?.instanceId,
750 activeThread?.session?.providerInstanceId,746 activeThreadSessionProviderInstanceId,
751 activeThreadModelSelection?.instanceId,747 activeThreadModelSelection?.instanceId,
752 composerDraft.activeProvider,748 composerDraft.activeProvider,
753 explicitSelectedInstanceId,749 explicitSelectedInstanceId,
@@ -841,10 +837,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 87 unmodified lines
841 // ------------------------------------------------------------------837 // ------------------------------------------------------------------
842 // Context window838 // Context window
843 // ------------------------------------------------------------------839 // ------------------------------------------------------------------
844 const activeContextWindow = useMemo(840 const contextWindowSelector = useMemo(
845 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),841 () => createContextWindowSnapshotSelectorByRef(routeThreadRef),
846 [activeThreadActivities],842 [routeThreadRef],
847 );843 );
844 const activeContextWindow = useStore(contextWindowSelector);
848 const activeThreadProviderDisplayName = useMemo(() => {845 const activeThreadProviderDisplayName = useMemo(() => {
849 if (!activeThreadModelSelection) return null;846 if (!activeThreadModelSelection) return null;
850 const entry = providerStatuses.find(847 const entry = providerStatuses.find(
@@ -1969,7 +1966,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1118 unmodified lines
1969 );1966 );
1970 },1967 },
1971 addTerminalContext: (selection: TerminalContextSelection) => {1968 addTerminalContext: (selection: TerminalContextSelection) => {
1972 if (!activeThread) return;1969 if (!activeThreadId) return;
1973 const snapshot = composerEditorRef.current?.readSnapshot() ?? {1970 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
1974 value: promptRef.current,1971 value: promptRef.current,
1975 cursor: composerCursor,1972 cursor: composerCursor,
@@ -1989,7 +1986,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 13 unmodified lines
1989 insertion.prompt,1986 insertion.prompt,
1990 {1987 {
1991 id: randomUUID(),1988 id: randomUUID(),
1992 threadId: activeThread.id,1989 threadId: activeThreadId,
1993 createdAt: new Date().toISOString(),1990 createdAt: new Date().toISOString(),
1994 ...selection,1991 ...selection,
1995 },1992 },
@@ -2019,7 +2016,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines
2019 }),2016 }),
2020 }),2017 }),
2021 [2018 [
2022 activeThread,2019 activeThreadId,
2023 composerDraftTarget,2020 composerDraftTarget,
2024 composerCursor,2021 composerCursor,
2025 composerTerminalContexts,2022 composerTerminalContexts,
apps/web/src/lib/contextWindow.ts+29−0
@@ -95,6 +95,35 @@ export function deriveLatestContextWindowSnapshot( · 94 unmodified lines
95 return null;95 return null;
96}96}
97 97
98export 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
98export function formatContextWindowTokens(value: number | null): string {127export function formatContextWindowTokens(value: number | null): string {
99 if (value === null || !Number.isFinite(value)) {128 if (value === null || !Number.isFinite(value)) {
100 return "0";129 return "0";
apps/web/src/storeSelectors.test.ts+108−0
@@ -0,0 +1,108 @@
1import {
2 EnvironmentId,
3 EventId,
4 type OrchestrationThreadActivity,
5 ThreadId,
6 TurnId,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import { type AppState, type EnvironmentState } from "./store";
11import { createContextWindowSnapshotSelectorByRef } from "./storeSelectors";
12
13const environmentId = EnvironmentId.make("environment-local");
14const threadId = ThreadId.make("thread-1");
15const threadRef = { environmentId, threadId };
16
17function 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
29function 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
63describe("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 @@
1import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";1import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
2import {
3 contextWindowSnapshotsEqual,
4 deriveLatestContextWindowSnapshot,
5 type ContextWindowSnapshot,
6} from "./lib/contextWindow";
2import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";7import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
3import { type Project, type Thread } from "./types";8import { type Project, type Thread } from "./types";
4import { getThreadFromEnvironmentState } from "./threadDerivation";9import { getThreadFromEnvironmentState } from "./threadDerivation";
@@ -45,6 +50,52 @@ export function createThreadSelectorByRef( · 40 unmodified lines
45 return createScopedThreadSelector(() => ref);50 return createScopedThreadSelector(() => ref);
46}51}
47 52
53export 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
48export function createThreadSelectorAcrossEnvironments(99export function createThreadSelectorAcrossEnvironments(
49 threadId: ThreadId | null | undefined,100 threadId: ThreadId | null | undefined,
50): (state: AppState) => Thread | undefined {101): (state: AppState) => Thread | undefined {