ui: add chat thinking selector

This commit is contained in:
Tak Hoffman
2026-04-04 11:40:59 -05:00
parent f463256660
commit 3017a71bb7
11 changed files with 514 additions and 34 deletions

View File

@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
- Synology Chat/security: route webhook token comparison through the shared constant-time secret helper for consistency with other bundled plugins.
- Gateway/security: scope loopback browser-origin auth throttling by normalized origin so one localhost Control UI tab cannot lock out a different localhost browser origin after repeated auth failures.
- Node exec approvals: keep node-host `system.run` approvals bound to the prepared execution plan, so script-drift revalidation still runs after agent-side approval forwarding.

View File

@@ -743,8 +743,8 @@
}
.chat-controls__session {
min-width: 140px;
max-width: 300px;
min-width: 98px;
max-width: 190px;
}
.chat-controls__session-row {
@@ -755,8 +755,13 @@
}
.chat-controls__model {
min-width: 170px;
max-width: 320px;
min-width: 124px;
max-width: 206px;
}
.chat-controls__thinking-select {
min-width: 88px;
max-width: 118px;
}
.chat-controls__thinking {
@@ -781,13 +786,17 @@
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
max-width: 300px;
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-controls__model select {
max-width: 320px;
max-width: 206px;
}
.chat-controls__thinking-select select {
max-width: 118px;
}
.chat-controls__thinking {
@@ -818,6 +827,11 @@
max-width: none;
}
.chat-controls__thinking-select {
min-width: 130px;
max-width: none;
}
.chat-controls {
gap: 8px;
}
@@ -863,6 +877,10 @@
.chat-controls__model {
min-width: 150px;
}
.chat-controls__thinking-select {
min-width: 140px;
}
}
/* Chat loading skeleton */

View File

@@ -1,4 +1,3 @@
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
import { setLastActiveSessionKey } from "./app-settings.ts";
import { resetToolStream } from "./app-tool-stream.ts";
@@ -10,6 +9,7 @@ import { loadModels } from "./controllers/models.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts";
import { parseAgentSessionKey } from "./session-key.ts";
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
import type { SessionsListResult } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";

View File

@@ -1,6 +1,5 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import { t } from "../i18n/index.ts";
import { refreshChat } from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
@@ -16,8 +15,14 @@ import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { loadSessions } from "./controllers/sessions.ts";
import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import { parseAgentSessionKey } from "./session-key.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
import {
listThinkingLevelLabels,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "./thinking.ts";
import type { SessionsListResult } from "./types.ts";
type SessionDefaultsSnapshot = {
@@ -133,11 +138,16 @@ function renderCronFilterIcon(hiddenCount: number) {
export function renderChatSessionSelect(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
title=${selectedSessionLabel}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
@@ -162,7 +172,7 @@ export function renderChatSessionSelect(state: AppViewState) {
)}
</select>
</label>
${modelSelect}
${modelSelect} ${thinkingSelect}
</div>
`;
}
@@ -436,6 +446,7 @@ export function renderChatMobileToggle(state: AppViewState) {
)}
</select>
</label>
${renderChatThinkingSelect(state)}
<div class="chat-controls__thinking">
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
@@ -532,11 +543,16 @@ function renderChatModelSelect(state: AppViewState) {
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label="Chat model"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
@@ -557,6 +573,125 @@ function renderChatModelSelect(state: AppViewState) {
`;
}
type ChatThinkingSelectOption = {
value: string;
label: string;
};
type ChatThinkingSelectState = {
currentOverride: string;
defaultLabel: string;
options: ChatThinkingSelectOption[];
};
function resolveThinkingTargetModel(state: AppViewState): {
provider: string | null;
model: string | null;
} {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
return {
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
};
}
function buildThinkingOptions(
provider: string | null,
model: string | null,
currentOverride: string,
): ChatThinkingSelectOption[] {
const seen = new Set<string>();
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
const key = trimmed.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({
value: trimmed,
label:
label ??
trimmed
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
});
};
for (const label of listThinkingLevelLabels(provider)) {
const normalized = normalizeThinkLevel(label) ?? label.trim().toLowerCase();
addOption(normalized);
}
if (currentOverride) {
addOption(currentOverride);
}
return options;
}
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const persisted = activeRow?.thinkingLevel;
const currentOverride =
typeof persisted === "string" && persisted.trim()
? (normalizeThinkLevel(persisted) ?? persisted.trim())
: "";
const { provider, model } = resolveThinkingTargetModel(state);
const defaultLevel =
provider && model
? resolveThinkingDefaultForModel({
provider,
model,
catalog: state.chatModelCatalog ?? [],
})
: "off";
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
options: buildThinkingOptions(provider, model, currentOverride),
};
}
function renderChatThinkingSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled = !state.connected || busy || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
data-chat-thinking-select="true"
aria-label="Chat thinking level"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function switchChatModel(state: AppViewState, nextModel: string) {
if (!state.client || !state.connected) {
return;
@@ -587,6 +722,60 @@ async function switchChatModel(state: AppViewState, nextModel: string) {
}
}
function patchSessionThinkingLevel(
state: AppViewState,
sessionKey: string,
thinkingLevel: string | undefined,
) {
const current = state.sessionsResult;
if (!current) {
return;
}
state.sessionsResult = {
...current,
sessions: current.sessions.map((row) =>
row.key === sessionKey
? {
...row,
thinkingLevel,
}
: row,
),
};
}
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
if (!state.client || !state.connected) {
return;
}
const targetSessionKey = state.sessionKey;
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
const previousThinkingLevel = activeRow?.thinkingLevel;
const normalizedNext =
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
const normalizedPrev =
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
: undefined;
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
return;
}
state.lastError = null;
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
state.chatThinkingLevel = normalizedNext ?? null;
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
thinkingLevel: normalizedNext ?? null,
});
await refreshSessionOptions(state);
} catch (err) {
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
state.chatThinkingLevel = normalizedPrev ?? null;
state.lastError = `Failed to set thinking level: ${String(err)}`;
}
}
/* ── Channel display labels ────────────────────────────── */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",

