authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-08 14:41:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 14:41:03-07:00
log7fbc545ae8c7866ac2b39648120cb2a17250d8b4
tree58cde99d2bf9390466652256d95161a0996df132
parentde545c41737aca08359244b7778ab52e55934f17
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the composer footer still while thread data loads (#10768)

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,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili
55const CLAUDE_PRESENTATION = {55const CLAUDE_PRESENTATION = {
56 displayName: "Claude",56 displayName: "Claude",
57 showInteractionModeToggle: true,57 showInteractionModeToggle: true,
58 reportsContextWindow: true,
58} as const;59} as const;
59function toTitleCaseWords(value: string): string {60function toTitleCaseWords(value: string): string {
60 const parts: Array<string> = [];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,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
62const CODEX_PRESENTATION = {62const CODEX_PRESENTATION = {
63 displayName: "Codex",63 displayName: "Codex",
64 showInteractionModeToggle: true,64 showInteractionModeToggle: true,
65 reportsContextWindow: true,
65} as const;66} as const;
6667
67export interface CodexAppServerProviderSnapshot {68export interface CodexAppServerProviderSnapshot {
apps/server/src/provider/providerSnapshot.ts+4
...@@ -64,6 +64,7 @@ export interface ServerProviderPresentation {...@@ -64,6 +64,7 @@ export interface ServerProviderPresentation {
64 readonly displayName: string;64 readonly displayName: string;
65 readonly badgeLabel?: string;65 readonly badgeLabel?: string;
66 readonly showInteractionModeToggle?: boolean;66 readonly showInteractionModeToggle?: boolean;
67 readonly reportsContextWindow?: boolean;
67 readonly requiresNewThreadForModelChange?: boolean;68 readonly requiresNewThreadForModelChange?: boolean;
68}69}
6970
...@@ -212,6 +213,9 @@ export function buildServerProvider(input: {...@@ -212,6 +213,9 @@ export function buildServerProvider(input: {
212 ...(typeof input.presentation.showInteractionModeToggle === "boolean"213 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
213 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }214 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
214 : {}),215 : {}),
216 ...(typeof input.presentation.reportsContextWindow === "boolean"
217 ? { reportsContextWindow: input.presentation.reportsContextWindow }
218 : {}),
215 ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"219 ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
216 ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }220 ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
217 : {}),221 : {}),
apps/web/src/components/ChatView.logic.test.ts+62
...@@ -47,6 +47,8 @@ import {...@@ -47,6 +47,8 @@ import {
47 isBranchMismatchDismissedForSession,47 isBranchMismatchDismissedForSession,
48 reconcileMountedTerminalThreadIds,48 reconcileMountedTerminalThreadIds,
49 reconcileRetainedMountedThreadIds,49 reconcileRetainedMountedThreadIds,
50 recallCheckoutIsRepo,
51 rememberCheckoutIsRepo,
50 resolveBackgroundDraftWorkspaceOptions,52 resolveBackgroundDraftWorkspaceOptions,
51 resolveComposerInteractionMode,53 resolveComposerInteractionMode,
52 resolveComposerProviderSelection,54 resolveComposerProviderSelection,
...@@ -55,6 +57,7 @@ import {...@@ -55,6 +57,7 @@ import {
55 resolveProactiveTurnDiffAction,57 resolveProactiveTurnDiffAction,
56 resolveThreadMetadataUpdateForNextTurn,58 resolveThreadMetadataUpdateForNextTurn,
57 resolveSendEnvMode,59 resolveSendEnvMode,
60 threadShellHasStarted,
58 resolveDraftHeroState,61 resolveDraftHeroState,
59 scheduleEnvironmentReconnectWarning,62 scheduleEnvironmentReconnectWarning,
60 startNewThreadForProject,63 startNewThreadForProject,
...@@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => {...@@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => {
1958 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);1961 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);
1959 });1962 });
1960});1963});
1964
1965describe("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
1991describe("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,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean
794 return key !== null && sessionDismissedBranchMismatchKeys.has(key);794 return key !== null && sessionDismissedBranchMismatchKeys.has(key);
795}795}
796796
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.
801const sessionCheckoutIsRepo = new Map<string, boolean>();
802
803function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string {
804 return JSON.stringify([environmentId, cwd]);
805}
806
807export function rememberCheckoutIsRepo(
808 environmentId: EnvironmentId,
809 cwd: string,
810 isRepo: boolean,
811): void {
812 sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo);
813}
814
815export 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
797export function threadHasStarted(thread: Thread | null | undefined): boolean {824export function threadHasStarted(thread: Thread | null | undefined): boolean {
798 return Boolean(825 return Boolean(
799 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),826 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
800 );827 );
801}828}
802829
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 */
837export 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// Imported history has no session until its first prompt. Resolve its instance846// Imported history has no session until its first prompt. Resolve its instance
804// through the environment's provider catalog before locking to a driver.847// through the environment's provider catalog before locking to a driver.
805export function deriveLockedProvider(input: {848export function deriveLockedProvider(input: {
apps/web/src/components/ChatView.tsx+15-2
...@@ -403,6 +403,8 @@ import {...@@ -403,6 +403,8 @@ import {
403 readFileAsDataUrl,403 readFileAsDataUrl,
404 resolveFileAttachmentUrl,404 resolveFileAttachmentUrl,
405 reconcileMountedTerminalThreadIds,405 reconcileMountedTerminalThreadIds,
406 recallCheckoutIsRepo,
407 rememberCheckoutIsRepo,
406 resolveBackgroundDraftWorkspaceOptions,408 resolveBackgroundDraftWorkspaceOptions,
407 resolveComposerInteractionMode,409 resolveComposerInteractionMode,
408 resolveComposerProviderSelection,410 resolveComposerProviderSelection,
...@@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) {
3327 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;3329 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
3328 const activeTerminalLaunchContext =3330 const activeTerminalLaunchContext =
3329 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;3331 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
3330 // Default true while loading to avoid toolbar flicker.3332 // Git status arrives after the composer paints. A checkout seen earlier in
3331 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;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 // Keep a hidden, off-flow strip mounted for existing threads so the composer3343 // Keep a hidden, off-flow strip mounted for existing threads so the composer
3333 // can measure whether its relocated controls fit. The visible chrome remains3344 // can measure whether its relocated controls fit. The visible chrome remains
3334 // content-driven: Git/environment context or controls that actually fit.3345 // content-driven: Git/environment context or controls that actually fit.
...@@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) {
8341 activeThreadId={activeThreadId}8352 activeThreadId={activeThreadId}
8342 activeThreadEnvironmentId={activeThread?.environmentId}8353 activeThreadEnvironmentId={activeThread?.environmentId}
8343 activeThread={activeThread}8354 activeThread={activeThread}
8355 activeThreadShell={routeServerThreadShell}
8344 promptHistoryMessages={timelineMessages}8356 promptHistoryMessages={timelineMessages}
8345 isServerThread={isServerThread}8357 isServerThread={isServerThread}
8346 isLocalDraftThread={isLocalDraftThread}8358 isLocalDraftThread={isLocalDraftThread}
...@@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) {
8386 interactionMode={interactionMode}8398 interactionMode={interactionMode}
8387 lockedProvider={lockedProvider}8399 lockedProvider={lockedProvider}
8388 providerStatuses={providerStatuses as ServerProvider[]}8400 providerStatuses={providerStatuses as ServerProvider[]}
8401 providerCatalogKnown={serverConfig !== null}
8389 activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}8402 activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}
8390 activeThreadModelSelection={activeThread?.modelSelection}8403 activeThreadModelSelection={activeThread?.modelSelection}
8391 activeContextWindow={activeContextWindow}8404 activeContextWindow={activeContextWindow}
apps/web/src/components/chat/ChatComposer.tsx+52-9
...@@ -63,6 +63,7 @@ import {...@@ -63,6 +63,7 @@ import {
63 readFileAsDataUrl,63 readFileAsDataUrl,
64 resolveComposerInteractionMode,64 resolveComposerInteractionMode,
65 resolveComposerProviderSelection,65 resolveComposerProviderSelection,
66 threadShellHasStarted,
66} from "../ChatView.logic";67} from "../ChatView.logic";
67import {68import {
68 dataTransferHasComposerMention,69 dataTransferHasComposerMention,
...@@ -198,10 +199,11 @@ import {...@@ -198,10 +199,11 @@ import {
198 renderProviderTraitsMenuContent,199 renderProviderTraitsMenuContent,
199 renderProviderTraitsPicker,200 renderProviderTraitsPicker,
200} from "./composerProviderState";201} from "./composerProviderState";
201import { ContextWindowMeter } from "./ContextWindowMeter";202import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter";
202import {203import {
203 providerSupportsManualCompaction,204 providerSupportsManualCompaction,
204 resolveContextWindowModelDisplayName,205 resolveContextWindowModelDisplayName,
206 shouldReserveContextWindowMeter,
205} from "./ContextWindowMeter.logic";207} from "./ContextWindowMeter.logic";
206import {208import {
207 attachVideoThumbnail,209 attachVideoThumbnail,
...@@ -866,7 +868,13 @@ import {...@@ -866,7 +868,13 @@ import {
866} from "../../providerInstances";868} from "../../providerInstances";
867import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";869import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
868import type { UnifiedSettings } from "@t3tools/contracts/settings";870import type { UnifiedSettings } from "@t3tools/contracts/settings";
869import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types";871import {
872 type ChatMessage,
873 type SessionPhase,
874 type Thread,
875 type ThreadShell,
876 videoMimeType,
877} from "../../types";
870import {878import {
871 buildComposerPromptHistoryEntries,879 buildComposerPromptHistoryEntries,
872 stepComposerPromptHistory,880 stepComposerPromptHistory,
...@@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
1107const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {1115const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
1108 compact: boolean;1116 compact: boolean;
1109 activeContextWindow: ContextWindowSnapshot | null;1117 activeContextWindow: ContextWindowSnapshot | null;
1118 reserveContextWindowMeter: boolean;
1110 activeThreadModelDisplayName: string | null;1119 activeThreadModelDisplayName: string | null;
1111 isPreparingWorktree: boolean;1120 isPreparingWorktree: boolean;
1112 pendingAction: {1121 pendingAction: {
...@@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1143 compactDisabled={props.compactDisabled}1152 compactDisabled={props.compactDisabled}
1144 compactDisabledReason={props.compactDisabledReason}1153 compactDisabledReason={props.compactDisabledReason}
1145 />1154 />
1155 ) : props.reserveContextWindowMeter ? (
1156 <ContextWindowMeterPlaceholder />
1146 ) : null}1157 ) : null}
1147 <ComposerPrimaryActions1158 <ComposerPrimaryActions
1148 compact={props.compact}1159 compact={props.compact}
...@@ -1242,6 +1253,8 @@ export interface ChatComposerProps {...@@ -1242,6 +1253,8 @@ export interface ChatComposerProps {
1242 activeThreadId: ThreadId | null;1253 activeThreadId: ThreadId | null;
1243 activeThreadEnvironmentId: EnvironmentId | undefined;1254 activeThreadEnvironmentId: EnvironmentId | undefined;
1244 activeThread: Thread | undefined;1255 activeThread: Thread | undefined;
1256 /** The routed server thread's shell, present before its detail loads. */
1257 activeThreadShell: ThreadShell | null;
1245 /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */1258 /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */
1246 promptHistoryMessages: ReadonlyArray<ChatMessage>;1259 promptHistoryMessages: ReadonlyArray<ChatMessage>;
1247 isServerThread: boolean;1260 isServerThread: boolean;
...@@ -1298,6 +1311,8 @@ export interface ChatComposerProps {...@@ -1298,6 +1311,8 @@ export interface ChatComposerProps {
1298 // Provider / model1311 // Provider / model
1299 lockedProvider: ProviderDriverKind | null;1312 lockedProvider: ProviderDriverKind | null;
1300 providerStatuses: ServerProvider[];1313 providerStatuses: ServerProvider[];
1314 /** False until the environment's server config has arrived at least once. */
1315 providerCatalogKnown: boolean;
1301 activeProjectDefaultModelSelection: ModelSelection | null | undefined;1316 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
1302 activeThreadModelSelection: ModelSelection | null | undefined;1317 activeThreadModelSelection: ModelSelection | null | undefined;
13031318
...@@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1416 interactionMode: requestedInteractionMode,1431 interactionMode: requestedInteractionMode,
1417 lockedProvider,1432 lockedProvider,
1418 providerStatuses,1433 providerStatuses,
1434 providerCatalogKnown,
1419 activeProjectDefaultModelSelection,1435 activeProjectDefaultModelSelection,
1420 activeThreadModelSelection,1436 activeThreadModelSelection,
1421 activeContextWindow,1437 activeContextWindow,
...@@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1712 const selectedInstanceId =1728 const selectedInstanceId =
1713 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;1729 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
1714 const noProviderAvailable = selectedProviderEntry === undefined;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 const providerSetupInstanceId = noProviderAvailable1737 const providerSetupInstanceId = noProviderAvailable
1716 ? (unavailableProviderInstanceId ??1738 ? (unavailableProviderInstanceId ??
1717 (lockedProvider === null1739 (lockedProvider === null
...@@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1887 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),1909 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
1888 [activeThreadModelSelection, modelOptionsByInstance],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 });
18901920
1891 // ------------------------------------------------------------------1921 // ------------------------------------------------------------------
1892 // Composer-local state1922 // Composer-local state
...@@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3843 isStashMenuOpen ||3873 isStashMenuOpen ||
3844 isDragOverComposer ||3874 isDragOverComposer ||
3845 isPreparingWorktree ||3875 isPreparingWorktree ||
3846 noProviderAvailable ||3876 showProviderUnavailable ||
3847 projectSelectionRequired ||3877 projectSelectionRequired ||
3848 environmentUnavailable !== null ||3878 environmentUnavailable !== null ||
3849 composerSubmissionError !== null ||3879 composerSubmissionError !== null ||
...@@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4088 const hiddenRestingBlockIds = restingBlockDefs4118 const hiddenRestingBlockIds = restingBlockDefs
4089 .slice(restingBlockDefs.length - restingHiddenBlockCount)4119 .slice(restingBlockDefs.length - restingHiddenBlockCount)
4090 .map((def) => def.id);4120 .map((def) => def.id);
4091 const composerControls = noProviderAvailable ? (4121 const composerControls = showProviderUnavailable ? (
4092 <Button4122 <Button
4093 type="button"4123 type="button"
4094 size="sm"4124 size="sm"
...@@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4117 <ProviderModelPicker4147 <ProviderModelPicker
4118 isComposerOwned4148 isComposerOwned
4119 compact={composerControlsCompact}4149 compact={composerControlsCompact}
4120 activeInstanceId={selectedInstanceId}4150 disabled={providerCatalogPending}
4121 model={selectedModelForPickerWithCustomFallback}4151 activeInstanceId={
4152 providerCatalogPending
4153 ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId)
4154 : selectedInstanceId
4155 }
4156 model={
4157 providerCatalogPending
4158 ? (activeThreadModelSelection?.model ?? selectedModelForPickerWithCustomFallback)
4159 : selectedModelForPickerWithCustomFallback
4160 }
4122 lockedProvider={lockedProvider}4161 lockedProvider={lockedProvider}
4123 lockedContinuationGroupKey={lockedContinuationGroupKey}4162 lockedContinuationGroupKey={lockedContinuationGroupKey}
4124 instanceEntries={providerInstanceEntries}4163 instanceEntries={providerInstanceEntries}
...@@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5172 : activePendingProgress.customAnswer ||5211 : activePendingProgress.customAnswer ||
5173 "Type your own answer, or leave this blank to use the selected option"5212 "Type your own answer, or leave this blank to use the selected option"
5174 : prompt.trim() ||5213 : prompt.trim() ||
5175 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}5214 (showProviderUnavailable
5215 ? "Enable a provider in Settings"
5216 : "Ask anything...")}
5176 </button>5217 </button>
5177 {collapsedComposerImagePreviews}5218 {collapsedComposerImagePreviews}
5178 <button5219 <button
...@@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5631 "relative",5672 "relative",
5632 isComposerResting && "flex min-w-0 items-center gap-1",5673 isComposerResting && "flex min-w-0 items-center gap-1",
5633 isComposerResting &&5674 isComposerResting &&
5634 (settings.contextWindowMeterEnabled && activeContextWindow5675 ((settings.contextWindowMeterEnabled && activeContextWindow) ||
5676 reserveContextWindowMeter
5635 ? "pr-28"5677 ? "pr-28"
5636 : showComposerAttachAction5678 : showComposerAttachAction
5637 ? "pr-20"5679 ? "pr-20"
...@@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5685 ? "Add feedback to refine the plan, or leave this blank to implement it"5727 ? "Add feedback to refine the plan, or leave this blank to implement it"
5686 : projectSelectionRequired5728 : projectSelectionRequired
5687 ? "Choose a project above to start a thread"5729 ? "Choose a project above to start a thread"
5688 : noProviderAvailable5730 : showProviderUnavailable
5689 ? "Enable a provider in Settings to send a message"5731 ? "Enable a provider in Settings to send a message"
5690 : phase === "disconnected"5732 : phase === "disconnected"
5691 ? DISCONNECTED_COMPOSER_PLACEHOLDER5733 ? DISCONNECTED_COMPOSER_PLACEHOLDER
...@@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5808 activeContextWindow={5850 activeContextWindow={
5809 settings.contextWindowMeterEnabled ? activeContextWindow : null5851 settings.contextWindowMeterEnabled ? activeContextWindow : null
5810 }5852 }
5853 reserveContextWindowMeter={reserveContextWindowMeter}
5811 activeThreadModelDisplayName={activeThreadModelDisplayName}5854 activeThreadModelDisplayName={activeThreadModelDisplayName}
5812 pendingAction={pendingPrimaryAction}5855 pendingAction={pendingPrimaryAction}
5813 isRunning={phase === "running"}5856 isRunning={phase === "running"}
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+50
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 hasDismissedResumeCompaction,7 hasDismissedResumeCompaction,
8 resolveContextWindowModelDisplayName,8 resolveContextWindowModelDisplayName,
9 shouldOfferResumeCompaction,9 shouldOfferResumeCompaction,
10 shouldReserveContextWindowMeter,
10} from "./ContextWindowMeter.logic";11} from "./ContextWindowMeter.logic";
1112
12function claudeProvider(input: {13function claudeProvider(input: {
...@@ -236,3 +237,52 @@ describe("hasDismissedResumeCompaction", () => {...@@ -236,3 +237,52 @@ describe("hasDismissedResumeCompaction", () => {
236 ).toBe(false);237 ).toBe(false);
237 });238 });
238});239});
240
241describe("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,3 +108,30 @@ export function formatContextWindowCompactionMessage(
108 ? `Context for ${modelDisplayName} compacts automatically when needed.`108 ? `Context for ${modelDisplayName} compacts automatically when needed.`
109 : "Context compacts automatically when needed.";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 */
124export 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,3 +161,8 @@ export function ContextWindowMeter(props: {
161 </Popover>161 </Popover>
162 );162 );
163}163}
164
165/** Holds the meter's footprint while a thread's activities are still loading. */
166export 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,6 +197,9 @@ export const ServerProvider = Schema.Struct({
197 badgeLabel: Schema.optional(TrimmedNonEmptyString),197 badgeLabel: Schema.optional(TrimmedNonEmptyString),
198 continuation: Schema.optional(ServerProviderContinuation),198 continuation: Schema.optional(ServerProviderContinuation),
199 showInteractionModeToggle: Schema.optional(Schema.Boolean),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 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),203 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
201 supportsConversationRollback: Schema.optional(Schema.Boolean),204 supportsConversationRollback: Schema.optional(Schema.Boolean),
202 supportsTextGeneration: Schema.optional(Schema.Boolean),205 supportsTextGeneration: Schema.optional(Schema.Boolean),