From efbcb57569abb684b43c481058f76b1f9ec63f5e Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:02:33 +1000 Subject: [PATCH] fix(control-ui): autocomplete model thinking levels (#123507) * fix(control-ui): autocomplete model thinking levels * fix(control-ui): keep thinking options model-current * fix(control-ui): execute catalog thinking levels * fix(control-ui): close stale think picker --------- Co-authored-by: Jesse Merhi --- .../e2e/chat-thinking-arguments.e2e.test.ts | 126 ++++++++++++++++++ ui/src/lib/chat/thinking.ts | 36 ++++- .../pages/chat/chat-command-executor.test.ts | 44 ++++++ ui/src/pages/chat/chat-command-executor.ts | 11 +- .../pages/chat/chat-composer.test-support.ts | 2 + ui/src/pages/chat/chat-pane-render.ts | 2 + ui/src/pages/chat/chat-view.test.ts | 89 ++++++++++++- ui/src/pages/chat/chat-view.ts | 6 +- .../components/chat-composer-slash-menu.ts | 43 ++++-- .../chat/components/chat-composer-types.ts | 4 +- ui/src/pages/chat/components/chat-composer.ts | 5 + 11 files changed, 344 insertions(+), 24 deletions(-) create mode 100644 ui/src/e2e/chat-thinking-arguments.e2e.test.ts diff --git a/ui/src/e2e/chat-thinking-arguments.e2e.test.ts b/ui/src/e2e/chat-thinking-arguments.e2e.test.ts new file mode 100644 index 000000000000..17afaa8796a0 --- /dev/null +++ b/ui/src/e2e/chat-thinking-arguments.e2e.test.ts @@ -0,0 +1,126 @@ +// Control UI E2E proves model-aware /think completion in the rendered composer. +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI thinking argument completion", +}); + +const VIEWPORTS = [ + { name: "mobile", width: 390, height: 844 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "desktop", width: 1440, height: 900 }, +] as const; + +suite.define(() => { + it.each(VIEWPORTS)( + "opens the active model's thinking levels above the composer ($name)", + async (viewport) => { + await suite.withPage({ viewport }, async ({ page }) => { + const browserErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + browserErrors.push(message.text()); + } + }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + const gateway = await installMockGateway(page, { + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai", + thinkingLevels: [ + { id: "off", label: "off" }, + { id: "minimal", label: "minimal" }, + { id: "low", label: "low" }, + { id: "medium", label: "medium" }, + { id: "high", label: "high" }, + { id: "xhigh", label: "xhigh" }, + { id: "max", label: "max" }, + { id: "ultra", label: "ultra" }, + ], + }, + ], + methodResponses: { + "sessions.list": { + count: 1, + defaults: { + contextTokens: 200_000, + model: "gpt-5.6-sol", + modelProvider: "openai", + }, + path: "", + sessions: [ + { + key: "main", + kind: "direct", + model: "gpt-5.6-sol", + modelProvider: "openai", + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }, + }, + }); + + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible" }); + await expect.poll(() => composer.isEnabled()).toBe(true); + + await composer.fill("/think"); + await composer.press("Tab"); + + const picker = page.locator(".slash-menu[role='listbox']"); + await picker.waitFor({ state: "visible" }); + await expect.poll(() => composer.inputValue()).toBe("/think "); + await expect + .poll(() => picker.getByRole("option").locator(".slash-menu-name").allTextContents()) + .toEqual(["default", "off", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); + + const [pickerBox, inputBox] = await Promise.all([ + picker.boundingBox(), + page.locator(".agent-chat__input").boundingBox(), + ]); + expect(pickerBox).not.toBeNull(); + expect(inputBox).not.toBeNull(); + expect((pickerBox?.y ?? 0) + (pickerBox?.height ?? 0)).toBeLessThanOrEqual( + (inputBox?.y ?? 0) + 1, + ); + expect(pickerBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((pickerBox?.x ?? 0) + (pickerBox?.width ?? 0)).toBeLessThanOrEqual(viewport.width); + expect(pickerBox?.y ?? -1).toBeGreaterThanOrEqual(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + viewport.width, + ); + expect(browserErrors).toEqual([]); + + await composer.press("ArrowUp"); + await composer.press("Tab"); + await expect.poll(() => composer.inputValue()).toBe("/think ultra"); + await composer.press("Enter"); + const patchRequest = await gateway.waitForRequest("sessions.patch"); + expect(patchRequest.params).toMatchObject({ + key: "main", + thinkingLevel: "ultra", + }); + + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await fs.mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + path: path.join(artifactDir, `think-arguments-${viewport.name}.png`), + fullPage: true, + }); + } + }); + }, + ); +}); diff --git a/ui/src/lib/chat/thinking.ts b/ui/src/lib/chat/thinking.ts index a49082e6e724..fbb0b1725e64 100644 --- a/ui/src/lib/chat/thinking.ts +++ b/ui/src/lib/chat/thinking.ts @@ -43,16 +43,39 @@ export type ChatThinkingSelectState = { function resolveThinkingLevelOptionsForSession( session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, + catalog: readonly ModelCatalogEntry[] = [], + fallbackLabels?: readonly string[], ): GatewayThinkingLevelOption[] { const { provider, model } = resolveThinkingTargetModel({ defaults, session }); - return resolveThinkingLevelOptions({ catalog: [], defaults, model, provider, session }); + return resolveThinkingLevelOptions({ + catalog, + defaults, + fallbackLabels, + model, + provider, + session, + }); +} + +export function resolveThinkingCommandArgOptionsForSession( + session: ChatThinkingTarget | undefined, + defaults?: SessionsListResult["defaults"], + catalog: readonly ModelCatalogEntry[] = [], +): string[] { + const options = resolveThinkingLevelOptionsForSession(session, defaults, catalog, []).map( + (level) => normalizeThinkingOptionValue(level.id), + ); + return options.length > 0 + ? ["default", ...new Set(options.filter((option) => option && option !== "default"))] + : []; } export function formatThinkingCommandOptionsForSession( session: ChatThinkingTarget | undefined, defaults?: SessionsListResult["defaults"], + catalog: readonly ModelCatalogEntry[] = [], ): string { - const options = resolveThinkingLevelOptionsForSession(session, defaults) + const options = resolveThinkingLevelOptionsForSession(session, defaults, catalog) .map((level) => level.label) .join(", "); return options.split(", ").includes("default") ? options : `default, ${options}`; @@ -62,13 +85,14 @@ export function resolveThinkingLevelInput( rawLevel: string, session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, + catalog: readonly ModelCatalogEntry[] = [], ): string | undefined { const normalized = normalizeThinkLevel(rawLevel); if (normalized) { return normalized; } const rawKey = normalizeLowercaseStringOrEmpty(rawLevel); - return resolveThinkingLevelOptionsForSession(session, defaults) + return resolveThinkingLevelOptionsForSession(session, defaults, catalog) .map((option) => ({ id: normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id), label: normalizeLowercaseStringOrEmpty(option.label), @@ -80,8 +104,9 @@ export function isThinkingLevelOptionForSession( session: ChatThinkingTarget | undefined, defaults: ThinkingSessionDefaults, level: string, + catalog: readonly ModelCatalogEntry[] = [], ): boolean { - return resolveThinkingLevelOptionsForSession(session, defaults).some((option) => { + return resolveThinkingLevelOptionsForSession(session, defaults, catalog).some((option) => { const id = normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id); return id === level || normalizeThinkLevel(option.label) === level; }); @@ -167,6 +192,7 @@ function resolveThinkingCatalogEntry( function resolveThinkingLevelOptions(params: { catalog: readonly ModelCatalogEntry[]; defaults: ThinkingSessionDefaults; + fallbackLabels?: readonly string[]; hideUnsupportedOffOnly?: boolean; model: string | null; provider: string | null; @@ -202,7 +228,7 @@ function resolveThinkingLevelOptions(params: { return []; } } - const labels = explicitLabels ?? BASE_THINKING_LEVELS; + const labels = explicitLabels ?? params.fallbackLabels ?? BASE_THINKING_LEVELS; return labels.map((label) => ({ id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label), label, diff --git a/ui/src/pages/chat/chat-command-executor.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts index a9775e6ed44b..a22846b66875 100644 --- a/ui/src/pages/chat/chat-command-executor.test.ts +++ b/ui/src/pages/chat/chat-command-executor.test.ts @@ -1099,6 +1099,50 @@ describe("executeSlashCommand directives", () => { }); }); + it("accepts a thinking level advertised only by the active model catalog", async () => { + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + model: "gpt-5.6-sol", + modelProvider: "openai", + }), + ], + }; + } + if (method === "sessions.patch") { + return { ok: true, ...((payload ?? {}) as object) }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + createTestGatewayClient(request), + "agent:main:main", + "think", + "ultra", + { + chatModelCatalog: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai", + reasoning: true, + thinkingLevels: [{ id: "ultra", label: "ultra" }], + }, + ], + }, + ); + + expect(result.content).toBe(t("chat.commandResults.thinking.set", { level: "**ultra**" })); + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "agent:main:main", + thinkingLevel: "ultra", + }); + expectNoRequestCall(request, "models.list"); + }); + it("clears thinking override for /think default", async () => { const request = vi.fn(async (method: string, payload?: unknown) => { if (method === "sessions.patch") { diff --git a/ui/src/pages/chat/chat-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts index 8ccc2510a126..c943e7eba708 100644 --- a/ui/src/pages/chat/chat-command-executor.ts +++ b/ui/src/pages/chat/chat-command-executor.ts @@ -375,7 +375,7 @@ async function executeThink( t("chat.commandResults.thinking.current", { level: resolveCurrentThinkingLevel(session, defaults, models), }), - formatThinkingCommandOptionsForSession(session, defaults), + formatThinkingCommandOptionsForSession(session, defaults, models), ), }; } catch (err) { @@ -405,20 +405,21 @@ async function executeThink( try { const { session, defaults } = await loadCurrentSessionState(context, sessionKey); - const level = resolveThinkingLevelInput(rawLevel, session, defaults); + const modelCatalog = context.chatModelCatalog ?? context.modelCatalog ?? []; + const level = resolveThinkingLevelInput(rawLevel, session, defaults, modelCatalog); if (!level) { return { content: t("chat.commandResults.thinking.unrecognized", { level: rawLevel, - options: formatThinkingCommandOptionsForSession(session, defaults), + options: formatThinkingCommandOptionsForSession(session, defaults, modelCatalog), }), }; } - if (!isThinkingLevelOptionForSession(session, defaults, level)) { + if (!isThinkingLevelOptionForSession(session, defaults, level, modelCatalog)) { return { content: t("chat.commandResults.thinking.unsupported", { level: rawLevel, - options: formatThinkingCommandOptionsForSession(session, defaults), + options: formatThinkingCommandOptionsForSession(session, defaults, modelCatalog), }), }; } diff --git a/ui/src/pages/chat/chat-composer.test-support.ts b/ui/src/pages/chat/chat-composer.test-support.ts index af2a9d06eff7..ba40f7ae480f 100644 --- a/ui/src/pages/chat/chat-composer.test-support.ts +++ b/ui/src/pages/chat/chat-composer.test-support.ts @@ -18,6 +18,8 @@ export function createComposerProps(overrides: Partial = {}): Com stream: null, queue: [], draft: "", + modelCatalog: [], + modelSwitching: false, sessions: null, assistantName: "OpenClaw", onDraftChange: vi.fn(), diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index b1bb4493d753..47e31181b55c 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -340,6 +340,8 @@ export class ChatPane extends ChatPaneLayoutRender { sendShortcut: state.settings.chatSendShortcut, followUpMode: state.chatFollowUpMode, draft: state.chatMessage, + modelCatalog: state.chatModelCatalog, + modelSwitching: Boolean(state.chatModelSwitchPromises[state.sessionKey]), queue: state.chatQueue, queuedOutboxCount: state.chatQueue.filter((item) => !item.pendingRunId).length, realtimeTalkActive: state.realtimeTalkActive, diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index f481bc019fab..6f76fce8e1e8 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -673,6 +673,8 @@ function createChatProps(overrides: Partial = {}): ChatProps { streamStartedAt: null, assistantAvatarUrl: null, draft: "", + modelCatalog: [], + modelSwitching: false, queue: [], realtimeTalkActive: false, realtimeTalkStatus: "idle", @@ -3456,22 +3458,24 @@ describe("chat slash menu accessibility", () => { ...overrides }: Partial = {}) { let draft = ""; + let currentOverrides = overrides; const container = document.createElement("div"); const onDraftChange = vi.fn((next: string) => { draft = next; observeDraftChange?.(next); }); - const renderCurrent = () => { + const renderCurrent = (nextOverrides: Partial = {}) => { + currentOverrides = { ...currentOverrides, ...nextOverrides }; renderChatInto(container, { draft, getDraft: () => draft, onDraftChange, onRequestUpdate: renderCurrent, - ...overrides, + ...currentOverrides, }); }; renderCurrent(); - return { container }; + return { container, renderCurrent }; } function createSlashRerenderHarness() { @@ -4195,6 +4199,85 @@ describe("chat slash menu accessibility", () => { expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("aria-selected")).toBe("true"); }); + it("opens model-supported thinking arguments after tab-completing /think", () => { + const sessions = createSessionsListResult({ + model: "gpt-5.6-sol", + modelProvider: "openai", + }); + const session = expectDefined(sessions.sessions[0], "active session"); + session.thinkingLevels = [ + { id: "off", label: "off" }, + { id: "minimal", label: "minimal" }, + { id: "low", label: "low" }, + { id: "medium", label: "medium" }, + { id: "high", label: "high" }, + { id: "xhigh", label: "xhigh" }, + { id: "max", label: "max" }, + { id: "ultra", label: "ultra" }, + ]; + const { container } = createReactiveDraftHarness({ sessions }); + + inputDraft(container, "/think"); + keydownComposer(container, "Tab"); + + expect(container.querySelector("textarea")?.value).toBe("/think "); + expect( + Array.from(container.querySelectorAll(".slash-menu [role='option']")).map( + (option) => option.querySelector(".slash-menu-name")?.textContent?.trim(), + ), + ).toEqual(["default", "off", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); + }); + + it("suppresses thinking arguments while the active model is switching", () => { + const sessions = createSessionsListResult({ + model: "gpt-5.6-sol", + modelProvider: "openai", + }); + const session = expectDefined(sessions.sessions[0], "active session"); + session.thinkingLevels = [ + { id: "low", label: "low" }, + { id: "high", label: "high" }, + ]; + const { container } = createReactiveDraftHarness({ modelSwitching: true, sessions }); + + inputDraft(container, "/think"); + keydownComposer(container, "Tab"); + + expect(container.querySelector("textarea")?.value).toBe("/think "); + expect(container.querySelector(".slash-menu")).toBeNull(); + }); + + it("closes open thinking arguments when the active model starts switching", () => { + const sessions = createSessionsListResult({ + model: "gpt-5.6-sol", + modelProvider: "openai", + }); + const session = expectDefined(sessions.sessions[0], "active session"); + session.thinkingLevels = [ + { id: "low", label: "low" }, + { id: "high", label: "high" }, + ]; + const { container, renderCurrent } = createReactiveDraftHarness({ sessions }); + + inputDraft(container, "/think"); + keydownComposer(container, "Tab"); + expect(container.querySelector(".slash-menu")).not.toBeNull(); + expect( + container + .querySelector("textarea") + ?.getAttribute("aria-activedescendant"), + ).toBe("chat-single-slash-option-arg-think-default"); + + renderCurrent({ modelSwitching: true }); + + expect(container.querySelector(".slash-menu")).toBeNull(); + expect( + container + .querySelector("textarea") + ?.hasAttribute("aria-activedescendant"), + ).toBe(false); + }); + it("clears active descendant when suggestions close", () => { const harness = createSlashRerenderHarness(); let container = harness.inputAndRender(harness.container, "/"); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 7dd1ac264cb2..4768a251a12d 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -13,7 +13,7 @@ import type { ControlUiSessionPullRequest, } from "../../../../src/gateway/control-ui-contract.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import type { GatewaySessionRow, ModelCatalogEntry, SessionsListResult } from "../../api/types.ts"; import type { ExecApprovalDecision, ExecApprovalRequest } from "../../app/exec-approval.ts"; import type { QuestionPrompt } from "../../app/question-prompt.ts"; import type { ChatSendShortcut } from "../../app/settings.ts"; @@ -125,6 +125,8 @@ export type ChatProps = ChatTaskSuggestionTrayProps & runOutputTokens?: number | null; assistantAvatarUrl?: string | null; draft: string; + modelCatalog: readonly ModelCatalogEntry[]; + modelSwitching: boolean; queue: ChatQueueItem[]; queuedOutboxCount?: number; realtimeTalkActive?: boolean; @@ -408,6 +410,8 @@ export function renderChat(props: ChatProps) { stream: props.stream, queue: props.queue, draft: props.draft, + modelCatalog: props.modelCatalog, + modelSwitching: props.modelSwitching, sessions: props.sessions, toolOverrides: props.toolOverrides, capabilityMenu: props.capabilityMenu, diff --git a/ui/src/pages/chat/components/chat-composer-slash-menu.ts b/ui/src/pages/chat/components/chat-composer-slash-menu.ts index 01a4bdbe1a4e..231ef9924590 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -9,6 +9,8 @@ import { type SlashCommandCategory, type SlashCommandDef, } from "../../../lib/chat/commands.ts"; +import { resolveThinkingCommandArgOptionsForSession } from "../../../lib/chat/thinking.ts"; +import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts"; import { paneDomId } from "./chat-composer-dom.ts"; import { commitComposerDraft, getChatComposerState } from "./chat-composer-state.ts"; import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts"; @@ -39,6 +41,26 @@ function closeSlashMenuIfNeeded(state: ChatComposerState, requestUpdate: () => v requestUpdate(); } +function resolveSlashCommandArgOptions( + command: SlashCommandDef, + props: ChatComposerProps, +): string[] { + if (command.key !== "think") { + return command.argOptions ?? []; + } + if (props.modelSwitching) { + return []; + } + const session = props.sessions?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, props.sessionKey), + ); + return resolveThinkingCommandArgOptionsForSession( + session, + props.sessions?.defaults, + props.modelCatalog, + ); +} + function requestSlashCommandRefresh( value: string, props: ChatComposerProps, @@ -85,10 +107,11 @@ export function updateSlashMenu( return; } const cmd = SLASH_COMMANDS.find((entry) => entry.name === cmdName); - if (cmd?.argOptions?.length) { + const argOptions = cmd ? resolveSlashCommandArgOptions(cmd, props) : []; + if (cmd && argOptions.length > 0) { const filtered = argFilter - ? cmd.argOptions.filter((arg) => arg.toLowerCase().startsWith(argFilter)) - : cmd.argOptions; + ? argOptions.filter((arg) => arg.toLowerCase().startsWith(argFilter)) + : argOptions; if (filtered.length > 0) { state.slashMenuMode = "args"; state.slashMenuCommand = cmd; @@ -129,11 +152,12 @@ export function selectSlashCommand( requestUpdate: () => void, ) { const state = getChatComposerState(props.paneId); - if (cmd.argOptions?.length) { + const argOptions = resolveSlashCommandArgOptions(cmd, props); + if (argOptions.length > 0) { commitComposerDraft(props, `/${cmd.name} `); state.slashMenuMode = "args"; state.slashMenuCommand = cmd; - state.slashMenuArgItems = cmd.argOptions; + state.slashMenuArgItems = argOptions; state.slashMenuOpen = true; state.slashMenuIndex = 0; state.slashMenuItems = []; @@ -158,11 +182,12 @@ export function tabCompleteSlashCommand( requestUpdate: () => void, ) { const state = getChatComposerState(props.paneId); - if (cmd.argOptions?.length) { + const argOptions = resolveSlashCommandArgOptions(cmd, props); + if (argOptions.length > 0) { commitComposerDraft(props, `/${cmd.name} `); state.slashMenuMode = "args"; state.slashMenuCommand = cmd; - state.slashMenuArgItems = cmd.argOptions; + state.slashMenuArgItems = argOptions; state.slashMenuOpen = true; state.slashMenuIndex = 0; state.slashMenuItems = []; @@ -367,10 +392,10 @@ export function renderSlashMenu( ${getSlashCommandDescription(cmd)} - ${cmd.argOptions?.length + ${resolveSlashCommandArgOptions(cmd, props).length ? html`${t("chat.commands.optionCount", { - count: String(cmd.argOptions.length), + count: String(resolveSlashCommandArgOptions(cmd, props).length), })}` : nothing} diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index af6fa7f602b1..889d424f086c 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -1,7 +1,7 @@ import type { ProgressCard } from "@openclaw/gateway-protocol"; import type { TemplateResult, nothing } from "lit"; import type { GatewayBrowserClient } from "../../../api/gateway.ts"; -import type { SessionsListResult } from "../../../api/types.ts"; +import type { ModelCatalogEntry, SessionsListResult } from "../../../api/types.ts"; import type { QuestionPrompt } from "../../../app/question-prompt.ts"; import type { ChatSendShortcut } from "../../../app/settings.ts"; import type { ChatQueueItem } from "../../../lib/chat/chat-types.ts"; @@ -92,6 +92,8 @@ export type ChatComposerProps = ChatAttachmentControlsProps & { stream: string | null; queue: ChatQueueItem[]; draft: string; + modelCatalog: readonly ModelCatalogEntry[]; + modelSwitching: boolean; sessions: SessionsListResult | null; toolOverrides?: SessionToolOverrides; capabilityMenu?: CapabilityMenuProps; diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index 648f6dd235c9..7a1eecb65337 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -31,6 +31,7 @@ import { getActiveSlashMenuOptionId, getActiveSlashMenuOptionLabel, isSlashMenuVisible, + resetSlashMenuState, updateSlashMenu, } from "./chat-composer-slash-menu.ts"; import { @@ -513,6 +514,10 @@ export function renderChatComposer(props: ChatComposerProps) { ?.getVideoTracks?.()[0] ?.getSettings?.().facingMode; const mirrorCameraPreview = cameraFacingMode !== "environment"; + if (props.modelSwitching && state.slashMenuCommand?.key === "think") { + state.slashMenuOpen = false; + resetSlashMenuState(state); + } const slashMenuVisible = props.connected && canCompose && isSlashMenuVisible(state); const skillMenuVisible = props.connected && canCompose && isSkillMenuVisible(state); if (!skillMenuVisible && state.skillMenuOpen && !state.skillCommandRefreshPending) {