View File

@@ -1,9 +1,4 @@
import { html, nothing } from "lit";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChatAvatar } from "./app-chat.ts";
@@ -96,10 +91,15 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import "./components/dashboard-header.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import "./components/dashboard-header.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {
resolveAgentConfig,

View File

@@ -1,6 +1,5 @@
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
@@ -71,6 +70,7 @@ import type {
} from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { resolveAgentIdFromSessionKey } from "./session-key.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type {

View File

@@ -3,20 +3,19 @@
* Calls gateway RPC methods and returns formatted results.
*/
import {
formatThinkingLevels,
normalizeThinkLevel,
normalizeVerboseLevel,
resolveThinkingDefaultForModel,
} from "../../../../src/auto-reply/thinking.shared.js";
import { createChatModelOverride, resolvePreferredServerChatModel } from "../chat-model-ref.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
isSubagentSessionKey,
parseAgentSessionKey,
} from "../../../../src/routing/session-key.js";
import { createChatModelOverride, resolvePreferredServerChatModel } from "../chat-model-ref.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
} from "../session-key.ts";
import {
formatThinkingLevels,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "../thinking.ts";
import type {
AgentsListResult,
ChatModelOverride,
@@ -56,6 +55,24 @@ export type SlashCommandContext = {
modelCatalog?: ModelCatalogEntry[];
sessionsResult?: SessionsListResult | null;
};
function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | undefined {
if (!raw) {
return undefined;
}
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) {
return "off";
}
if (["full", "all", "everything"].includes(key)) {
return "full";
}
if (["on", "minimal", "true", "yes", "1"].includes(key)) {
return "on";
}
return undefined;
}
export async function executeSlashCommand(
client: GatewayBrowserClient,
sessionKey: string,
@@ -205,7 +222,7 @@ async function executeThink(
return {
content: formatDirectiveOptions(
`Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`,
formatThinkingLevels(session?.modelProvider, session?.model),
formatThinkingLevels(session?.modelProvider),
),
};
} catch (err) {
@@ -218,7 +235,7 @@ async function executeThink(
try {
const session = await loadCurrentSession(client, sessionKey);
return {
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`,
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider)}.`,
};
} catch (err) {
return { content: `Failed to validate thinking level: ${String(err)}` };

View File

@@ -1,9 +1,9 @@
import { resolveAgentIdFromSessionKey } from "../../../../src/routing/session-key.js";
import {
resolveChatModelOverride,
resolvePreferredServerChatModelValue,
} from "../chat-model-ref.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { resolveAgentIdFromSessionKey } from "../session-key.ts";
import type {
AgentsListResult,
ChatModelOverride,

80
ui/src/ui/session-key.ts Normal file
View File

@@ -0,0 +1,80 @@
export type ParsedAgentSessionKey = {
agentId: string;
rest: string;
};
export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main";
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
export function parseAgentSessionKey(
sessionKey: string | undefined | null,
): ParsedAgentSessionKey | null {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return null;
}
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3 || parts[0] !== "agent") {
return null;
}
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) {
return null;
}
return { agentId, rest };
}
export function normalizeMainKey(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_KEY;
}
export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_AGENT_ID;
}
if (VALID_ID_RE.test(trimmed)) {
return trimmed.toLowerCase();
}
return (
trimmed
.toLowerCase()
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
export function buildAgentMainSessionKey(params: {
agentId: string;
mainKey?: string | undefined;
}): string {
const agentId = normalizeAgentId(params.agentId);
const mainKey = normalizeMainKey(params.mainKey);
return `agent:${agentId}:${mainKey}`;
}
export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
}
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return false;
}
if (raw.toLowerCase().startsWith("subagent:")) {
return true;
}
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
}

93
ui/src/ui/thinking.ts Normal file
View File

