| author | |
| committer | |
| log | 7fbc545ae8c7866ac2b39648120cb2a17250d8b4 |
| tree | 58cde99d2bf9390466652256d95161a0996df132 |
| parent | de545c41737aca08359244b7778ab52e55934f17 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>11 files changed, 263 insertions(+), 11 deletions(-)
apps/server/src/provider/Layers/ClaudeProvider.ts+1| ... | ... | @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili |
| 55 | 55 | const CLAUDE_PRESENTATION = { |
| 56 | 56 | displayName: "Claude", |
| 57 | 57 | showInteractionModeToggle: true, |
| 58 | reportsContextWindow: true, | |
| 58 | 59 | } as const; |
| 59 | 60 | function toTitleCaseWords(value: string): string { |
| 60 | 61 | const parts: Array<string> = []; |
apps/server/src/provider/Layers/CodexProvider.ts+1| ... | ... | @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; |
| 62 | 62 | const CODEX_PRESENTATION = { |
| 63 | 63 | displayName: "Codex", |
| 64 | 64 | showInteractionModeToggle: true, |
| 65 | reportsContextWindow: true, | |
| 65 | 66 | } as const; |
| 66 | 67 | |
| 67 | 68 | export interface CodexAppServerProviderSnapshot { |
apps/server/src/provider/providerSnapshot.ts+4| ... | ... | @@ -64,6 +64,7 @@ export interface ServerProviderPresentation { |
| 64 | 64 | readonly displayName: string; |
| 65 | 65 | readonly badgeLabel?: string; |
| 66 | 66 | readonly showInteractionModeToggle?: boolean; |
| 67 | readonly reportsContextWindow?: boolean; | |
| 67 | 68 | readonly requiresNewThreadForModelChange?: boolean; |
| 68 | 69 | } |
| 69 | 70 | |
| ... | ... | @@ -212,6 +213,9 @@ export function buildServerProvider(input: { |
| 212 | 213 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" |
| 213 | 214 | ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } |
| 214 | 215 | : {}), |
| 216 | ...(typeof input.presentation.reportsContextWindow === "boolean" | |
| 217 | ? { reportsContextWindow: input.presentation.reportsContextWindow } | |
| 218 | : {}), | |
| 215 | 219 | ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" |
| 216 | 220 | ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } |
| 217 | 221 | : {}), |
apps/web/src/components/ChatView.logic.test.ts+62| ... | ... | @@ -47,6 +47,8 @@ import { |
| 47 | 47 | isBranchMismatchDismissedForSession, |
| 48 | 48 | reconcileMountedTerminalThreadIds, |
| 49 | 49 | reconcileRetainedMountedThreadIds, |
| 50 | recallCheckoutIsRepo, | |
| 51 | rememberCheckoutIsRepo, | |
| 50 | 52 | resolveBackgroundDraftWorkspaceOptions, |
| 51 | 53 | resolveComposerInteractionMode, |
| 52 | 54 | resolveComposerProviderSelection, |
| ... | ... | @@ -55,6 +57,7 @@ import { |
| 55 | 57 | resolveProactiveTurnDiffAction, |
| 56 | 58 | resolveThreadMetadataUpdateForNextTurn, |
| 57 | 59 | resolveSendEnvMode, |
| 60 | threadShellHasStarted, | |
| 58 | 61 | resolveDraftHeroState, |
| 59 | 62 | scheduleEnvironmentReconnectWarning, |
| 60 | 63 | startNewThreadForProject, |
| ... | ... | @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => { |
| 1958 | 1961 | expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); |
| 1959 | 1962 | }); |
| 1960 | 1963 | }); |
| 1964 | ||
| 1965 | describe("checkout Git memory", () => { | |
| 1966 | it("answers from the last status seen for the same checkout", () => { | |
| 1967 | rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); | |
| 1968 | expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); | |
| 1969 | rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); | |
| 1970 | expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); | |
| 1971 | }); | |
| 1972 | ||
| 1973 | it("does not answer for a checkout it has not seen", () => { | |
| 1974 | expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); | |
| 1975 | expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); | |
| 1976 | }); | |
| 1977 | ||
| 1978 | it("keeps environments apart", () => { | |
| 1979 | rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); | |
| 1980 | expect( | |
| 1981 | recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), | |
| 1982 | ).toBeUndefined(); | |
| 1983 | }); | |
| 1984 | ||
| 1985 | it("does not confuse an environment id containing the separator with a path", () => { | |
| 1986 | rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); | |
| 1987 | expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); | |
| 1988 | }); | |
| 1989 | }); | |
| 1990 | ||
| 1991 | describe("threadShellHasStarted", () => { | |
| 1992 | it("counts a thread that has a user message but no latest turn", () => { | |
| 1993 | expect( | |
| 1994 | threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }), | |
| 1995 | ).toBe(true); | |
| 1996 | }); | |
| 1997 | ||
| 1998 | it("counts a thread with a live session and nothing else", () => { | |
| 1999 | expect( | |
| 2000 | threadShellHasStarted({ | |
| 2001 | latestTurn: null, | |
| 2002 | latestUserMessageAt: null, | |
| 2003 | session: { | |
| 2004 | threadId, | |
| 2005 | status: "starting", | |
| 2006 | providerName: "codex", | |
| 2007 | runtimeMode: "full-access", | |
| 2008 | activeTurnId: null, | |
| 2009 | lastError: null, | |
| 2010 | updatedAt: now, | |
| 2011 | }, | |
| 2012 | }), | |
| 2013 | ).toBe(true); | |
| 2014 | }); | |
| 2015 | ||
| 2016 | it("does not count a thread that never sent anything", () => { | |
| 2017 | expect( | |
| 2018 | threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }), | |
| 2019 | ).toBe(false); | |
| 2020 | expect(threadShellHasStarted(null)).toBe(false); | |
| 2021 | }); | |
| 2022 | }); |
apps/web/src/components/ChatView.logic.ts+43| ... | ... | @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean |
| 794 | 794 | return key !== null && sessionDismissedBranchMismatchKeys.has(key); |
| 795 | 795 | } |
| 796 | 796 | |
| 797 | // Git status for a checkout arrives after the composer paints, and the branch | |
| 798 | // strip mounts on the assumption that a project is a Git repo. Without a | |
| 799 | // memory, a non-Git project would mount the strip and drop it on every visit. | |
| 800 | // Keyed by environment and checkout for the session; never persisted. | |
| 801 | const sessionCheckoutIsRepo = new Map<string, boolean>(); | |
| 802 | ||
| 803 | function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { | |
| 804 | return JSON.stringify([environmentId, cwd]); | |
| 805 | } | |
| 806 | ||
| 807 | export function rememberCheckoutIsRepo( | |
| 808 | environmentId: EnvironmentId, | |
| 809 | cwd: string, | |
| 810 | isRepo: boolean, | |
| 811 | ): void { | |
| 812 | sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); | |
| 813 | } | |
| 814 | ||
| 815 | export function recallCheckoutIsRepo( | |
| 816 | environmentId: EnvironmentId, | |
| 817 | cwd: string | null, | |
| 818 | ): boolean | undefined { | |
| 819 | return cwd === null | |
| 820 | ? undefined | |
| 821 | : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); | |
| 822 | } | |
| 823 | ||
| 797 | 824 | export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 798 | 825 | return Boolean( |
| 799 | 826 | thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), |
| 800 | 827 | ); |
| 801 | 828 | } |
| 802 | 829 | |
| 830 | /** | |
| 831 | * Whether a thread ran at least one turn, judged from its shell alone. | |
| 832 | * | |
| 833 | * `threadHasStarted` needs the detail: a thread whose latest turn was cleared | |
| 834 | * still has messages, and the loading shell carries none. The shell records | |
| 835 | * when the last user message landed, which every started thread has. | |
| 836 | */ | |
| 837 | export function threadShellHasStarted( | |
| 838 | shell: Pick<ThreadShell, "latestTurn" | "latestUserMessageAt" | "session"> | null | undefined, | |
| 839 | ): boolean { | |
| 840 | return Boolean( | |
| 841 | shell && | |
| 842 | (shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null), | |
| 843 | ); | |
| 844 | } | |
| 845 | ||
| 803 | 846 | // Imported history has no session until its first prompt. Resolve its instance |
| 804 | 847 | // through the environment's provider catalog before locking to a driver. |
| 805 | 848 | export function deriveLockedProvider(input: { |
apps/web/src/components/ChatView.tsx+15-2| ... | ... | @@ -403,6 +403,8 @@ import { |
| 403 | 403 | readFileAsDataUrl, |
| 404 | 404 | resolveFileAttachmentUrl, |
| 405 | 405 | reconcileMountedTerminalThreadIds, |
| 406 | recallCheckoutIsRepo, | |
| 407 | rememberCheckoutIsRepo, | |
| 406 | 408 | resolveBackgroundDraftWorkspaceOptions, |
| 407 | 409 | resolveComposerInteractionMode, |
| 408 | 410 | resolveComposerProviderSelection, |
| ... | ... | @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) { |
| 3327 | 3329 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| 3328 | 3330 | const activeTerminalLaunchContext = |
| 3329 | 3331 | terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; |
| 3330 | // Default true while loading to avoid toolbar flicker. | |
| 3331 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | |
| 3332 | // Git status arrives after the composer paints. A checkout seen earlier in | |
| 3333 | // this session answers from memory, so a non-Git project does not mount the | |
| 3334 | // branch strip and then drop it. A never-seen checkout assumes Git, which | |
| 3335 | // is what nearly every project is. | |
| 3336 | const liveIsGitRepo = gitStatusQuery.data?.isRepo; | |
| 3337 | useEffect(() => { | |
| 3338 | if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { | |
| 3339 | rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); | |
| 3340 | } | |
| 3341 | }, [environmentId, gitStatusCwd, liveIsGitRepo]); | |
| 3342 | const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; | |
| 3332 | 3343 | // Keep a hidden, off-flow strip mounted for existing threads so the composer |
| 3333 | 3344 | // can measure whether its relocated controls fit. The visible chrome remains |
| 3334 | 3345 | // content-driven: Git/environment context or controls that actually fit. |
| ... | ... | @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8341 | 8352 | activeThreadId={activeThreadId} |
| 8342 | 8353 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 8343 | 8354 | activeThread={activeThread} |
| 8355 | activeThreadShell={routeServerThreadShell} | |
| 8344 | 8356 | promptHistoryMessages={timelineMessages} |
| 8345 | 8357 | isServerThread={isServerThread} |
| 8346 | 8358 | isLocalDraftThread={isLocalDraftThread} |
| ... | ... | @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8386 | 8398 | interactionMode={interactionMode} |
| 8387 | 8399 | lockedProvider={lockedProvider} |
| 8388 | 8400 | providerStatuses={providerStatuses as ServerProvider[]} |
| 8401 | providerCatalogKnown={serverConfig !== null} | |
| 8389 | 8402 | activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} |
| 8390 | 8403 | activeThreadModelSelection={activeThread?.modelSelection} |
| 8391 | 8404 | activeContextWindow={activeContextWindow} |
apps/web/src/components/chat/ChatComposer.tsx+52-9| ... | ... | @@ -63,6 +63,7 @@ import { |
| 63 | 63 | readFileAsDataUrl, |
| 64 | 64 | resolveComposerInteractionMode, |
| 65 | 65 | resolveComposerProviderSelection, |
| 66 | threadShellHasStarted, | |
| 66 | 67 | } from "../ChatView.logic"; |
| 67 | 68 | import { |
| 68 | 69 | dataTransferHasComposerMention, |
| ... | ... | @@ -198,10 +199,11 @@ import { |
| 198 | 199 | renderProviderTraitsMenuContent, |
| 199 | 200 | renderProviderTraitsPicker, |
| 200 | 201 | } from "./composerProviderState"; |
| 201 | import { ContextWindowMeter } from "./ContextWindowMeter"; | |
| 202 | import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; | |
| 202 | 203 | import { |
| 203 | 204 | providerSupportsManualCompaction, |
| 204 | 205 | resolveContextWindowModelDisplayName, |
| 206 | shouldReserveContextWindowMeter, | |
| 205 | 207 | } from "./ContextWindowMeter.logic"; |
| 206 | 208 | import { |
| 207 | 209 | attachVideoThumbnail, |
| ... | ... | @@ -866,7 +868,13 @@ import { |
| 866 | 868 | } from "../../providerInstances"; |
| 867 | 869 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; |
| 868 | 870 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 869 | import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; | |
| 871 | import { | |
| 872 | type ChatMessage, | |
| 873 | type SessionPhase, | |
| 874 | type Thread, | |
| 875 | type ThreadShell, | |
| 876 | videoMimeType, | |
| 877 | } from "../../types"; | |
| 870 | 878 | import { |
| 871 | 879 | buildComposerPromptHistoryEntries, |
| 872 | 880 | stepComposerPromptHistory, |
| ... | ... | @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1107 | 1115 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { |
| 1108 | 1116 | compact: boolean; |
| 1109 | 1117 | activeContextWindow: ContextWindowSnapshot | null; |
| 1118 | reserveContextWindowMeter: boolean; | |
| 1110 | 1119 | activeThreadModelDisplayName: string | null; |
| 1111 | 1120 | isPreparingWorktree: boolean; |
| 1112 | 1121 | pendingAction: { |
| ... | ... | @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 1143 | 1152 | compactDisabled={props.compactDisabled} |
| 1144 | 1153 | compactDisabledReason={props.compactDisabledReason} |
| 1145 | 1154 | /> |
| 1155 | ) : props.reserveContextWindowMeter ? ( | |
| 1156 | <ContextWindowMeterPlaceholder /> | |
| 1146 | 1157 | ) : null} |
| 1147 | 1158 | <ComposerPrimaryActions |
| 1148 | 1159 | compact={props.compact} |
| ... | ... | @@ -1242,6 +1253,8 @@ export interface ChatComposerProps { |
| 1242 | 1253 | activeThreadId: ThreadId | null; |
| 1243 | 1254 | activeThreadEnvironmentId: EnvironmentId | undefined; |
| 1244 | 1255 | activeThread: Thread | undefined; |
| 1256 | /** The routed server thread's shell, present before its detail loads. */ | |
| 1257 | activeThreadShell: ThreadShell | null; | |
| 1245 | 1258 | /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ |
| 1246 | 1259 | promptHistoryMessages: ReadonlyArray<ChatMessage>; |
| 1247 | 1260 | isServerThread: boolean; |
| ... | ... | @@ -1298,6 +1311,8 @@ export interface ChatComposerProps { |
| 1298 | 1311 | // Provider / model |
| 1299 | 1312 | lockedProvider: ProviderDriverKind | null; |
| 1300 | 1313 | providerStatuses: ServerProvider[]; |
| 1314 | /** False until the environment's server config has arrived at least once. */ | |
| 1315 | providerCatalogKnown: boolean; | |
| 1301 | 1316 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; |
| 1302 | 1317 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 1303 | 1318 | |
| ... | ... | @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1416 | 1431 | interactionMode: requestedInteractionMode, |
| 1417 | 1432 | lockedProvider, |
| 1418 | 1433 | providerStatuses, |
| 1434 | providerCatalogKnown, | |
| 1419 | 1435 | activeProjectDefaultModelSelection, |
| 1420 | 1436 | activeThreadModelSelection, |
| 1421 | 1437 | activeContextWindow, |
| ... | ... | @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1712 | 1728 | const selectedInstanceId = |
| 1713 | 1729 | selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; |
| 1714 | 1730 | const noProviderAvailable = selectedProviderEntry === undefined; |
| 1731 | // Before the catalog arrives, every thread resolves to "no provider". Send | |
| 1732 | // stays blocked either way; only the chrome waits, keeping the picker with | |
| 1733 | // the thread's own selection instead of swapping in the setup button and | |
| 1734 | // back once the catalog lands. | |
| 1735 | const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; | |
| 1736 | const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; | |
| 1715 | 1737 | const providerSetupInstanceId = noProviderAvailable |
| 1716 | 1738 | ? (unavailableProviderInstanceId ?? |
| 1717 | 1739 | (lockedProvider === null |
| ... | ... | @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1887 | 1909 | () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), |
| 1888 | 1910 | [activeThreadModelSelection, modelOptionsByInstance], |
| 1889 | 1911 | ); |
| 1912 | const reserveContextWindowMeter = shouldReserveContextWindowMeter({ | |
| 1913 | meterEnabled: settings.contextWindowMeterEnabled, | |
| 1914 | detailLoading: props.threadSyncPhase === "loading", | |
| 1915 | threadStarted: threadShellHasStarted(props.activeThreadShell), | |
| 1916 | providerReportsContextWindow: selectedProviderStatus | |
| 1917 | ? selectedProviderStatus.reportsContextWindow === true | |
| 1918 | : null, | |
| 1919 | }); | |
| 1890 | 1920 | |
| 1891 | 1921 | // ------------------------------------------------------------------ |
| 1892 | 1922 | // Composer-local state |
| ... | ... | @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3843 | 3873 | isStashMenuOpen || |
| 3844 | 3874 | isDragOverComposer || |
| 3845 | 3875 | isPreparingWorktree || |
| 3846 | noProviderAvailable || | |
| 3876 | showProviderUnavailable || | |
| 3847 | 3877 | projectSelectionRequired || |
| 3848 | 3878 | environmentUnavailable !== null || |
| 3849 | 3879 | composerSubmissionError !== null || |
| ... | ... | @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4088 | 4118 | const hiddenRestingBlockIds = restingBlockDefs |
| 4089 | 4119 | .slice(restingBlockDefs.length - restingHiddenBlockCount) |
| 4090 | 4120 | .map((def) => def.id); |
| 4091 | const composerControls = noProviderAvailable ? ( | |
| 4121 | const composerControls = showProviderUnavailable ? ( | |
| 4092 | 4122 | <Button |
| 4093 | 4123 | type="button" |
| 4094 | 4124 | size="sm" |
| ... | ... | @@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4117 | 4147 | <ProviderModelPicker |
| 4118 | 4148 | isComposerOwned |
| 4119 | 4149 | compact={composerControlsCompact} |
| 4120 | activeInstanceId={selectedInstanceId} | |
| 4121 | model={selectedModelForPickerWithCustomFallback} | |
| 4150 | disabled={providerCatalogPending} | |
| 4151 | activeInstanceId={ | |
| 4152 | providerCatalogPending | |
| 4153 | ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) | |
| 4154 | : selectedInstanceId | |
| 4155 | } | |
| 4156 | model={ | |
| 4157 | providerCatalogPending | |
| 4158 | ? (activeThreadModelSelection?.model ?? selectedModelForPickerWithCustomFallback) | |
| 4159 | : selectedModelForPickerWithCustomFallback | |
| 4160 | } | |
| 4122 | 4161 | lockedProvider={lockedProvider} |
| 4123 | 4162 | lockedContinuationGroupKey={lockedContinuationGroupKey} |
| 4124 | 4163 | instanceEntries={providerInstanceEntries} |
| ... | ... | @@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5172 | 5211 | : activePendingProgress.customAnswer || |
| 5173 | 5212 | "Type your own answer, or leave this blank to use the selected option" |
| 5174 | 5213 | : prompt.trim() || |
| 5175 | (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} | |
| 5214 | (showProviderUnavailable | |
| 5215 | ? "Enable a provider in Settings" | |
| 5216 | : "Ask anything...")} | |
| 5176 | 5217 | </button> |
| 5177 | 5218 | {collapsedComposerImagePreviews} |
| 5178 | 5219 | <button |
| ... | ... | @@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5631 | 5672 | "relative", |
| 5632 | 5673 | isComposerResting && "flex min-w-0 items-center gap-1", |
| 5633 | 5674 | isComposerResting && |
| 5634 | (settings.contextWindowMeterEnabled && activeContextWindow | |
| 5675 | ((settings.contextWindowMeterEnabled && activeContextWindow) || | |
| 5676 | reserveContextWindowMeter | |
| 5635 | 5677 | ? "pr-28" |
| 5636 | 5678 | : showComposerAttachAction |
| 5637 | 5679 | ? "pr-20" |
| ... | ... | @@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5685 | 5727 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 5686 | 5728 | : projectSelectionRequired |
| 5687 | 5729 | ? "Choose a project above to start a thread" |
| 5688 | : noProviderAvailable | |
| 5730 | : showProviderUnavailable | |
| 5689 | 5731 | ? "Enable a provider in Settings to send a message" |
| 5690 | 5732 | : phase === "disconnected" |
| 5691 | 5733 | ? DISCONNECTED_COMPOSER_PLACEHOLDER |
| ... | ... | @@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5808 | 5850 | activeContextWindow={ |
| 5809 | 5851 | settings.contextWindowMeterEnabled ? activeContextWindow : null |
| 5810 | 5852 | } |
| 5853 | reserveContextWindowMeter={reserveContextWindowMeter} | |
| 5811 | 5854 | activeThreadModelDisplayName={activeThreadModelDisplayName} |
| 5812 | 5855 | pendingAction={pendingPrimaryAction} |
| 5813 | 5856 | isRunning={phase === "running"} |
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+50| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | hasDismissedResumeCompaction, |
| 8 | 8 | resolveContextWindowModelDisplayName, |
| 9 | 9 | shouldOfferResumeCompaction, |
| 10 | shouldReserveContextWindowMeter, | |
| 10 | 11 | } from "./ContextWindowMeter.logic"; |
| 11 | 12 | |
| 12 | 13 | function claudeProvider(input: { |
| ... | ... | @@ -236,3 +237,52 @@ describe("hasDismissedResumeCompaction", () => { |
| 236 | 237 | ).toBe(false); |
| 237 | 238 | }); |
| 238 | 239 | }); |
| 240 | ||
| 241 | describe("shouldReserveContextWindowMeter", () => { | |
| 242 | const loadingStartedThread = { | |
| 243 | meterEnabled: true, | |
| 244 | detailLoading: true, | |
| 245 | threadStarted: true, | |
| 246 | providerReportsContextWindow: true, | |
| 247 | }; | |
| 248 | ||
| 249 | it("holds the meter's slot while a started thread's detail loads", () => { | |
| 250 | expect(shouldReserveContextWindowMeter(loadingStartedThread)).toBe(true); | |
| 251 | }); | |
| 252 | ||
| 253 | it("reserves nothing once the detail is in", () => { | |
| 254 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, detailLoading: false })).toBe( | |
| 255 | false, | |
| 256 | ); | |
| 257 | }); | |
| 258 | ||
| 259 | it("reserves nothing for a thread that never ran a turn", () => { | |
| 260 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, threadStarted: false })).toBe( | |
| 261 | false, | |
| 262 | ); | |
| 263 | }); | |
| 264 | ||
| 265 | it("reserves while the thread's provider is not in the catalog yet", () => { | |
| 266 | expect( | |
| 267 | shouldReserveContextWindowMeter({ | |
| 268 | ...loadingStartedThread, | |
| 269 | providerReportsContextWindow: null, | |
| 270 | }), | |
| 271 | ).toBe(true); | |
| 272 | }); | |
| 273 | ||
| 274 | it("reserves nothing for a provider that does not stream usage", () => { | |
| 275 | expect( | |
| 276 | shouldReserveContextWindowMeter({ | |
| 277 | ...loadingStartedThread, | |
| 278 | providerReportsContextWindow: false, | |
| 279 | }), | |
| 280 | ).toBe(false); | |
| 281 | }); | |
| 282 | ||
| 283 | it("reserves nothing while the meter is switched off", () => { | |
| 284 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, meterEnabled: false })).toBe( | |
| 285 | false, | |
| 286 | ); | |
| 287 | }); | |
| 288 | }); |
apps/web/src/components/chat/ContextWindowMeter.logic.ts+27| ... | ... | @@ -108,3 +108,30 @@ export function formatContextWindowCompactionMessage( |
| 108 | 108 | ? `Context for ${modelDisplayName} compacts automatically when needed.` |
| 109 | 109 | : "Context compacts automatically when needed."; |
| 110 | 110 | } |
| 111 | ||
| 112 | /** | |
| 113 | * Whether the footer should hold the meter's slot before a snapshot exists. | |
| 114 | * | |
| 115 | * The snapshot comes from thread activities, which load after the shell. | |
| 116 | * Reserving the slot while the detail loads, for a started thread, keeps the | |
| 117 | * attach button still until the meter mounts. Once the detail is in, a | |
| 118 | * missing snapshot means there is no usage to show and nothing is reserved. | |
| 119 | * | |
| 120 | * The meter renders from stored activities whatever the provider's state, so | |
| 121 | * only a provider known not to stream usage skips the reservation. An unknown | |
| 122 | * provider (catalog still loading, or the thread's provider disabled) reserves. | |
| 123 | */ | |
| 124 | export function shouldReserveContextWindowMeter(input: { | |
| 125 | readonly meterEnabled: boolean; | |
| 126 | readonly detailLoading: boolean; | |
| 127 | readonly threadStarted: boolean; | |
| 128 | /** `null` while the thread's provider is not in the catalog. */ | |
| 129 | readonly providerReportsContextWindow: boolean | null; | |
| 130 | }): boolean { | |
| 131 | return ( | |
| 132 | input.meterEnabled && | |
| 133 | input.detailLoading && | |
| 134 | input.threadStarted && | |
| 135 | input.providerReportsContextWindow !== false | |
| 136 | ); | |
| 137 | } |
apps/web/src/components/chat/ContextWindowMeter.tsx+5| ... | ... | @@ -161,3 +161,8 @@ export function ContextWindowMeter(props: { |
| 161 | 161 | </Popover> |
| 162 | 162 | ); |
| 163 | 163 | } |
| 164 | ||
| 165 | /** Holds the meter's footprint while a thread's activities are still loading. */ | |
| 166 | export function ContextWindowMeterPlaceholder() { | |
| 167 | return <span aria-hidden="true" className="size-7 shrink-0" />; | |
| 168 | } |
packages/contracts/src/server.ts+3| ... | ... | @@ -197,6 +197,9 @@ export const ServerProvider = Schema.Struct({ |
| 197 | 197 | badgeLabel: Schema.optional(TrimmedNonEmptyString), |
| 198 | 198 | continuation: Schema.optional(ServerProviderContinuation), |
| 199 | 199 | showInteractionModeToggle: Schema.optional(Schema.Boolean), |
| 200 | // The driver streams context window usage, so a started thread will have a | |
| 201 | // meter once its activities load. Clients reserve the meter's space on it. | |
| 202 | reportsContextWindow: Schema.optional(Schema.Boolean), | |
| 200 | 203 | requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), |
| 201 | 204 | supportsConversationRollback: Schema.optional(Schema.Boolean), |
| 202 | 205 | supportsTextGeneration: Schema.optional(Schema.Boolean), |