From 5e16e5e03c00d205acfa52fcc45a1d052dc62d64 Mon Sep 17 00:00:00 2001 From: 24baigei Date: Tue, 21 Jul 2026 03:42:34 +0800 Subject: [PATCH 1/3] feat(chat): switch execution mode from the model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat/Agent execution mode lived only in Settings, several clicks away. Embed a compact mode segment at the top of the model dropdown on both the desktop ChatHeader and the WebUI fork: Chat | Agent side by side with the active mode highlighted, plus a hint that a switch applies from the next message while the conversation continues. Selecting a mode calls updateSystem on the global setting (idempotent when re-selecting the current mode); the panel stays open so users can adjust model and mode together. agent-dev counts as Agent: the segment shows "Agent·dev" and re-selecting Agent never downgrades it — the dev toggle remains settings-only. The model picker is the natural home because model and mode answer the same question (how the next reply is produced) and the dropdown costs no space on the crowded mobile composer. Co-Authored-By: Claude Fable 5 --- .../agent-gateway/web/src/app/GatewayApp.tsx | 15 ++++++ crates/agent-gateway/web/src/i18n/config.ts | 2 + .../web/src/pages/chat/ChatHeader.tsx | 54 +++++++++++++++++++ crates/agent-gui/src/i18n/config.ts | 2 + crates/agent-gui/src/pages/ChatPage.tsx | 15 ++++++ .../src/pages/chat/components/ChatHeader.tsx | 54 +++++++++++++++++++ 6 files changed, 142 insertions(+) diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 85f7231c5..1ae16d93b 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -93,6 +93,7 @@ import { updateRightDockWidth, updateSkills, updateSshProjectHostIds, + updateSystem, type WorkspaceProject, workspaceProjectPathKey, } from "@/lib/settings"; @@ -4114,6 +4115,20 @@ export default function GatewayApp() { > + setSettings((prev) => { + const current = prev.system.executionMode; + if (mode === "text") { + return current === "text" + ? prev + : updateSystem(prev, { executionMode: "text" }); + } + // 切回 Agent:仅从 Chat 切换;agent-dev 视为 Agent,保持不降级。 + return current === "text" + ? updateSystem(prev, { executionMode: "tools" }) + : prev; + }) + } hasModels={modelOptions.length > 0} currentModelLabel={currentModelLabel} modelOptions={modelOptions} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index c2cc1daac..cb90850c0 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -168,6 +168,7 @@ export const translations: Record> = { "chat.expandProvider": "展开该提供商的模型", "chat.collapseProvider": "收起该提供商的模型", "chat.searchModel": "搜索模型...", + "chat.executionModeHint": "切换对下一条消息生效,当前对话继续", "chat.noModelFound": "未找到匹配的模型", "chat.aborted": "已取消", "chat.contextWindow": "上下文窗口", @@ -2027,6 +2028,7 @@ export const translations: Record> = { "chat.expandProvider": "Expand provider models", "chat.collapseProvider": "Collapse provider models", "chat.searchModel": "Search models...", + "chat.executionModeHint": "Applies from the next message; the conversation continues", "chat.noModelFound": "No matching models found", "chat.aborted": "Cancelled", "chat.contextWindow": "Context Window", diff --git a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx index 441ed1443..17bd6c1c5 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx @@ -26,6 +26,8 @@ import { type ModelOption, parseModelValue } from "../../lib/providers/llm"; import { type AppSettings, getNextTheme, + isAgentDevMode, + isAgentExecutionMode, type ProviderId, type SelectedModel, type Theme, @@ -54,6 +56,9 @@ export const ChatHeader = memo(function ChatHeader(props: { selectedValue?: string; sidebarOpen: boolean; onSelectModel: (selection: SelectedModel) => void; + // 模型下拉内嵌的执行模式分段器:请求切到 Chat("text") 或 Agent("tools")。 + // agent-dev 视为 Agent 的一种,由调用方决定是否保持不降级。 + onSelectExecutionMode: (mode: "text" | "tools") => void; onOpenSettings: (section?: SectionId) => void; onToggleTheme: () => void; onOpenSidebar: () => void; @@ -68,6 +73,7 @@ export const ChatHeader = memo(function ChatHeader(props: { selectedValue, sidebarOpen, onSelectModel, + onSelectExecutionMode, onOpenSettings, onToggleTheme, onOpenSidebar, @@ -147,6 +153,54 @@ export const ChatHeader = memo(function ChatHeader(props: { collisionPadding={8} className="model-selector-dropdown w-[min(18rem,calc(100vw-1rem))] overflow-hidden rounded-xl p-0 text-xs" > + {(() => { + const isAgent = isAgentExecutionMode(settings.system.executionMode); + const isDev = isAgentDevMode(settings.system.executionMode); + return ( +
+
+ + {t("settings.executionMode")} + +
+ + +
+
+
+ {t("chat.executionModeHint")} +
+
+ ); + })()}
{(() => { let animationIndex = 0; diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index af79b6d4f..0a818b356 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -188,6 +188,7 @@ export const translations: Record> = { "chat.branchUnavailable": "旧历史缺少稳定消息标识,无法创建分支", "chat.selectModel": "选择模型", "chat.searchModel": "搜索模型...", + "chat.executionModeHint": "切换对下一条消息生效,当前对话继续", "chat.noModelFound": "未找到匹配的模型", "chat.expandProvider": "展开该提供商的模型", "chat.collapseProvider": "收起该提供商的模型", @@ -2128,6 +2129,7 @@ export const translations: Record> = { "chat.branchUnavailable": "Legacy history lacks stable message ids; cannot branch", "chat.selectModel": "Select Model", "chat.searchModel": "Search models...", + "chat.executionModeHint": "Applies from the next message; the conversation continues", "chat.noModelFound": "No matching models found", "chat.expandProvider": "Expand provider models", "chat.collapseProvider": "Collapse provider models", diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index e3ee3cff5..3bdf22bfe 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -149,6 +149,7 @@ import { updateRightDockWidth, updateSkills, updateSshProjectHostIds, + updateSystem, type WorkspaceProject, workspaceProjectPathKey, } from "../lib/settings"; @@ -5496,6 +5497,20 @@ export function ChatPage(props: ChatPageProps) {
+ setSettings((prev) => { + const current = prev.system.executionMode; + if (mode === "text") { + return current === "text" + ? prev + : updateSystem(prev, { executionMode: "text" }); + } + // 切回 Agent:仅从 Chat 切换;agent-dev 视为 Agent,保持不降级。 + return current === "text" + ? updateSystem(prev, { executionMode: "tools" }) + : prev; + }) + } hasModels={hasModels} currentModelLabel={currentModelLabel} modelOptions={modelOptions} diff --git a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx index 3b18c0232..2bfbb3861 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx @@ -28,6 +28,8 @@ import { type ModelOption, parseModelValue } from "../../../lib/providers/llm"; import { type AppSettings, getNextTheme, + isAgentDevMode, + isAgentExecutionMode, type ProviderId, type SelectedModel, type Theme, @@ -56,6 +58,9 @@ export const ChatHeader = memo(function ChatHeader(props: { selectedValue?: string; sidebarOpen: boolean; onSelectModel: (selection: SelectedModel) => void; + // 模型下拉内嵌的执行模式分段器:请求切到 Chat("text") 或 Agent("tools")。 + // agent-dev 视为 Agent 的一种,由调用方决定是否保持不降级。 + onSelectExecutionMode: (mode: "text" | "tools") => void; onOpenSettings: (section?: SectionId) => void; onToggleTheme: () => void; onOpenSidebar: () => void; @@ -70,6 +75,7 @@ export const ChatHeader = memo(function ChatHeader(props: { selectedValue, sidebarOpen, onSelectModel, + onSelectExecutionMode, onOpenSettings, onToggleTheme, onOpenSidebar, @@ -167,6 +173,54 @@ export const ChatHeader = memo(function ChatHeader(props: { collisionPadding={8} className="model-selector-dropdown w-[min(18rem,calc(100vw-1rem))] overflow-hidden rounded-xl p-0 text-xs" > + {(() => { + const isAgent = isAgentExecutionMode(settings.system.executionMode); + const isDev = isAgentDevMode(settings.system.executionMode); + return ( +
+
+ + {t("settings.executionMode")} + +
+ + +
+
+
+ {t("chat.executionModeHint")} +
+
+ ); + })()}
From ea52e5afdbb7a1c8fc3b823953d5a1221c99a122 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 21 Jul 2026 14:49:18 +0800 Subject: [PATCH 2/3] fix(chat): make model picker keyboard accessible --- crates/agent-gateway/web/src/index.css | 47 +-- .../web/src/pages/chat/ChatHeader.tsx | 325 ++++++++-------- .../src/pages/chat/components/ChatHeader.tsx | 364 +++++++++--------- .../chat/execution-mode-model-picker.test.mjs | 45 +++ 4 files changed, 422 insertions(+), 359 deletions(-) create mode 100644 crates/agent-gui/test/chat/execution-mode-model-picker.test.mjs diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index 0dbc5c8da..0bf3d9354 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -1217,52 +1217,37 @@ } /* Chat header model selector: grow out of the trigger with a soft slide. - * Radix keeps the popup mounted while the data-state="closed" animation - * plays, so exit stays keyframe-driven; `forwards` holds the final frame - * until unmount. */ + * Base UI drives the popup lifecycle, so enter/exit are keyed off + * data-starting-style / data-ending-style and the transition delays + * unmount. */ .model-selector-dropdown { /* Slide from the anchor side; flipped placement overrides per data-side. */ --model-menu-slide-y: -6px; - transform-origin: var(--radix-dropdown-menu-content-transform-origin, top left); + transform-origin: var(--transform-origin); + transition: + opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.18s cubic-bezier(0.16, 1, 0.3, 1); } .model-selector-dropdown[data-side="top"] { --model-menu-slide-y: 6px; } - .model-selector-dropdown[data-state="open"] { - animation: modelSelectorDropdownIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); - } - - .model-selector-dropdown[data-state="closed"] { - animation: modelSelectorDropdownOut 0.12s cubic-bezier(0.4, 0, 1, 1) forwards; - } - - @keyframes modelSelectorDropdownIn { - from { - opacity: 0; - transform: translateY(var(--model-menu-slide-y)) scale(0.96); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } + .model-selector-dropdown[data-starting-style], + .model-selector-dropdown[data-ending-style] { + opacity: 0; + transform: translateY(var(--model-menu-slide-y)) scale(0.96); } - @keyframes modelSelectorDropdownOut { - from { - opacity: 1; - transform: translateY(0) scale(1); - } - to { - opacity: 0; - transform: translateY(calc(var(--model-menu-slide-y) / 2)) scale(0.98); - } + .model-selector-dropdown[data-ending-style] { + transition-duration: 0.12s; + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); + transform: translateY(calc(var(--model-menu-slide-y) / 2)) scale(0.98); } /* `backwards` (not `both`): a filled forwards animation would keep holding * transform after it ends and suppress the item highlight translate. */ - .model-selector-dropdown[data-state="open"] .model-selector-item { + .model-selector-dropdown[data-open] .model-selector-item { animation: modelSelectorItemIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) backwards; } diff --git a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx index 17bd6c1c5..6ccfbef30 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx @@ -1,4 +1,5 @@ -import { memo, type ReactNode, useEffect, useMemo, useState } from "react"; +import { Popover } from "@base-ui/react"; +import { memo, type ReactNode, useEffect, useId, useMemo, useState } from "react"; import { Check, ChevronDown, @@ -13,13 +14,6 @@ import { } from "../../components/icons"; import { Button } from "../../components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../components/ui/dropdown-menu"; import { useLocale } from "../../i18n"; import { groupModelOptionsByProvider } from "../../lib/chat/chatPageHelpers"; import { type ModelOption, parseModelValue } from "../../lib/providers/llm"; @@ -88,14 +82,15 @@ export const ChatHeader = memo(function ChatHeader(props: { : nextTheme === "dark" ? t("tooltip.switchToDark") : t("tooltip.switchToAuto"); - const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const [isModelPickerOpen, setIsModelPickerOpen] = useState(false); const [expandedGroups, setExpandedGroups] = useState>({}); + const executionModeRadioName = useId(); useEffect(() => { - if (isModelMenuOpen) { + if (isModelPickerOpen) { setExpandedGroups({}); } - }, [isModelMenuOpen]); + }, [isModelPickerOpen]); const groups = useMemo(() => groupModelOptionsByProvider(modelOptions), [modelOptions]); const selectedOption = modelOptions.find((option) => option.value === selectedValue); @@ -123,158 +118,178 @@ export const ChatHeader = memo(function ChatHeader(props: { ) : null} - - - - - - {(() => { - const isAgent = isAgentExecutionMode(settings.system.executionMode); - const isDev = isAgentDevMode(settings.system.executionMode); - return ( -
-
- - {t("settings.executionMode")} - -
- - -
-
-
- {t("chat.executionModeHint")} -
-
- ); - })()} -
- {(() => { - let animationIndex = 0; - return groups.map((group, groupIndex) => { - const expanded = isGroupExpanded(group.id); + + {selectedOption ? ( + + ) : null} + {currentModelLabel} + + + + + + + {(() => { + const isAgent = isAgentExecutionMode(settings.system.executionMode); + const isDev = isAgentDevMode(settings.system.executionMode); return ( -
- {groupIndex > 0 ? : null} - { - // 阻止 Radix 默认的选中即关闭:分组头只负责展开/收起 - event.preventDefault(); - toggleGroup(group.id); - }} - aria-expanded={expanded} - title={expanded ? t("chat.collapseProvider") : t("chat.expandProvider")} - className="model-selector-group-label sticky top-0 z-10 flex h-[30px] shrink-0 cursor-pointer items-center gap-1.5 bg-popover/95 px-2 py-0 text-xs font-medium text-muted-foreground backdrop-blur transition-colors supports-[backdrop-filter]:bg-popover/80 dark:text-white/80" - > - - {group.name} - - {group.opts.length} +
+
+ + {t("settings.executionMode")} - - - {expanded - ? group.opts.map((option) => { - const isSelected = option.value === selectedValue; - const itemAnimationDelay = `${Math.min(animationIndex, 5) * 0.025}s`; - animationIndex += 1; - return ( - { - const parsed = parseModelValue(option.value); - if (!parsed) return; - onSelectModel(parsed); - }} - className={cn( - "model-selector-item h-[30px] max-w-full shrink-0 justify-between gap-3 overflow-hidden py-0 text-xs font-normal leading-5 text-foreground transition-none focus:bg-foreground/[0.05] dark:text-white", - isSelected && - "bg-foreground/[0.07] font-medium text-foreground focus:bg-foreground/[0.09]", - )} - style={{ animationDelay: itemAnimationDelay }} - > - - - {option.model} - - {isSelected ? ( - - ) : null} - - ); - }) - : null} +
+ + +
+
+
+ {t("chat.executionModeHint")} +
); - }); - })()} -
- - + })()} +
+ {(() => { + let animationIndex = 0; + return groups.map((group, groupIndex) => { + const expanded = isGroupExpanded(group.id); + return ( +
+ {groupIndex > 0 ?
: null} + + {expanded + ? group.opts.map((option) => { + const isSelected = option.value === selectedValue; + const itemAnimationDelay = `${Math.min(animationIndex, 5) * 0.025}s`; + animationIndex += 1; + return ( + + ); + }) + : null} +
+ ); + }); + })()} +
+
+
+
+
diff --git a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx index 2bfbb3861..9449dcdd4 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx @@ -1,4 +1,5 @@ -import { memo, type ReactNode, useEffect, useRef, useState } from "react"; +import { Popover } from "@base-ui/react"; +import { memo, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { Check, ChevronDown, @@ -15,13 +16,6 @@ import { import { isMacOsTauri } from "../../../components/MacOsTitleBarSpacer"; import { Button } from "../../../components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../components/ui/dropdown-menu"; import { useLocale } from "../../../i18n"; import { groupModelOptionsByProvider } from "../../../lib/chat/page/chatPageHelpers"; import { type ModelOption, parseModelValue } from "../../../lib/providers/llm"; @@ -90,19 +84,19 @@ export const ChatHeader = memo(function ChatHeader(props: { : nextTheme === "dark" ? t("tooltip.switchToDark") : t("tooltip.switchToAuto"); - const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const [isModelPickerOpen, setIsModelPickerOpen] = useState(false); const [modelSearch, setModelSearch] = useState(""); const [expandedGroups, setExpandedGroups] = useState>({}); const searchInputRef = useRef(null); + const executionModeRadioName = useId(); const macOsTauri = isMacOsTauri(); useEffect(() => { - if (isModelMenuOpen) { + if (isModelPickerOpen) { setModelSearch(""); setExpandedGroups({}); - setTimeout(() => searchInputRef.current?.focus(), 0); } - }, [isModelMenuOpen]); + }, [isModelPickerOpen]); const normalizedSearch = modelSearch.trim().toLowerCase(); const groups = groupModelOptionsByProvider(modelOptions); @@ -141,15 +135,15 @@ export const ChatHeader = memo(function ChatHeader(props: { ) : null} - - + } @@ -163,171 +157,195 @@ export const ChatHeader = memo(function ChatHeader(props: { - - - {(() => { - const isAgent = isAgentExecutionMode(settings.system.executionMode); - const isDev = isAgentDevMode(settings.system.executionMode); - return ( -
-
- - {t("settings.executionMode")} - -
- - + + + + + {(() => { + const isAgent = isAgentExecutionMode(settings.system.executionMode); + const isDev = isAgentDevMode(settings.system.executionMode); + return ( +
+
+ + {t("settings.executionMode")} + +
+ + +
+
+
+ {t("chat.executionModeHint")} +
-
-
- {t("chat.executionModeHint")} + ); + })()} +
+
+ + setModelSearch(e.target.value)} + placeholder={t("chat.searchModel")} + className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground/60" + onKeyDown={(e) => e.stopPropagation()} + />
- ); - })()} -
-
- - setModelSearch(e.target.value)} - placeholder={t("chat.searchModel")} - className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground/60" - onKeyDown={(e) => e.stopPropagation()} - /> -
-
-
- {(() => { - let animationIndex = 0; - const filteredGroups = normalizedSearch - ? groups - .map((group) => ({ - ...group, - opts: group.opts.filter( - (o) => - o.model.toLowerCase().includes(normalizedSearch) || - o.providerName.toLowerCase().includes(normalizedSearch), - ), - })) - .filter((g) => g.opts.length > 0) - : groups; +
+ {(() => { + let animationIndex = 0; + const filteredGroups = normalizedSearch + ? groups + .map((group) => ({ + ...group, + opts: group.opts.filter( + (o) => + o.model.toLowerCase().includes(normalizedSearch) || + o.providerName.toLowerCase().includes(normalizedSearch), + ), + })) + .filter((g) => g.opts.length > 0) + : groups; - if (filteredGroups.length === 0) { - return ( -
- {t("chat.noModelFound")} -
- ); - } + if (filteredGroups.length === 0) { + return ( +
+ {t("chat.noModelFound")} +
+ ); + } - return filteredGroups.map((group, groupIndex) => { - const expanded = isGroupExpanded(group.id); - return ( -
- {groupIndex > 0 ? : null} - toggleGroup(group.id)} - aria-expanded={expanded} - title={expanded ? t("chat.collapseProvider") : t("chat.expandProvider")} - className="model-selector-group-label sticky top-0 z-10 flex h-[30px] shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-popover/60 px-2 py-0 text-xs font-medium uppercase tracking-wider text-muted-foreground/80 backdrop-blur-xl transition-colors data-[highlighted]:bg-muted/40 supports-[backdrop-filter]:bg-popover/40 dark:text-white/80" - > - - - {group.name} - - - {group.opts.length} - - - - {expanded - ? group.opts.map((option) => { - const isSelected = option.value === selectedValue; - const itemAnimationDelay = `${Math.min(animationIndex, 5) * 0.025}s`; - animationIndex += 1; - return ( - { - const parsed = parseModelValue(option.value); - if (!parsed) return; - onSelectModel(parsed); - }} - className={cn( - "model-selector-item h-[30px] max-w-full shrink-0 justify-between gap-3 overflow-hidden rounded-md py-0 text-xs font-normal leading-5 text-foreground transition-none data-[highlighted]:bg-foreground/[0.05] dark:text-white", - isSelected && - "bg-foreground/[0.07] font-medium text-foreground data-[highlighted]:bg-foreground/[0.09]", - )} - style={{ animationDelay: itemAnimationDelay }} - > - - - {option.model} - - {isSelected ? ( - - ) : null} - - ); - }) - : null} -
- ); - }); - })()} -
- - + return filteredGroups.map((group, groupIndex) => { + const expanded = isGroupExpanded(group.id); + return ( +
+ {groupIndex > 0 ? ( +
+ ) : null} + + {expanded + ? group.opts.map((option) => { + const isSelected = option.value === selectedValue; + const itemAnimationDelay = `${Math.min(animationIndex, 5) * 0.025}s`; + animationIndex += 1; + return ( + + ); + }) + : null} +
+ ); + }); + })()} +
+ + + +
diff --git a/crates/agent-gui/test/chat/execution-mode-model-picker.test.mjs b/crates/agent-gui/test/chat/execution-mode-model-picker.test.mjs new file mode 100644 index 000000000..87bbb23a8 --- /dev/null +++ b/crates/agent-gui/test/chat/execution-mode-model-picker.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const headerSources = [ + readFileSync( + new URL("../../src/pages/chat/components/ChatHeader.tsx", import.meta.url), + "utf8", + ), + readFileSync( + new URL("../../../agent-gateway/web/src/pages/chat/ChatHeader.tsx", import.meta.url), + "utf8", + ), +]; + +test("model pickers use popover semantics instead of menu semantics", () => { + for (const source of headerSources) { + assert.match(source, /import \{ Popover \} from "@base-ui\/react"/); + assert.match(source, / { + for (const source of headerSources) { + assert.match(source, /role="radiogroup"/); + assert.match(source, /aria-label=\{t\("settings\.executionMode"\)\}/); + assert.equal((source.match(/type="radio"/g) ?? []).length, 2); + assert.match(source, /checked=\{!isAgent\}/); + assert.match(source, /checked=\{isAgent\}/); + assert.match(source, /onChange=\{\(\) => onSelectExecutionMode\("text"\)\}/); + assert.match(source, /onChange=\{\(\) => onSelectExecutionMode\("tools"\)\}/); + assert.match(source, /has-\[:focus-visible\]:ring-2/); + } +}); + +test("popover interactions preserve mode changes and close after model selection", () => { + for (const source of headerSources) { + assert.match(source, /onClick=\{\(\) => toggleGroup\(group\.id\)\}/); + assert.match(source, /aria-pressed=\{isSelected\}/); + assert.match(source, /onSelectModel\(parsed\);\s+setIsModelPickerOpen\(false\);/); + } +}); From f64f6cd39f5f9ef12e2b967ee449318940075a48 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 21 Jul 2026 15:24:50 +0800 Subject: [PATCH 3/3] fix(chat): honor reduced motion for the web model picker transitions The Base UI migration moved the model picker's enter/exit from keyframe animations to transitions, but the prefers-reduced-motion block only disabled animations. Mirror the desktop CSS by adding .model-selector-dropdown to the transition: none group. --- crates/agent-gateway/web/src/index.css | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index 0bf3d9354..435d5aa80 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -1274,6 +1274,7 @@ .composer-reasoning-trigger, .composer-reasoning-trigger > svg:last-child, + .model-selector-dropdown, .model-selector-trigger, .model-selector-trigger svg { transition: none !important;