fix(ui): label inherited thinking overrides

This commit is contained in:
Val Alexander 2026-05-05 20:23:07 -05:00
parent 70defcc046
commit f292cc67dc
No known key found for this signature in database
7 changed files with 66 additions and 40 deletions

View file

@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
- Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines.
- Control UI/chat: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, and hide that row while scrolling down the transcript. Thanks @BunsDev.
- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so repeated text-only messages stay compact without hiding nearby context.
- Control UI/chat and Sessions: label inherited thinking defaults separately from explicit overrides while preserving provider-supplied option labels. Fixes #77581. Thanks @BunsDev and @Beandon13.
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.

View file

@ -123,7 +123,8 @@ Malformed local-model reasoning tags are handled conservatively. Closed `<think>
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
- Picking another level writes the session override immediately via `sessions.patch`; it does not wait for the next send and it is not a one-shot `thinkingOnce` override.
- The first option is always `Default (<resolved level>)`, where the resolved default comes from the active session model's provider thinking profile plus the same fallback logic that `/status` and `session_status` use.
- The first option is always the clear-override choice. It shows `Inherited: <resolved level>` when the session is inheriting a non-off effective default, or `Off` when inherited thinking is disabled.
- Explicit picker choices are labeled as overrides, while preserving provider labels when present (for example `Override: maximum` for a provider-labeled `max` option).
- The picker uses `thinkingLevels` returned by the gateway session row/defaults, with `thinkingOptions` kept as a legacy label list. The browser UI does not keep its own provider regex list; plugins own model-specific level sets.
- `/think:<level>` still works and updates the same stored session level, so chat directives and the picker stay in sync.

View file

@ -15,6 +15,11 @@ import {
parseAgentSessionKey,
} from "../session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import {
formatInheritedThinkingLabel,
formatThinkingOverrideLabel,
normalizeThinkingOptionValue,
} from "../thinking-labels.ts";
import {
listThinkingLevelLabels,
normalizeThinkLevel,
@ -211,18 +216,14 @@ function buildThinkingOptions(
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
const resolvedLabel =
label ??
value
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" ");
pushUniqueTrimmedSelectOption(options, seen, value, () => resolvedLabel);
const normalizedValue = normalizeThinkingOptionValue(value);
pushUniqueTrimmedSelectOption(options, seen, normalizedValue, () =>
formatThinkingOverrideLabel(normalizedValue, label),
);
};
for (const level of levels) {
const normalized = normalizeThinkLevel(level.id) ?? normalizeLowercaseStringOrEmpty(level.id);
addOption(normalized, level.label);
addOption(level.id, level.label);
}
if (currentOverride) {
addOption(currentOverride);
@ -257,7 +258,7 @@ function resolveThinkingLevelOptions(
}));
}
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
export function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const persisted = activeRow?.thinkingLevel;
const currentOverride =
@ -283,7 +284,7 @@ function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelect
: "off");
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
defaultLabel: formatInheritedThinkingLabel(defaultLevel),
options: buildThinkingOptions(levels, currentOverride),
};
}

View file

@ -0,0 +1,23 @@
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
import { normalizeThinkLevel } from "./thinking.ts";
export function normalizeThinkingOptionValue(raw: string): string {
return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw);
}
export function formatInheritedThinkingLabel(effectiveLevel: string | null | undefined): string {
const normalized = effectiveLevel ? normalizeThinkingOptionValue(effectiveLevel) : "off";
if (!normalized || normalized === "off") {
return "Off";
}
return `Inherited: ${normalized}`;
}
export function formatThinkingOverrideLabel(value: string, label?: string | null): string {
const normalized = normalizeThinkingOptionValue(value);
if (!normalized || normalized === "off") {
return "Off";
}
const displayLabel = label?.trim() || normalized;
return `Override: ${displayLabel}`;
}

View file

@ -1041,7 +1041,7 @@ describe("chat session controls", () => {
[...(thinkingSelect?.options ?? [])]
.find((option) => option.value === "max")
?.textContent?.trim(),
).toBe("maximum");
).toBe("Override: maximum");
});
it("labels chat thinking default from the active session row", () => {
@ -1058,8 +1058,8 @@ describe("chat session controls", () => {
);
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Default (adaptive)");
expect(thinkingSelect?.title).toBe("Default (adaptive)");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Inherited: adaptive");
expect(thinkingSelect?.title).toBe("Inherited: adaptive");
});
it("always renders full thinking labels", () => {
@ -1089,14 +1089,14 @@ describe("chat session controls", () => {
expect(container.querySelector('select[data-chat-thinking-select-compact="true"]')).toBeNull();
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.title).toBe("Default (high)");
expect(thinkingSelect?.title).toBe("Inherited: high");
expect([...thinkingSelect!.options].map((option) => option.textContent?.trim())).toEqual([
"Default (high)",
"off",
"low",
"medium",
"high",
"xhigh",
"Inherited: high",
"Off",
"Override: low",
"Override: medium",
"Override: high",
"Override: xhigh",
]);
});
@ -1113,7 +1113,7 @@ describe("chat session controls", () => {
);
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Default (adaptive)");
expect(thinkingSelect?.title).toBe("Default (adaptive)");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Inherited: adaptive");
expect(thinkingSelect?.title).toBe("Inherited: adaptive");
});
});

View file

@ -229,7 +229,7 @@ describe("sessions view", () => {
Array.from(thinking?.options ?? [])
.find((option) => option.value === "max")
?.textContent?.trim(),
).toBe("maximum");
).toBe("Override: maximum");
thinking!.value = "max";
thinking!.dispatchEvent(new Event("change", { bubbles: true }));
@ -260,7 +260,12 @@ describe("sessions view", () => {
const thinking = container.querySelector("tbody select") as HTMLSelectElement | null;
expect(thinking?.value).toBe("");
expect(thinking?.options[0]?.textContent?.trim()).toBe("Default (adaptive)");
expect(thinking?.options[0]?.textContent?.trim()).toBe("Inherited: adaptive");
expect(
Array.from(thinking?.options ?? [])
.find((option) => option.value === "adaptive")
?.textContent?.trim(),
).toBe("Override: adaptive");
});
it("keeps legacy binary thinking labels patching canonical ids", async () => {
@ -289,7 +294,7 @@ describe("sessions view", () => {
Array.from(thinking?.options ?? [])
.find((option) => option.value === "low")
?.textContent?.trim(),
).toBe("on");
).toBe("Override: on");
thinking!.value = "low";
thinking!.dispatchEvent(new Event("change", { bubbles: true }));

View file

@ -5,7 +5,11 @@ import { icons } from "../icons.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import { normalizeThinkLevel } from "../thinking.ts";
import {
formatInheritedThinkingLabel,
formatThinkingOverrideLabel,
normalizeThinkingOptionValue,
} from "../thinking-labels.ts";
import type {
AgentIdentityResult,
GatewaySessionRow,
@ -88,16 +92,10 @@ function getAgentIdentity(
: null;
}
function normalizeThinkingOptionValue(raw: string): string {
return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw);
}
function resolveThinkLevelOptions(
row: GatewaySessionRow,
): readonly { value: string; label: string }[] {
const defaultLabel = row.thinkingDefault
? t("sessionsView.defaultOption", { value: row.thinkingDefault })
: t("sessionsView.inherit");
const defaultLabel = formatInheritedThinkingLabel(row.thinkingDefault);
const options: readonly GatewayThinkingLevelOption[] = row.thinkingLevels?.length
? row.thinkingLevels
: (row.thinkingOptions?.length ? row.thinkingOptions : DEFAULT_THINK_LEVELS).map((label) => ({
@ -108,7 +106,7 @@ function resolveThinkLevelOptions(
{ value: "", label: defaultLabel },
...options.map((option) => ({
value: normalizeThinkingOptionValue(option.id),
label: option.label,
label: formatThinkingOverrideLabel(option.id, option.label),
})),
];
}
@ -133,10 +131,7 @@ function withCurrentLabeledOption(
if (options.some((option) => option.value === current)) {
return [...options];
}
return [
...options,
{ value: current, label: t("sessionsView.customOption", { value: current }) },
];
return [...options, { value: current, label: formatThinkingOverrideLabel(current) }];
}
function buildVerboseLevelOptions(): Array<{ value: string; label: string }> {