@@ -0,0 +1,93 @@
export type ThinkingCatalogEntry = {
provider: string;
id: string;
reasoning?: boolean;
};
const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "adaptive"] as const;
const BINARY_THINKING_LEVELS = ["off", "on"] as const;
const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
export function normalizeThinkingProviderId(provider?: string | null): string {
if (!provider) {
return "";
}
const normalized = provider.trim().toLowerCase();
if (normalized === "z.ai" || normalized === "z-ai") {
return "zai";
}
if (normalized === "bedrock" || normalized === "aws-bedrock") {
return "amazon-bedrock";
}
return normalized;
}
export function isBinaryThinkingProvider(provider?: string | null): boolean {
return normalizeThinkingProviderId(provider) === "zai";
}
export function normalizeThinkLevel(raw?: string | null): string | undefined {
if (!raw) {
return undefined;
}
const key = raw.trim().toLowerCase();
const collapsed = key.replace(/[\s_-]+/g, "");
if (collapsed === "adaptive" || collapsed === "auto") {
return "adaptive";
}
if (collapsed === "xhigh" || collapsed === "extrahigh") {
return "xhigh";
}
if (key === "off") {
return "off";
}
if (["on", "enable", "enabled"].includes(key)) {
return "low";
}
if (["min", "minimal"].includes(key)) {
return "minimal";
}
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) {
return "low";
}
if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) {
return "medium";
}
if (
["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key)
) {
return "high";
}
if (key === "think") {
return "minimal";
}
return undefined;
}
export function listThinkingLevelLabels(provider?: string | null): readonly string[] {
return isBinaryThinkingProvider(provider) ? BINARY_THINKING_LEVELS : BASE_THINKING_LEVELS;
}
export function formatThinkingLevels(provider?: string | null): string {
return listThinkingLevelLabels(provider).join(", ");
}
export function resolveThinkingDefaultForModel(params: {
provider: string;
model: string;
catalog?: ThinkingCatalogEntry[];
}): string {
const normalizedProvider = normalizeThinkingProviderId(params.provider);
const modelId = params.model.trim();
if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) {
return "adaptive";
}
if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) {
return "adaptive";
}
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
return candidate?.reasoning ? "low" : "off";
}

View File

@@ -61,17 +61,23 @@ function createChatHeaderState(
overrides: {
model?: string | null;
modelProvider?: string | null;
thinkingLevel?: string | null;
models?: ModelCatalogEntry[];
omitSessionFromList?: boolean;
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
let currentModel = overrides.model ?? null;
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
let currentThinkingLevel = overrides.thinkingLevel ?? null;
const omitSessionFromList = overrides.omitSessionFromList ?? false;
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.patch") {
const nextModel = (params.model as string | null | undefined) ?? null;
const nextThinkingLevel = params.thinkingLevel as string | null | undefined;
if ("thinkingLevel" in params) {
currentThinkingLevel = nextThinkingLevel ?? null;
}
if (!nextModel) {
currentModel = null;
currentModelProvider = null;
@@ -97,11 +103,15 @@ function createChatHeaderState(
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return createSessionsListResult({
const result = createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
});
if (result.sessions[0]) {
result.sessions[0].thinkingLevel = currentThinkingLevel ?? undefined;
}
return result;
}
if (method === "models.list") {
return { models: catalog };
@@ -119,11 +129,17 @@ function createChatHeaderState(
sessionKey: "main",
connected: true,
sessionsHideCron: true,
sessionsResult: createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
}),
sessionsResult: (() => {
const result = createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
});
if (result.sessions[0]) {
result.sessions[0].thinkingLevel = currentThinkingLevel ?? undefined;
}
return result;
})(),
chatModelOverrides: {},
chatModelCatalog: catalog,
chatModelsLoading: false,
@@ -940,6 +956,72 @@ describe("chat view", () => {
vi.unstubAllGlobals();
});
it("shows the default thinking level in the chat header picker", async () => {
const { state } = createChatHeaderState({
model: "gpt-5",
modelProvider: "openai",
});
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
expect(thinkingSelect).not.toBeNull();
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Default (off)");
});
it("patches the current session thinking level from the chat header picker", async () => {
const { state, request } = createChatHeaderState({
model: "gpt-5",
modelProvider: "openai",
});
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
expect(thinkingSelect).not.toBeNull();
thinkingSelect!.value = "off";
thinkingSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
thinkingLevel: "off",
});
expect(state.sessionsResult?.sessions[0]?.thinkingLevel).toBe("off");
});
it("clears the session thinking override back to the default thinking level", async () => {
const { state, request } = createChatHeaderState({
model: "gpt-5",
modelProvider: "openai",
thinkingLevel: "high",
});
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
expect(thinkingSelect).not.toBeNull();
expect(thinkingSelect?.value).toBe("high");
thinkingSelect!.value = "";
thinkingSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
thinkingLevel: null,
});
expect(state.sessionsResult?.sessions[0]?.thinkingLevel).toBeUndefined();
});
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
vi.stubGlobal(
"fetch",