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
5555const CLAUDE_PRESENTATION = {
5656 displayName: "Claude",
5757 showInteractionModeToggle: true,
58 reportsContextWindow: true,
5859} as const;
5960function toTitleCaseWords(value: string): string {
6061 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;
6262const CODEX_PRESENTATION = {
6363 displayName: "Codex",
6464 showInteractionModeToggle: true,
65 reportsContextWindow: true,
6566} as const;
6667
6768export interface CodexAppServerProviderSnapshot {
apps/server/src/provider/providerSnapshot.ts+4
......@@ -64,6 +64,7 @@ export interface ServerProviderPresentation {
6464 readonly displayName: string;
6565 readonly badgeLabel?: string;
6666 readonly showInteractionModeToggle?: boolean;
67 readonly reportsContextWindow?: boolean;
6768 readonly requiresNewThreadForModelChange?: boolean;
6869}
6970
......@@ -212,6 +213,9 @@ export function buildServerProvider(input: {
212213 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
213214 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
214215 : {}),
216 ...(typeof input.presentation.reportsContextWindow === "boolean"
217 ? { reportsContextWindow: input.presentation.reportsContextWindow }
218 : {}),
215219 ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
216220 ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
217221 : {}),
apps/web/src/components/ChatView.logic.test.ts+62
......@@ -47,6 +47,8 @@ import {
4747 isBranchMismatchDismissedForSession,
4848 reconcileMountedTerminalThreadIds,
4949 reconcileRetainedMountedThreadIds,
50 recallCheckoutIsRepo,
51 rememberCheckoutIsRepo,
5052 resolveBackgroundDraftWorkspaceOptions,
5153 resolveComposerInteractionMode,
5254 resolveComposerProviderSelection,
......@@ -55,6 +57,7 @@ import {
5557 resolveProactiveTurnDiffAction,
5658 resolveThreadMetadataUpdateForNextTurn,
5759 resolveSendEnvMode,
60 threadShellHasStarted,
5861 resolveDraftHeroState,
5962 scheduleEnvironmentReconnectWarning,
6063 startNewThreadForProject,
......@@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => {
19581961 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);
19591962 });
19601963});
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
794794 return key !== null && sessionDismissedBranchMismatchKeys.has(key);
795795}
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
797824export function threadHasStarted(thread: Thread | null | undefined): boolean {
798825 return Boolean(
799826 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
800827 );
801828}
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
803846// Imported history has no session until its first prompt. Resolve its instance
804847// through the environment's provider catalog before locking to a driver.
805848export function deriveLockedProvider(input: {
apps/web/src/components/ChatView.tsx+15-2
......@@ -403,6 +403,8 @@ import {
403403 readFileAsDataUrl,
404404 resolveFileAttachmentUrl,
405405 reconcileMountedTerminalThreadIds,
406 recallCheckoutIsRepo,
407 rememberCheckoutIsRepo,
406408 resolveBackgroundDraftWorkspaceOptions,
407409 resolveComposerInteractionMode,
408410 resolveComposerProviderSelection,
......@@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) {
33273329 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
33283330 const activeTerminalLaunchContext =
33293331 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;
33323343 // Keep a hidden, off-flow strip mounted for existing threads so the composer
33333344 // can measure whether its relocated controls fit. The visible chrome remains
33343345 // content-driven: Git/environment context or controls that actually fit.
......@@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) {
83418352 activeThreadId={activeThreadId}
83428353 activeThreadEnvironmentId={activeThread?.environmentId}
83438354 activeThread={activeThread}
8355 activeThreadShell={routeServerThreadShell}
83448356 promptHistoryMessages={timelineMessages}
83458357 isServerThread={isServerThread}
83468358 isLocalDraftThread={isLocalDraftThread}
......@@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) {
83868398 interactionMode={interactionMode}
83878399 lockedProvider={lockedProvider}
83888400 providerStatuses={providerStatuses as ServerProvider[]}
8401 providerCatalogKnown={serverConfig !== null}
83898402 activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}
83908403 activeThreadModelSelection={activeThread?.modelSelection}
83918404 activeContextWindow={activeContextWindow}
apps/web/src/components/chat/ChatComposer.tsx+52-9
......@@ -63,6 +63,7 @@ import {
6363 readFileAsDataUrl,
6464 resolveComposerInteractionMode,
6565 resolveComposerProviderSelection,
66 threadShellHasStarted,
6667} from "../ChatView.logic";
6768import {
6869 dataTransferHasComposerMention,
......@@ -198,10 +199,11 @@ import {
198199 renderProviderTraitsMenuContent,
199200 renderProviderTraitsPicker,
200201} from "./composerProviderState";
201import { ContextWindowMeter } from "./ContextWindowMeter";
202import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter";
202203import {
203204 providerSupportsManualCompaction,
204205 resolveContextWindowModelDisplayName,
206 shouldReserveContextWindowMeter,
205207} from "./ContextWindowMeter.logic";
206208import {
207209 attachVideoThumbnail,
......@@ -866,7 +868,13 @@ import {
866868} from "../../providerInstances";
867869import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
868870import 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";
870878import {
871879 buildComposerPromptHistoryEntries,
872880 stepComposerPromptHistory,
......@@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
11071115const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
11081116 compact: boolean;
11091117 activeContextWindow: ContextWindowSnapshot | null;
1118 reserveContextWindowMeter: boolean;
11101119 activeThreadModelDisplayName: string | null;
11111120 isPreparingWorktree: boolean;
11121121 pendingAction: {
......@@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
11431152 compactDisabled={props.compactDisabled}
11441153 compactDisabledReason={props.compactDisabledReason}
11451154 />
1155 ) : props.reserveContextWindowMeter ? (
1156 <ContextWindowMeterPlaceholder />
11461157 ) : null}
11471158 <ComposerPrimaryActions
11481159 compact={props.compact}
......@@ -1242,6 +1253,8 @@ export interface ChatComposerProps {
12421253 activeThreadId: ThreadId | null;
12431254 activeThreadEnvironmentId: EnvironmentId | undefined;
12441255 activeThread: Thread | undefined;
1256 /** The routed server thread's shell, present before its detail loads. */
1257 activeThreadShell: ThreadShell | null;
12451258 /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */
12461259 promptHistoryMessages: ReadonlyArray<ChatMessage>;
12471260 isServerThread: boolean;
......@@ -1298,6 +1311,8 @@ export interface ChatComposerProps {
12981311 // Provider / model
12991312 lockedProvider: ProviderDriverKind | null;
13001313 providerStatuses: ServerProvider[];
1314 /** False until the environment's server config has arrived at least once. */
1315 providerCatalogKnown: boolean;
13011316 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
13021317 activeThreadModelSelection: ModelSelection | null | undefined;
13031318
......@@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14161431 interactionMode: requestedInteractionMode,
14171432 lockedProvider,
14181433 providerStatuses,
1434 providerCatalogKnown,
14191435 activeProjectDefaultModelSelection,
14201436 activeThreadModelSelection,
14211437 activeContextWindow,
......@@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17121728 const selectedInstanceId =
17131729 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
17141730 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;
17151737 const providerSetupInstanceId = noProviderAvailable
17161738 ? (unavailableProviderInstanceId ??
17171739 (lockedProvider === null
......@@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18871909 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
18881910 [activeThreadModelSelection, modelOptionsByInstance],
18891911 );
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
18911921 // ------------------------------------------------------------------
18921922 // Composer-local state
......@@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38433873 isStashMenuOpen ||
38443874 isDragOverComposer ||
38453875 isPreparingWorktree ||
3846 noProviderAvailable ||
3876 showProviderUnavailable ||
38473877 projectSelectionRequired ||
38483878 environmentUnavailable !== null ||
38493879 composerSubmissionError !== null ||
......@@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40884118 const hiddenRestingBlockIds = restingBlockDefs
40894119 .slice(restingBlockDefs.length - restingHiddenBlockCount)
40904120 .map((def) => def.id);
4091 const composerControls = noProviderAvailable ? (
4121 const composerControls = showProviderUnavailable ? (
40924122 <Button
40934123 type="button"
40944124 size="sm"
......@@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41174147 <ProviderModelPicker
41184148 isComposerOwned
41194149 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 }
41224161 lockedProvider={lockedProvider}
41234162 lockedContinuationGroupKey={lockedContinuationGroupKey}
41244163 instanceEntries={providerInstanceEntries}
......@@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
51725211 : activePendingProgress.customAnswer ||
51735212 "Type your own answer, or leave this blank to use the selected option"
51745213 : prompt.trim() ||
5175 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
5214 (showProviderUnavailable
5215 ? "Enable a provider in Settings"
5216 : "Ask anything...")}
51765217 </button>
51775218 {collapsedComposerImagePreviews}
51785219 <button
......@@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
56315672 "relative",
56325673 isComposerResting && "flex min-w-0 items-center gap-1",
56335674 isComposerResting &&
5634 (settings.contextWindowMeterEnabled && activeContextWindow
5675 ((settings.contextWindowMeterEnabled && activeContextWindow) ||
5676 reserveContextWindowMeter
56355677 ? "pr-28"
56365678 : showComposerAttachAction
56375679 ? "pr-20"
......@@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
56855727 ? "Add feedback to refine the plan, or leave this blank to implement it"
56865728 : projectSelectionRequired
56875729 ? "Choose a project above to start a thread"
5688 : noProviderAvailable
5730 : showProviderUnavailable
56895731 ? "Enable a provider in Settings to send a message"
56905732 : phase === "disconnected"
56915733 ? DISCONNECTED_COMPOSER_PLACEHOLDER
......@@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
58085850 activeContextWindow={
58095851 settings.contextWindowMeterEnabled ? activeContextWindow : null
58105852 }
5853 reserveContextWindowMeter={reserveContextWindowMeter}
58115854 activeThreadModelDisplayName={activeThreadModelDisplayName}
58125855 pendingAction={pendingPrimaryAction}
58135856 isRunning={phase === "running"}
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+50
......@@ -7,6 +7,7 @@ import {
77 hasDismissedResumeCompaction,
88 resolveContextWindowModelDisplayName,
99 shouldOfferResumeCompaction,
10 shouldReserveContextWindowMeter,
1011} from "./ContextWindowMeter.logic";
1112
1213function claudeProvider(input: {
......@@ -236,3 +237,52 @@ describe("hasDismissedResumeCompaction", () => {
236237 ).toBe(false);
237238 });
238239});
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(
108108 ? `Context for ${modelDisplayName} compacts automatically when needed.`
109109 : "Context compacts automatically when needed.";
110110}
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: {
161161 </Popover>
162162 );
163163}
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({
197197 badgeLabel: Schema.optional(TrimmedNonEmptyString),
198198 continuation: Schema.optional(ServerProviderContinuation),
199199 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),
200203 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
201204 supportsConversationRollback: Schema.optional(Schema.Boolean),
202205 supportsTextGeneration: Schema.optional(Schema.Boolean),