mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user