mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 21:30:42 +00:00
fix(ui): label inherited thinking overrides
Closes #77581. ## Summary - Add a shared thinking-label formatter for inherited vs explicit reasoning values. - Show inherited thinking controls as `Inherited (Default: <Level>)` in chat and Sessions selectors. - Preserve provider/model labels for explicit thinking overrides and document the inherited/default wording. ## Verification - `pnpm docs:list` - `pnpm tsgo:core` on current `origin/main` (`70defcc046`) -> passes - `git diff --check` - `pnpm exec oxfmt --check --threads=1 ui/src/ui/thinking-labels.ts ui/src/ui/chat/session-controls.ts ui/src/ui/views/chat.test.ts ui/src/ui/views/sessions.ts ui/src/ui/views/sessions.test.ts docs/tools/thinking.md CHANGELOG.md` - `pnpm changed:lanes --json` -> `core`, `coreTests`, `docs` - `pnpm test ui/src/ui/views/chat.test.ts ui/src/ui/views/sessions.test.ts src/gateway/server.sessions.list-changed.test.ts` -> 3 Vitest shards, 58 tests - Testbox `pnpm check:changed` on `a906cb75ce` -> passes - GitHub PR checks for #78176 on `a906cb75ce` -> no pending or failed jobs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
23
ui/src/ui/thinking-labels.ts
Normal file
23
ui/src/ui/thinking-labels.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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["defaults"]>,
|
||||
): 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 }));
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
Reference in New Issue
Block a user