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