diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e4f0988a2..48f4ec4386a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,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..99a5f0b531b 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -5,12 +5,15 @@ import { describe, expect, it, vi } from "vitest"; import type { SessionsListResult } from "../types.ts"; import { renderSessions, type SessionsProps } from "./sessions.ts"; -function buildResult(session: SessionsListResult["sessions"][number]): SessionsListResult { +function buildResult( + session: SessionsListResult["sessions"][number], + defaults?: Partial, +): SessionsListResult { return { ts: Date.now(), path: "(multiple)", count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null, ...defaults }, sessions: [session], }; } @@ -229,7 +232,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 +263,47 @@ 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("labels inherited thinking from list defaults when lightweight rows omit row defaults", async () => { + const container = document.createElement("div"); + render( + renderSessions( + buildProps( + buildResult( + { + key: "agent:main:main", + kind: "direct", + updatedAt: Date.now(), + }, + { + modelProvider: "openai-codex", + model: "gpt-5.5", + thinkingDefault: "high", + thinkingLevels: [ + { id: "off", label: "off" }, + { id: "high", label: "high" }, + ], + }, + ), + ), + ), + container, + ); + await Promise.resolve(); + + const thinking = container.querySelector("tbody select") as HTMLSelectElement | null; + expect(thinking?.value).toBe(""); + expect(thinking?.options[0]?.textContent?.trim()).toBe("Inherited: high"); + expect(Array.from(thinking?.options ?? []).map((option) => option.textContent?.trim())).toEqual( + ["Inherited: high", "Off", "Override: high"], + ); }); it("keeps legacy binary thinking labels patching canonical ids", async () => { @@ -289,7 +332,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..85a39664203 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,27 +92,42 @@ function getAgentIdentity( : null; } -function normalizeThinkingOptionValue(raw: string): string { - return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw); +function rowMatchesSessionDefaults( + row: GatewaySessionRow, + defaults: SessionsListResult["defaults"] | undefined, +): boolean { + return ( + (!row.modelProvider || row.modelProvider === defaults?.modelProvider) && + (!row.model || row.model === defaults?.model) + ); } function resolveThinkLevelOptions( row: GatewaySessionRow, + defaults?: SessionsListResult["defaults"], ): readonly { value: string; label: string }[] { - const defaultLabel = row.thinkingDefault - ? t("sessionsView.defaultOption", { value: row.thinkingDefault }) - : t("sessionsView.inherit"); + const sessionModelMatchesDefaults = rowMatchesSessionDefaults(row, defaults); + const defaultLabel = formatInheritedThinkingLabel( + row.thinkingDefault ?? (sessionModelMatchesDefaults ? defaults?.thinkingDefault : undefined), + ); const options: readonly GatewayThinkingLevelOption[] = row.thinkingLevels?.length ? row.thinkingLevels - : (row.thinkingOptions?.length ? row.thinkingOptions : DEFAULT_THINK_LEVELS).map((label) => ({ - id: normalizeThinkingOptionValue(label), - label, - })); + : sessionModelMatchesDefaults && defaults?.thinkingLevels?.length + ? defaults.thinkingLevels + : (row.thinkingOptions?.length + ? row.thinkingOptions + : sessionModelMatchesDefaults && defaults?.thinkingOptions?.length + ? defaults.thinkingOptions + : DEFAULT_THINK_LEVELS + ).map((label) => ({ + id: normalizeThinkingOptionValue(label), + label, + })); return [ { value: "", label: defaultLabel }, ...options.map((option) => ({ value: normalizeThinkingOptionValue(option.id), - label: option.label, + label: formatThinkingOverrideLabel(option.id, option.label), })), ]; } @@ -133,10 +152,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 }> { @@ -689,7 +705,10 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : t("common.na"); const rawThinking = row.thinkingLevel ?? ""; const thinking = rawThinking ? normalizeThinkingOptionValue(rawThinking) : ""; - const thinkLevels = withCurrentLabeledOption(resolveThinkLevelOptions(row), thinking); + const thinkLevels = withCurrentLabeledOption( + resolveThinkLevelOptions(row, props.result?.defaults), + thinking, + ); const fastMode = row.fastMode === true ? "on" : row.fastMode === false ? "off" : ""; const fastLevels = withCurrentLabeledOption(buildFastLevelOptions(), fastMode); const verbose = row.verboseLevel ?? "";