From f292cc67dcfe093e79a6cbfcfb048afed62394c3 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 5 May 2026 20:23:07 -0500 Subject: [PATCH] fix(ui): label inherited thinking overrides --- CHANGELOG.md | 1 + docs/tools/thinking.md | 3 ++- ui/src/ui/chat/session-controls.ts | 23 ++++++++++++----------- ui/src/ui/thinking-labels.ts | 23 +++++++++++++++++++++++ ui/src/ui/views/chat.test.ts | 24 ++++++++++++------------ ui/src/ui/views/sessions.test.ts | 11 ++++++++--- ui/src/ui/views/sessions.ts | 21 ++++++++------------- 7 files changed, 66 insertions(+), 40 deletions(-) create mode 100644 ui/src/ui/thinking-labels.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fbab5b0e6..1028be82593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index b870eb85cb8..1ad5e01eac3 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -123,7 +123,8 @@ Malformed local-model reasoning tags are handled conservatively. Closed ` - 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 ()`, 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: ` 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:` still works and updates the same stored session level, so chat directives and the picker stay in sync. diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index a10f032cba0..e567749a1c8 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -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), }; } diff --git a/ui/src/ui/thinking-labels.ts b/ui/src/ui/thinking-labels.ts new file mode 100644 index 00000000000..ac141f9497b --- /dev/null +++ b/ui/src/ui/thinking-labels.ts @@ -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}`; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d522fc4f98e..286ca14c9fd 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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"); }); }); diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 9612d666340..9f231788df8 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -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 })); diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 80a27bdb5ca..c44b498f93f 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -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 }> {