diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index ad315d5bb16d..804d45af7fb5 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5203,6 +5203,7 @@ export const en: TranslationMap = { fastHelp: "Fast responses finish sooner and can use more of your usage limits.", speedUnsupported: "Speed control is not supported for this model.", contextWindow: "{count} context", + contextActiveAndMax: "{active} active · {maximum} max", chatOnly: "Chat only", chatOnlyHelp: "This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.", diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index d5ac29c39552..ccffd7862d09 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -5518,7 +5518,45 @@ describe("chat model controls", () => { expect(container.querySelector('[data-chat-model-provider-group="moonshotai"]')).toBeNull(); }); - it("shows model context size inline without a redundant tooltip", () => { + it("keeps active context in picker details without crowding the compact trigger", () => { + const { state } = createChatHeaderState({ + model: "gpt-5.6-sol", + modelProvider: "openai", + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai", + contextWindow: 1_050_000, + agentRuntime: { id: "openclaw", source: "model" }, + }, + ], + }); + state.sessionsResult = createSessionsResultFromRows([ + { + key: "main", + kind: "direct", + updatedAt: 1, + model: "gpt-5.6-sol", + modelProvider: "openai", + agentRuntime: { id: "openclaw", source: "model" }, + contextTokens: 1_000_000, + }, + ]); + const container = renderModelControls(state); + const modelOption = container.querySelector( + '[data-chat-model-option="openai/gpt-5.6-sol"]', + ); + + expect(modelOption?.querySelector(".chat-controls__model-option-meta")?.textContent).toBe( + "1M active · 1M max · OpenClaw", + ); + expect(modelOption?.textContent).not.toContain("700k"); + expect(getChatModelSelect(container).querySelector(".chat-controls__trigger-meta")).toBeNull(); + expect(modelOption?.closest("openclaw-tooltip")).toBeNull(); + }); + + it("uses the default selection runtime for an implicit Codex model", () => { const { state } = createChatHeaderState({ model: "gpt-5.6-sol", modelProvider: "openai", @@ -5531,18 +5569,146 @@ describe("chat model controls", () => { }, ], }); + state.sessionsResult = createSessionsResultFromRows([ + { + key: "main", + kind: "direct", + updatedAt: 1, + model: "gpt-5.6-sol", + modelProvider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, + contextTokens: 1_000_000, + }, + ]); + state.sessionsResult.defaults = { + modelProvider: "openai", + model: "gpt-5.6-sol", + contextTokens: 1_000_000, + agentRuntime: { id: "codex", source: "implicit" }, + }; + + const container = renderModelControls(state); + const modelOption = container.querySelector( + '[data-chat-model-option="openai/gpt-5.6-sol"]', + ); + + expect(modelOption?.querySelector(".chat-controls__model-option-meta")?.textContent).toBe( + "1M active · 1M max", + ); + expect(modelOption?.textContent).not.toContain("700k"); + }); + + it("rejects stale active context after an implicit default runtime change", () => { + const { state } = createChatHeaderState({ + model: "gpt-5.6-sol", + modelProvider: "openai", + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai", + contextWindow: 1_050_000, + }, + ], + }); + state.sessionsResult = createSessionsResultFromRows([ + { + key: "main", + kind: "direct", + updatedAt: 1, + model: "gpt-5.6-sol", + modelProvider: "openai", + agentRuntime: { id: "openclaw", source: "session" }, + contextTokens: 272_000, + }, + ]); + state.sessionsResult.defaults = { + modelProvider: "openai", + model: "gpt-5.6-sol", + contextTokens: 1_000_000, + agentRuntime: { id: "codex", source: "implicit" }, + }; + const container = renderModelControls(state); const modelOption = container.querySelector( '[data-chat-model-option="openai/gpt-5.6-sol"]', ); expect(modelOption?.querySelector(".chat-controls__model-option-meta")?.textContent).toBe("1M"); - expect( - getChatModelSelect(container).querySelector(".chat-controls__trigger-meta")?.textContent, - ).toBe("1M"); - expect(modelOption?.closest("openclaw-tooltip")).toBeNull(); + expect(modelOption?.textContent).not.toContain("272k active"); }); + it.each([ + { + name: "a pending same-model switch", + modelSwitching: true, + sessionRuntimeId: "codex", + optionRuntimeId: "codex", + }, + { + name: "a different session runtime", + modelSwitching: false, + sessionRuntimeId: "openclaw", + optionRuntimeId: "codex", + }, + { + name: "missing session runtime provenance", + modelSwitching: false, + sessionRuntimeId: undefined, + optionRuntimeId: "codex", + }, + { + name: "missing catalog runtime provenance", + modelSwitching: false, + sessionRuntimeId: "codex", + optionRuntimeId: undefined, + }, + ])( + "does not pair a stale session budget with $name", + ({ modelSwitching, optionRuntimeId, sessionRuntimeId }) => { + const { state } = createChatHeaderState({ + model: "gpt-5.6-sol", + modelProvider: "openai", + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai", + contextWindow: 1_050_000, + ...(optionRuntimeId + ? { agentRuntime: { id: optionRuntimeId, source: "model" as const } } + : {}), + }, + ], + }); + state.sessionsResult = createSessionsResultFromRows([ + { + key: "main", + kind: "direct", + updatedAt: 1, + model: "gpt-5.6-sol", + modelProvider: "openai", + ...(sessionRuntimeId + ? { agentRuntime: { id: sessionRuntimeId, source: "session" as const } } + : {}), + contextTokens: 272_000, + }, + ]); + + const container = renderModelControls(state, { + modelOverrides: { main: "openai/gpt-5.6-sol" }, + modelSwitching, + }); + const selectedModelOption = container.querySelector( + '[data-chat-model-option="openai/gpt-5.6-sol"]', + ); + + expect( + selectedModelOption?.querySelector(".chat-controls__model-option-meta")?.textContent, + ).toBe(optionRuntimeId ? "1M · Codex" : "1M"); + }, + ); + it("synthesizes a selectable row for a persisted override missing from the catalog", () => { const { state } = createChatHeaderState({ model: "gpt-5.2-retired", @@ -5785,18 +5951,21 @@ describe("chat model controls", () => { }); }); - it("uses the session provider for slash-containing raw model ids without metadata", () => { + it("uses the session provider for slash-containing raw model ids", () => { const { state } = createChatHeaderState(); state.chatModelCatalog = [ { id: "google/gemma-4-26b-a4b-it", name: "Gemma 4", provider: "google", + agentRuntime: { id: "openclaw", source: "implicit" }, }, { id: "google/gemma-4-26b-a4b-it", name: "Gemma 4", provider: "openrouter", + contextWindow: 1_000_000, + agentRuntime: { id: "openclaw", source: "implicit" }, }, ]; state.sessionsResult = createSessionsListResult({ @@ -5805,6 +5974,11 @@ describe("chat model controls", () => { defaultsModel: "google/gemma-4-26b-a4b-it", defaultsProvider: "openrouter", }); + state.sessionsResult.sessions[0]!.agentRuntime = { + id: "openclaw", + source: "implicit", + }; + state.sessionsResult.sessions[0]!.contextTokens = 272_000; const container = renderModelControls(state); const providerButtons = Array.from( @@ -5821,7 +5995,7 @@ describe("chat model controls", () => { expect( container.querySelector('[data-chat-model-provider-group="openrouter"]') ?.textContent, - ).toContain("Gemma 4"); + ).toContain("272k active · 1M max"); }); it("uses a unique catalog provider before an unrelated stale session hint", () => { diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index b9c91e77cde5..8fc0343057c1 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -2,7 +2,10 @@ import { html } from "lit"; import type { ModelCatalogEntry, SessionsListResult } from "../../../api/types.ts"; import { t } from "../../../i18n/index.ts"; -import { normalizeChatModelProviderId } from "../../../lib/chat/model-ref.ts"; +import { + normalizeChatModelProviderId, + resolvePreferredServerChatModelValue, +} from "../../../lib/chat/model-ref.ts"; import { resolveChatFastModeSelectState, resolveChatModelSelectState, @@ -272,6 +275,44 @@ export function renderChatModelControls(props: ChatModelControlsProps) { !explicitOverride && currentOverride.trim().toLowerCase() === defaultModel.trim().toLowerCase() ? "" : currentOverride; + const activeModelOption = + pickerValue === "" + ? modelOptions.find((option) => option.isDefault) + : modelOptions.find((option) => option.value === pickerValue); + const activeSessionModel = activeSession?.model + ? resolveChatModelCatalogEntry( + resolvePreferredServerChatModelValue( + activeSession.model, + activeSession.modelProvider, + props.modelCatalog, + ), + props.modelCatalog, + ) + : undefined; + const activeOptionModel = activeModelOption + ? resolveChatModelCatalogEntry(activeModelOption.value, props.modelCatalog) + : undefined; + const activeSessionRuntime = activeSession?.agentRuntime?.id.trim().toLowerCase(); + const activeOptionRuntime = ( + activeOptionModel?.agentRuntime?.id ?? + (activeModelOption?.isDefault ? props.sessionsResult?.defaults?.agentRuntime?.id : undefined) + ) + ?.trim() + .toLowerCase(); + const activeRuntimeMatches = + Boolean(activeSessionRuntime) && activeSessionRuntime === activeOptionRuntime; + // Missing or mismatched current-selection provenance cannot bind the cached + // session window. Even matching provenance is useful only after the switch settles. + if ( + !props.modelSwitching && + activeModelOption && + activeSession?.contextTokens && + activeRuntimeMatches && + activeSessionModel !== undefined && + activeSessionModel === activeOptionModel + ) { + activeModelOption.contextTokens = activeSession.contextTokens; + } const lockedModelLabel = props.modelSelectionRuntimeId?.trim().toLowerCase() === "codex" ? t("chat.selectors.nativeCodexModel") diff --git a/ui/src/pages/chat/components/chat-model-picker-options.ts b/ui/src/pages/chat/components/chat-model-picker-options.ts index 29ec41a85f02..3bf0634abebc 100644 --- a/ui/src/pages/chat/components/chat-model-picker-options.ts +++ b/ui/src/pages/chat/components/chat-model-picker-options.ts @@ -11,6 +11,7 @@ import { formatContextTokenCapacity } from "../../../lib/format.ts"; export type ChatModelPickerOption = { agentRuntimeId?: string; commitValue: string; + contextTokens?: number; contextWindow?: number; disabled?: boolean; isDefault: boolean; @@ -20,6 +21,18 @@ export type ChatModelPickerOption = { value: string; }; +function formatModelContextMeta(option: ChatModelPickerOption): string { + const active = option.contextTokens; + const maximum = option.contextWindow; + if (active && maximum && active !== maximum) { + return t("chat.modelControls.contextActiveAndMax", { + active: formatContextTokenCapacity(active), + maximum: formatContextTokenCapacity(maximum), + }); + } + return maximum ? formatContextTokenCapacity(maximum) : ""; +} + export type ChatModelPickerTargetGroup = { id: string; label: string; @@ -74,7 +87,7 @@ export function renderChatModelPickerOption(params: { (params.entry.isDefault && params.selectedModelValue === ""); const modelLabel = formatModelLabel(params.entry); const modelMeta = [ - params.entry.contextWindow ? formatContextTokenCapacity(params.entry.contextWindow) : "", + formatModelContextMeta(params.entry), params.entry.supportsTools === false ? t("chat.modelControls.chatOnly") : "", params.entry.agentRuntimeId ? formatAgentRuntimeLabel(params.entry.agentRuntimeId) : "", params.entry.disabled ? t("modelSetup.candidates.signInNeeded") : "", diff --git a/ui/src/pages/chat/components/chat-model-picker.ts b/ui/src/pages/chat/components/chat-model-picker.ts index d293950d319c..dfc2364ee43f 100644 --- a/ui/src/pages/chat/components/chat-model-picker.ts +++ b/ui/src/pages/chat/components/chat-model-picker.ts @@ -4,7 +4,6 @@ import { icons } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; import { providerDisplayLabel } from "../../../components/provider-icon.ts"; import { t } from "../../../i18n/index.ts"; -import { formatContextTokenCapacity } from "../../../lib/format.ts"; import { renderChatModelPickerOption, renderChatModelPickerTargetOption, @@ -301,9 +300,6 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { ] .filter(Boolean) .join(" · "); - const triggerMeta = activeModelOption?.contextWindow - ? formatContextTokenCapacity(activeModelOption.contextWindow) - : ""; const providerGroups = new Map(); for (const option of params.modelOptions) { const existing = providerGroups.get(option.provider); @@ -422,9 +418,6 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { ${params.triggerStatusLabel ?? params.triggerModelLabel} - ${params.triggerStatusLabel || !triggerMeta - ? nothing - : html`${triggerMeta}`}