fix(ui): clarify active model context in picker (#124207)

* fix(ui): show active model context budget

* fix(ui): simplify compact model label

* fix(ui): preserve provider when matching context budget

* fix(ui): hide stale context during model switches

---------

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
Patrick Erichsen
2026-08-15 19:57:27 -07:00
committed by GitHub
parent e68f3d8ad1
commit a73249c7cd
5 changed files with 238 additions and 16 deletions
+1
View File
@@ -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.",
+181 -7
View File
@@ -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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[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<HTMLElement>('[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", () => {
@@ -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")
@@ -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") : "",
@@ -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<string, ChatModelPickerOption[]>();
for (const option of params.modelOptions) {
const existing = providerGroups.get(option.provider);
@@ -422,9 +418,6 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
<span class="chat-controls__inline-select-label">
${params.triggerStatusLabel ?? params.triggerModelLabel}
</span>
${params.triggerStatusLabel || !triggerMeta
? nothing
: html`<span class="chat-controls__trigger-meta">${triggerMeta}</span>`}
</summary>
<wa-popup data-anchored-overlay>
<div