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/index.css b/crates/agent-gateway/web/src/index.css index 0dbc5c8da..435d5aa80 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; } @@ -1289,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; diff --git a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx index 441ed1443..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,19 +14,14 @@ 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"; import { type AppSettings, getNextTheme, + isAgentDevMode, + isAgentExecutionMode, type ProviderId, type SelectedModel, type Theme, @@ -54,6 +50,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 +67,7 @@ export const ChatHeader = memo(function ChatHeader(props: { selectedValue, sidebarOpen, onSelectModel, + onSelectExecutionMode, onOpenSettings, onToggleTheme, onOpenSidebar, @@ -82,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); @@ -117,110 +118,178 @@ export const ChatHeader = memo(function ChatHeader(props: { ) : null} - - - - - -
- {(() => { - 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/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..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,19 +16,14 @@ 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"; import { type AppSettings, getNextTheme, + isAgentDevMode, + isAgentExecutionMode, type ProviderId, type SelectedModel, type Theme, @@ -56,6 +52,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 +69,7 @@ export const ChatHeader = memo(function ChatHeader(props: { selectedValue, sidebarOpen, onSelectModel, + onSelectExecutionMode, onOpenSettings, onToggleTheme, onOpenSidebar, @@ -84,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); @@ -135,15 +135,15 @@ export const ChatHeader = memo(function ChatHeader(props: { ) : null} - - + } @@ -157,123 +157,195 @@ export const ChatHeader = memo(function ChatHeader(props: { - - -
-
- - 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; - - if (filteredGroups.length === 0) { - return ( -
- {t("chat.noModelFound")} -
- ); - } - - return filteredGroups.map((group, groupIndex) => { - const expanded = isGroupExpanded(group.id); + + + + + {(() => { + const isAgent = isAgentExecutionMode(settings.system.executionMode); + const isDev = isAgentDevMode(settings.system.executionMode); 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} +
+
+ + {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 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} +
+ + +
+
+
+ {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()} + /> +
+
+
+ {(() => { + 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")} +
+ ); + } + + 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\);/); + } +});