diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index 1e21892aec..49f3d6bb32 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -42,4 +42,10 @@ pnpm run design-system:check Run `pnpm run design-system:check` for cross-package or release-boundary changes. Token and theme changes must also run the repository-level `pnpm run theme:color-audit:all`. +For shared icon rendering or geometry changes, use the focused package checks: + +```bash +pnpm --dir design-system run build:ui && node --test design-system/packages/ui/tests/icon.test.mjs design-system/packages/ui/tests/icon-slots.test.mjs +``` + Do not use browser automation or mock screenshots as visual proof. Design Lab is the manual authoring surface; source, build, HTTP, and package checks do not establish final visual fidelity. diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index 3c3fd7c1f7..e143ff2acc 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -163,7 +163,8 @@ export const enUSMessages = { "component.GitToolCard.description": "A prominent Git command card with output, warning, failure, and execution metadata.", "component.GlobSearchToolCard.description": "An ambient file-pattern search card with reusable result-list anatomy.", "component.GrepSearchToolCard.description": "An ambient text-search card with query metadata and a scrollable result preview.", - "component.IconButton.description": "A labeled icon-only action with annotation, quiet, outlined, filled, and primary variants. An annotation provides a small, muted entry to supplemental help.", + "component.IconButton.description": "A labeled icon action with annotation, quiet, glass, outlined, filled, and primary variants. Glass actions float over intact content.", + "component.Capsule.description": "A capsule with a circular glass action floating over intact text and equal edge spacing.", "component.Input.description": "A native text field with independent leading and trailing content slots.", "component.KeyHint.description": "A semantic keyboard hint with optional decorative icon content.", "component.LoadingState.description": "A centered loading composition with an activity indicator and optional support copy.", @@ -186,7 +187,7 @@ export const enUSMessages = { "component.SkillToolCard.description": "An ambient trace for skill loading progress and outcomes.", "component.Spinner.description": "A compact activity indicator with matrix and bar presentations.", "component.Switch.description": "Toggles a single setting between on and off with native checkbox semantics.", - "component.TabGroup.description": "A compact single-selection tab list with optional leading icons, end actions, and automatic keyboard activation.", + "component.TabGroup.description": "A compact single-selection tab list with leading icons, glass end actions over intact labels, and automatic keyboard activation.", "component.RollingText.description": "Vertical text replacement with coordinated width, interruption handling, and reduced-motion support. Try repeated clicks in the replacement specimen.", "component.ShimmerText.description": "A soft fade moves slowly across activity text, keeping every glyph visible. Reduced motion, high contrast and print keep the text static and readable.", "components.preview.replaceText": "Replace title", @@ -557,6 +558,7 @@ export const enUSMessages = { "detail.option.primary": "Primary", "detail.option.quiet": "Quiet", "detail.option.annotation": "Annotation", + "detail.option.glass": "Glass", "detail.option.text": "Text", "detail.option.sm": "Small", "detail.option.start": "Start", @@ -969,7 +971,8 @@ export const zhCNMessages = { "component.GitToolCard.description": "关注型 Git 命令卡片,统一呈现输出、警告、失败与执行元数据。", "component.GlobSearchToolCard.description": "复用结果列表结构的低关注文件模式搜索卡片。", "component.GrepSearchToolCard.description": "带查询元数据和可滚动结果预览的低关注文本搜索卡片。", - "component.IconButton.description": "带无障碍标签的纯图标组件,支持标注、安静、描边、填充和主要呈现方式。标注以小尺寸、弱化颜色提供补充说明入口。", + "component.IconButton.description": "带无障碍标签的纯图标组件,支持标注、安静、毛玻璃、描边、填充和主要呈现方式。毛玻璃操作悬浮在完整内容上方。", + "component.Capsule.description": "胶囊内容保持完整,圆形毛玻璃操作悬浮其上,边缘间距一致。", "component.Input.description": "使用原生输入语义,并提供独立前置与后置内容槽的文本字段。", "component.KeyHint.description": "使用键盘提示语义,并支持可选装饰图标的基础组件。", "component.LoadingState.description": "由活动指示器与可选说明文字组成的居中加载状态。", @@ -992,7 +995,7 @@ export const zhCNMessages = { "component.SkillToolCard.description": "低关注 Skill 轨迹,用于呈现加载进度和执行结果。", "component.Spinner.description": "提供点阵与条形样式的紧凑活动指示器。", "component.Switch.description": "通过原生复选框语义在开启与关闭之间切换单项设置。", - "component.TabGroup.description": "紧凑的单选标签组,支持前置图标、尾部操作与方向键自动切换。", + "component.TabGroup.description": "紧凑的单选标签组,支持前置图标、悬浮于完整标签上的毛玻璃尾部操作与方向键自动切换。", "component.RollingText.description": "垂直滚动替换文本,宽度同步过渡,支持连续切换与减少动态效果。可在替换示例中连续点击体验。", "component.ShimmerText.description": "柔和的渐隐在运行文字间缓慢流动,始终保留完整字形;减少动态效果、高对比度和打印时保持静态可读。", "components.preview.replaceText": "替换标题", @@ -1363,6 +1366,7 @@ export const zhCNMessages = { "detail.option.primary": "主要", "detail.option.quiet": "安静", "detail.option.annotation": "标注", + "detail.option.glass": "毛玻璃", "detail.option.text": "文字", "detail.option.sm": "小", "detail.option.start": "起始", @@ -1772,7 +1776,8 @@ export const zhTWMessages = { "meta.category.primitive": "基礎元件", "meta.category.other": "元件", "component.ActionCard.description": "可獨立設定前置內容、標題、說明、選取狀態和同級尾端操作的可互動卡片。", - "component.IconButton.description": "帶無障礙標籤的純圖示元件,支援標註、安靜、描邊、填滿和主要呈現方式。標註以小尺寸、弱化顏色提供補充說明入口。", + "component.IconButton.description": "帶無障礙標籤的純圖示元件,支援標註、安靜、毛玻璃、描邊、填滿和主要呈現方式。毛玻璃操作懸浮在完整內容上方。", + "component.Capsule.description": "膠囊內容保持完整,圓形毛玻璃操作懸浮其上,邊緣間距一致。", "component.MobileActionSheet.description": "用於統一呈現一般和危險操作清單的可關閉行動端彈層。", "component.MobileBadge.description": "用於狀態、身分與中繼資料的緊湊行動端標籤。", "component.MobileBanner.description": "支援語義色調和可選操作的行動端行內狀態橫幅。", @@ -1839,7 +1844,7 @@ export const zhTWMessages = { "component.SkillToolCard.description": "低關注 Skill 軌跡,用於呈現載入進度和執行結果。", "component.Spinner.description": "提供點陣與條形樣式的緊湊活動指示器。", "component.Switch.description": "以原生核取方塊語意在開啟與關閉之間切換單一設定。", - "component.TabGroup.description": "緊湊的單選標籤群組,支援前置圖示、尾端操作與方向鍵自動切換。", + "component.TabGroup.description": "緊湊的單選標籤群組,支援前置圖示、懸浮於完整標籤上的毛玻璃尾端操作與方向鍵自動切換。", "component.RollingText.description": "垂直捲動替換文字,寬度同步過渡,支援連續切換與減少動態效果。可在替換範例中連續點擊體驗。", "component.ShimmerText.description": "柔和的漸隱在執行文字間緩慢流動,始終保留完整字形;減少動態效果、高對比度和列印時保持靜態可讀。", "components.preview.replaceText": "替換標題", @@ -2113,6 +2118,7 @@ export const zhTWMessages = { "detail.iconPosition": "圖示位置", "detail.option.outline": "描邊", "detail.option.annotation": "標註", + "detail.option.glass": "毛玻璃", "detail.option.fill": "填滿", "detail.option.sm": "小", "detail.option.start": "起始", diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index f9aec4b64b..e041290432 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -1,3 +1,4 @@ +import { CapsulePreview } from "../preview/CapsulePreview"; import { Fragment, useEffect, useMemo, useState, type CSSProperties } from "react"; import { ComponentOverview } from "../preview/ComponentOverview"; import { getComponentPresentation, getInitialPreviewState, hasComponentOverview } from "../preview/componentPresentation"; @@ -163,7 +164,7 @@ type PageHeaderAlign = "center" | "start"; type PageHeaderSize = "display" | "lg" | "md" | "sm"; const buttonVariants = ["outline", "fill", "secondary", "primary", "text"] as const; -const iconButtonVariants = ["annotation", "quiet", "outline", "fill", "primary"] as const; +const iconButtonVariants = ["annotation", "quiet", "glass", "outline", "fill", "primary"] as const; const iconButtonSizes = ["xs", "standard", "sm", "md", "lg"] as const; const buttonInspectorStates = ["default", "hover", "active"] as const; const fieldOrientations = ["vertical", "horizontal"] as const; @@ -231,6 +232,7 @@ const optionLabelKeys: Readonly> = { open: "detail.option.open", outline: "detail.option.outline", annotation: "detail.option.annotation", + glass: "detail.option.glass", primary: "detail.option.primary", quiet: "detail.option.quiet", raised: "detail.option.raised", @@ -537,6 +539,9 @@ export function ComponentDetailPage({ return `import { Icon } from "@openbitfun/ui";\n\n`; } + if (component.name === "Capsule") { + return `import { Button, Capsule, Icon, IconButton } from "@openbitfun/ui";\n\n} onClick={remove} />\n}>\n \n`; + } if (component.name === "IconButton") { const stateProps = `${inspectorDisabled ? " disabled" : ""}${inspectorLoading ? " loading" : ""}`; const annotation = iconButtonVariant === "annotation"; @@ -904,6 +909,7 @@ export function ComponentDetailPage({ ); } + if (component.name === "Capsule") return ; if (component.name === "Icon") { return ; } diff --git a/design-system/apps/design-lab/src/pages/ComponentsPage.tsx b/design-system/apps/design-lab/src/pages/ComponentsPage.tsx index 77b6dbcc40..41e187d0b5 100644 --- a/design-system/apps/design-lab/src/pages/ComponentsPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { ComponentDetailPage } from "./ComponentDetailPage"; import { BrandPreview } from "../preview/BrandPreview"; +import { CapsulePreview } from "../preview/CapsulePreview"; import { AppWindow, Heading, Keyboard, List, Rows3, MousePointerClick, PanelTop, ToggleLeft } from "lucide-react"; import { VoiceCallPreview, VoiceParticlePreview } from "../components/VoiceCallPreview"; import { @@ -607,6 +608,8 @@ export function ComponentCardPreview({ component }: { component: ComponentMeta } shortcut={} /> ); + case "Capsule": + return ; case "StatusPill": return ( }> diff --git a/design-system/apps/design-lab/src/preview/CapsulePreview.tsx b/design-system/apps/design-lab/src/preview/CapsulePreview.tsx new file mode 100644 index 0000000000..18844261dd --- /dev/null +++ b/design-system/apps/design-lab/src/preview/CapsulePreview.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; +import { Button, Capsule, Icon, IconButton, Stack, Tooltip } from "@openbitfun/ui"; +import { useI18n } from "../i18n"; + +export function CapsulePreview({ state = "default" }: { state?: string }) { + const { t } = useI18n(); + const [removed, setRemoved] = useState([]); + const disabled = state === "disabled"; + return + {(["xs", "sm"] as const).filter(size => !removed.includes(size)).map(size => ( + + } + disabled={disabled} onClick={() => setRemoved(current => [...current, size])} /> + + )}> + + + ))} + {removed.length > 0 && } + ; +} diff --git a/design-system/apps/design-lab/src/preview/FlowChatMockTools.tsx b/design-system/apps/design-lab/src/preview/FlowChatMockTools.tsx index 153e2d4a56..29c362a661 100644 --- a/design-system/apps/design-lab/src/preview/FlowChatMockTools.tsx +++ b/design-system/apps/design-lab/src/preview/FlowChatMockTools.tsx @@ -9,14 +9,14 @@ export type MockToolScope = 'all' | ToolGroup; // Only grouping is Lab-owned. Names and component bindings come from production. const groupForTool = { - get_goal: 'collaboration', create_goal: 'collaboration', update_goal: 'collaboration', AgentList: 'collaboration', AgentDelete: 'collaboration', + get_goal: 'collaboration', create_goal: 'collaboration', update_goal: 'collaboration', AgentList: 'collaboration', SessionHistory: 'collaboration', analyze_image: 'files', GetTime: 'execution', ListMCPResources: 'execution', ReadMCPResource: 'execution', ListMCPPrompts: 'execution', GetMCPPrompt: 'execution', Worktree: 'files', PortForward: 'execution', ReviewPlatform: 'collaboration', FrontendWorkbench: 'web', FinalizeMiniApp: 'web', PublishMiniApp: 'web', PublishAppearance: 'web', Playbook: 'execution', Read: 'files', Write: 'files', Edit: 'files', Delete: 'files', Grep: 'files', Glob: 'files', LS: 'files', GetFileDiff: 'files', view_image: 'files', - ListModels: 'execution', ControlHub: 'web', AgentInterrupt: 'collaboration', + ListModels: 'execution', ControlHub: 'web', RunCode: 'execution', ExecCommand: 'execution', WriteStdin: 'execution', ExecControl: 'execution', Cron: 'execution', GetToolSpec: 'execution', Skill: 'execution', WebSearch: 'web', WebFetch: 'web', PageDeploy: 'web', PagePublish: 'web', AgentSpawn: 'collaboration', AgentSendInput: 'collaboration', AgentWait: 'collaboration', TodoWrite: 'collaboration', diff --git a/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx b/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx index 23cd63c772..c76860ea89 100644 --- a/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx +++ b/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx @@ -1,4 +1,4 @@ -import { LayersPlusIcon } from "@openbitfun/ui"; +import { Icon, LayersPlusIcon } from "@openbitfun/ui"; import { toolsForComponent } from '@openbitfun/flow-chat-presentation/registry'; import { semanticToolCardPreviews } from './SemanticToolCardPreview'; import { createScenarioClock, execScenarios, execSnapshot, type ExecToolName } from '@openbitfun/flow-chat-presentation/scenarios'; @@ -35,7 +35,6 @@ import { Monitor, MousePointer, Plus, - Pencil, Rocket, Search, SearchCheck, @@ -257,7 +256,7 @@ function ChatComposerPreview({ interactive, state }: PreviewProps) { /> } + icon={} size="xs" /> diff --git a/design-system/apps/design-lab/src/preview/componentPresentation.ts b/design-system/apps/design-lab/src/preview/componentPresentation.ts index 87fa3537a5..55041f0071 100644 --- a/design-system/apps/design-lab/src/preview/componentPresentation.ts +++ b/design-system/apps/design-lab/src/preview/componentPresentation.ts @@ -7,7 +7,7 @@ export type ComponentPresentation = // These are website presentation choices. The public registry still owns the // component inventory, properties and supported states. const presentations: Record = { - Button: "matrix", IconButton: "matrix", + Button: "matrix", IconButton: "matrix", Capsule: "compact", Avatar: "compact", Checkbox: "compact", Radio: "compact", Switch: "compact", StatusPill: "compact", Spinner: "compact", LoadingState: "compact", LauncherButton: "compact", NumberBadge: "compact", KeyHint: "compact", diff --git a/design-system/apps/design-lab/vite/component-detail-contract.test.mjs b/design-system/apps/design-lab/vite/component-detail-contract.test.mjs index 31108cde29..062fc4b94c 100644 --- a/design-system/apps/design-lab/vite/component-detail-contract.test.mjs +++ b/design-system/apps/design-lab/vite/component-detail-contract.test.mjs @@ -201,7 +201,7 @@ test("IconButton preview exposes its icon-only presentation contract", async () assert.ok(declaration); assert.deepEqual( [...declaration[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]), - ["annotation", "quiet", "outline", "fill", "primary"], + ["annotation", "quiet", "glass", "outline", "fill", "primary"], ); assert.match(source, /data-component="icon-button"/); assert.match(source, /aria-label=\{t\(annotation \? "components\.preview\.fieldHelp" : "components\.preview\.listView"\)\}/); diff --git a/design-system/apps/design-lab/vite/component-preview-render.test.mjs b/design-system/apps/design-lab/vite/component-preview-render.test.mjs index 4ee8eb342f..578a9bffe3 100644 --- a/design-system/apps/design-lab/vite/component-preview-render.test.mjs +++ b/design-system/apps/design-lab/vite/component-preview-render.test.mjs @@ -75,6 +75,16 @@ test("color and pattern directories expose one labelled panel while retaining ot } }); +test("Capsule previews publish the glass action composition in every supported state", () => { + const { html, preview } = render("Capsule"); + assert.match(preview, /data-openbitfun-component="capsule"/); + assert.match(preview, /data-openbitfun-variant="glass"/); + assert.match(preview, /data-openbitfun-shape="circle"/); + assert.match(preview, /data-openbitfun-preview-state="disabled"/); + assert.match(html, /import \{ Button, Capsule, Icon, IconButton \}/); + assert.doesNotMatch(html, />component\.Capsule\.description { const { html, preview } = render("Textarea"); assert.equal((preview.match(/data-openbitfun-component="textarea"/g) ?? []).length, 5); diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index b4f7593d3c..e27c81804c 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -133,7 +133,9 @@ by the component that needs them. ## Icons Use `Icon` for shared size, tone and accessible labels. General-purpose symbols -use Lucide. Automatic reasoning uses the authored `reasoning-auto` SVG mark with +use Lucide. The named `edit` icon uses the simple `Pen` outline without Pencil's +cap divider, keeping compact action groups visually light. +Automatic reasoning uses the authored `reasoning-auto` SVG mark with a `currentColor` mask so it follows the active theme. Manual reasoning levels use the product slider and text labels. Product consumers own capability filtering, translated names and @@ -392,6 +394,41 @@ focus ring. Use it for toolbar, dialog, and row utilities. `fill` and `primary` keep an opaque backing surface for persistent emphasis. Disabled quiet actions remain transparent and do not show hover or pressed feedback. +`IconButton variant="glass"` is a translucent, blurred action over existing +content. It uses a low-opacity tint of the shared raised surface, a softened +subtle border and subtle blur, without an outer shadow. Hover and pressed states +retain translucency. Unsupported blur, increased contrast and reduced transparency +use a solid surface. +The button itself owns the glass material and its reveal transition so an opaque +backing or fading parent cannot prevent it from sampling the content behind it. + +### Capsule actions + +Wrap a pill-shaped `Button`, inline `Input`, or tag label in `Capsule`, matching +its `size` to the child. Pass one `IconButton` (optionally inside `Tooltip`) to +`action`. The slot selects circular glass presentation and compact geometry; +the consumer keeps the action's accessible label, tone, disabled/loading state, +and callbacks. The action is a sibling of the primary control, never a nested +button. Pointer hover or focus within the capsule reveals it; touch keeps it +available. Its right inset equals the top and bottom clearance at every density. + +```tsx +} + tone="danger" onClick={requestRemoval} /> +}> + + +``` + +The action floats over the intact label: do not clip, mask, hide, resize, or add +action padding to its text when revealing the icon. Normal constrained-width +`OverflowText` behavior remains available. `TabGroup.endAction` and removable +`MultiSelect` tags share this contract, including existing native tab buttons. +Leading identity icons stay in their label layout; this contract governs the +independent trailing actions. Product CSS owns placement of capsules, not their +action offsets, backgrounds, or visibility. + For supplemental label annotations, compose `Tooltip trigger="hover-focus" openOnClick` with `IconButton variant="annotation" size="xs"` and an `info` icon. The annotation uses `color.content.muted` at rest and diff --git a/design-system/packages/ui/src/components/Capsule/Capsule.meta.ts b/design-system/packages/ui/src/components/Capsule/Capsule.meta.ts new file mode 100644 index 0000000000..10cd8e4d7d --- /dev/null +++ b/design-system/packages/ui/src/components/Capsule/Capsule.meta.ts @@ -0,0 +1,15 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const capsuleMeta = { + category: "action", + description: "A capsule composition with a circular glass action floating over its unmodified label. Pointer hover and keyboard focus reveal the action; touch keeps it available.", + maturity: "stable", + name: "Capsule", + props: [ + { name: "children", type: "ReactNode" }, + { name: "action", type: "ReactNode" }, + { name: "size", type: "xs | sm | md | lg", defaultValue: "sm" }, + ], + states: ["default", "hover", "focus-visible", "disabled"], + tokens: ["control.button.xsHeight", "control.height.sm", "control.height.md", "control.height.lg", "control.iconButton.xsSize", "color.surface.raised", "color.border.subtle", "effect.blur.subtle", "radius.pill"], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Capsule/Capsule.module.css b/design-system/packages/ui/src/components/Capsule/Capsule.module.css new file mode 100644 index 0000000000..496a721189 --- /dev/null +++ b/design-system/packages/ui/src/components/Capsule/Capsule.module.css @@ -0,0 +1,14 @@ +@layer openbitfun.components { + .root { + --_floating-action-host-height: var(--openbitfun-control-height-sm); + display: inline-flex; + flex: 0 1 auto; + align-items: center; + min-inline-size: 0; + max-inline-size: 100%; + } + + .root[data-size="xs"] { --_floating-action-host-height: var(--openbitfun-control-button-xs-height); } + .root[data-size="md"] { --_floating-action-host-height: var(--openbitfun-control-height-md); } + .root[data-size="lg"] { --_floating-action-host-height: var(--openbitfun-control-height-lg); } +} diff --git a/design-system/packages/ui/src/components/Capsule/Capsule.tsx b/design-system/packages/ui/src/components/Capsule/Capsule.tsx new file mode 100644 index 0000000000..fe957e7a63 --- /dev/null +++ b/design-system/packages/ui/src/components/Capsule/Capsule.tsx @@ -0,0 +1,29 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; +import { classNames } from "../../internal/classNames"; +import { FloatingAction } from "../../internal/FloatingAction"; +import actionStyles from "../../internal/FloatingAction.module.css"; +import styles from "./Capsule.module.css"; + +export interface CapsuleProps extends HTMLAttributes { + /** One IconButton (optionally wrapped in Tooltip), rendered beside the content. */ + action?: ReactNode; + /** Match the size of the child Button, Input, or label surface. */ + size?: "xs" | "sm" | "md" | "lg"; +} + +/** Keeps label layout intact beneath a circular glass action with equal edge gaps. */ +export const Capsule = forwardRef(function Capsule({ + action, + children, + className, + size = "sm", + ...props +}, ref) { + return ( + + {children} + {action != null && {action}} + + ); +}); diff --git a/design-system/packages/ui/src/components/Capsule/index.ts b/design-system/packages/ui/src/components/Capsule/index.ts new file mode 100644 index 0000000000..6262586ca6 --- /dev/null +++ b/design-system/packages/ui/src/components/Capsule/index.ts @@ -0,0 +1 @@ +export { Capsule, type CapsuleProps } from "./Capsule"; diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.module.css b/design-system/packages/ui/src/components/Combobox/Combobox.module.css index 8bc63530f3..1ec8876520 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.module.css +++ b/design-system/packages/ui/src/components/Combobox/Combobox.module.css @@ -225,7 +225,7 @@ overflow: hidden; align-items: center; max-inline-size: 10rem; - min-block-size: var(--openbitfun-control-icon-button-xs-size); + block-size: var(--openbitfun-control-button-xs-height); gap: var(--openbitfun-space-1); padding-inline: var(--openbitfun-space-2); border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); @@ -236,10 +236,6 @@ white-space: nowrap; } - .tag:has(button) { - padding-inline-end: 0; - } - .tag > span { overflow: hidden; } diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.tsx b/design-system/packages/ui/src/components/Combobox/Combobox.tsx index e3f73cb43f..5f65da32ea 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.tsx +++ b/design-system/packages/ui/src/components/Combobox/Combobox.tsx @@ -22,6 +22,7 @@ import { Portal } from "../../overlay/Portal"; import { useDesignSystem } from "../../overlay/useDesignSystem"; import { useDismissibleLayer } from "../../overlay/useDismissibleLayer"; import { IconButton } from "../IconButton"; +import { Capsule } from "../Capsule"; import { Listbox, ListboxEmpty, @@ -656,8 +657,7 @@ const CollectionPicker = forwardRef(function Collec {showTags && ( {selectedOptions.slice(0, Math.max(1, maxVisibleTags)).map((option) => ( - - {option.label} + (function Collec tabIndex={resolvedOpen ? -1 : undefined} variant="quiet" /> - + )}> + {option.label} + ))} {selectedOptions.length > Math.max(1, maxVisibleTags) && ( +{selectedOptions.length - Math.max(1, maxVisibleTags)} diff --git a/design-system/packages/ui/src/components/Icon/Icon.tsx b/design-system/packages/ui/src/components/Icon/Icon.tsx index 2714bd8588..97badfc451 100644 --- a/design-system/packages/ui/src/components/Icon/Icon.tsx +++ b/design-system/packages/ui/src/components/Icon/Icon.tsx @@ -62,7 +62,7 @@ import { PanelLeft, PanelRight, PanelsTopLeft, - Pencil, + Pen, Pin, Plus, Plug, @@ -270,7 +270,7 @@ const lineGlyphs = { "device-mac": Monitor, "download": ArrowDown, "duplicate": Copy, - "edit": Pencil, + "edit": Pen, "extension": Puzzle, "eye": Eye, "file-search-corner": FileSearchCorner, diff --git a/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts b/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts index 6a044ae694..1c690639e1 100644 --- a/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts +++ b/design-system/packages/ui/src/components/IconButton/IconButton.meta.ts @@ -2,13 +2,13 @@ import type { ComponentMeta } from "../../registry.types"; export const iconButtonMeta = { category: "action", - description: "A labeled icon-only action with annotation, quiet, outlined, filled, and primary variants. Annotation pairs a muted 12px mark with the existing 22px xs target for supplemental label help.", + description: "A labeled icon-only action with annotation, quiet, glass, outlined, filled, and primary variants. Glass floats over intact content; capsule and tab action slots select it automatically.", maturity: "stable", name: "IconButton", props: [ { name: "aria-label", type: "string" }, { name: "icon", type: "ReactNode" }, - { defaultValue: "quiet", name: "variant", type: "annotation | quiet | outline | fill | primary" }, + { defaultValue: "quiet", name: "variant", type: "annotation | quiet | glass | outline | fill | primary" }, { defaultValue: "square", name: "shape", type: "square | circle" }, { defaultValue: "sm", name: "size", type: "xs | standard | sm | md | lg" }, { defaultValue: "neutral", name: "tone", type: "neutral | danger" }, @@ -16,6 +16,9 @@ export const iconButtonMeta = { ], states: ["default", "hover", "active", "focus-visible", "disabled", "loading"], tokens: [ + "color.surface.raised", + "color.border.subtle", + "effect.blur.subtle", "opacity.iconArtwork", "color.content.primary", "color.content.secondary", diff --git a/design-system/packages/ui/src/components/IconButton/IconButton.tsx b/design-system/packages/ui/src/components/IconButton/IconButton.tsx index be5b18a01a..c41a124ffa 100644 --- a/design-system/packages/ui/src/components/IconButton/IconButton.tsx +++ b/design-system/packages/ui/src/components/IconButton/IconButton.tsx @@ -1,9 +1,12 @@ import { forwardRef, + useContext, type ButtonHTMLAttributes, type ReactNode, } from "react"; import { classNames } from "../../internal/classNames"; +import { FloatingActionContext } from "../../internal/floatingActionContext"; +import glassStyles from "../../internal/GlassAction.module.css"; import styles from "./IconButton.module.css"; export interface IconButtonProps @@ -15,7 +18,7 @@ export interface IconButtonProps size?: "xs" | "standard" | "sm" | "md" | "lg"; tone?: "danger" | "neutral"; /** Use annotation with an xs size and Tooltip for supplemental label help. */ - variant?: "annotation" | "fill" | "outline" | "primary" | "quiet"; + variant?: "annotation" | "fill" | "glass" | "outline" | "primary" | "quiet"; } export const IconButton = forwardRef(function IconButton({ @@ -31,18 +34,20 @@ export const IconButton = forwardRef(functio variant = "quiet", ...props }, ref) { + const floating = useContext(FloatingActionContext); + const resolvedVariant = floating ? "glass" : variant; return ( {hasEndAction && ( - + {item.endAction} - + )} ); diff --git a/design-system/packages/ui/src/index.ts b/design-system/packages/ui/src/index.ts index 842ff7c1c1..f511c0784f 100644 --- a/design-system/packages/ui/src/index.ts +++ b/design-system/packages/ui/src/index.ts @@ -57,6 +57,7 @@ export { export { Alert, type AlertProps, type AlertTone } from "./components/Alert"; export { Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps, type AvatarSize } from "./components/Avatar"; export { Button, type ButtonProps } from "./components/Button"; +export { Capsule, type CapsuleProps } from "./components/Capsule"; export { Checkbox, type CheckboxProps, type CheckboxSize } from "./components/Checkbox"; export { Card, diff --git a/design-system/packages/ui/src/internal/FloatingAction.module.css b/design-system/packages/ui/src/internal/FloatingAction.module.css new file mode 100644 index 0000000000..29985f6673 --- /dev/null +++ b/design-system/packages/ui/src/internal/FloatingAction.module.css @@ -0,0 +1,50 @@ +@layer openbitfun.components { + .host { + --_floating-action-size: var(--openbitfun-control-icon-button-xs-size); + position: relative; + } + + .action { + pointer-events: auto; + position: absolute; + inset-block-start: 50%; + inset-inline-end: calc((var(--_floating-action-host-height) - var(--_floating-action-size)) / 2); + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--_floating-action-size); + block-size: var(--_floating-action-size); + transform: translateY(-50%); + } + + .action > button { + inline-size: 100%; + block-size: 100%; + border-radius: var(--openbitfun-radius-pill); + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + @media (hover: hover) and (pointer: fine) { + .action { + pointer-events: none; + } + + .action > button { + opacity: 0; + } + + .host:is(:hover, :focus-within, [data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) > .action { + pointer-events: auto; + } + + .host:is(:hover, :focus-within, [data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) > .action > button { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .action > button { + transition: none; + } + } +} diff --git a/design-system/packages/ui/src/internal/FloatingAction.tsx b/design-system/packages/ui/src/internal/FloatingAction.tsx new file mode 100644 index 0000000000..5525c51f29 --- /dev/null +++ b/design-system/packages/ui/src/internal/FloatingAction.tsx @@ -0,0 +1,19 @@ +import { Children, cloneElement, isValidElement, type HTMLAttributes, type ReactNode } from "react"; +import { classNames } from "./classNames"; +import { FloatingActionContext } from "./floatingActionContext"; +import glassStyles from "./GlassAction.module.css"; +import styles from "./FloatingAction.module.css"; + +export function FloatingAction({ children, className, ...props }: HTMLAttributes) { + return ( + + + {Children.map(children, (child: ReactNode) => ( + isValidElement>(child) && child.type === "button" + ? cloneElement(child, { className: classNames(child.props.className, glassStyles.surface) }) + : child + ))} + + + ); +} diff --git a/design-system/packages/ui/src/internal/GlassAction.module.css b/design-system/packages/ui/src/internal/GlassAction.module.css new file mode 100644 index 0000000000..405ef24d24 --- /dev/null +++ b/design-system/packages/ui/src/internal/GlassAction.module.css @@ -0,0 +1,54 @@ +@layer openbitfun.components { + .surface[data-openbitfun-variant="glass"], + .surface:not([data-openbitfun-component="icon-button"]) { + background: var(--openbitfun-color-surface-raised); + box-shadow: inset 0 0 0 var(--openbitfun-border-width-default) color-mix(in srgb, var(--openbitfun-color-border-subtle) 60%, transparent); + } + + @supports ((backdrop-filter: blur(0)) or (-webkit-backdrop-filter: blur(0))) { + .surface[data-openbitfun-variant="glass"], + .surface:not([data-openbitfun-component="icon-button"]) { + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 28%, transparent); + -webkit-backdrop-filter: var(--openbitfun-effect-blur-subtle); + backdrop-filter: var(--openbitfun-effect-blur-subtle); + } + + .surface[data-openbitfun-variant="glass"]:where(:not(:disabled):is(:hover, [data-openbitfun-preview-state="hover"])), + .surface:not([data-openbitfun-component="icon-button"]):where(:not(:disabled):is(:hover, [data-openbitfun-preview-state="hover"])) { + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 40%, transparent); + } + + .surface[data-openbitfun-variant="glass"]:where(:not(:disabled):is(:active, [data-openbitfun-preview-state="active"])), + .surface:not([data-openbitfun-component="icon-button"]):where(:not(:disabled):is(:active, [data-openbitfun-preview-state="active"])) { + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 48%, transparent); + } + } + + :where([data-contrast="high"]) .surface[data-openbitfun-variant="glass"], + :where([data-contrast="high"]) .surface:not([data-openbitfun-component="icon-button"]) { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + .surface[data-openbitfun-variant="glass"], + .surface:not([data-openbitfun-component="icon-button"]) { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + } + + @media (forced-colors: active) { + .surface[data-openbitfun-variant="glass"], + .surface:not([data-openbitfun-component="icon-button"]) { + color: ButtonText; + background: Canvas; + box-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; + outline: var(--openbitfun-border-width-default) solid ButtonText; + } + } +} diff --git a/design-system/packages/ui/src/internal/floatingActionContext.ts b/design-system/packages/ui/src/internal/floatingActionContext.ts new file mode 100644 index 0000000000..ec2f7864c0 --- /dev/null +++ b/design-system/packages/ui/src/internal/floatingActionContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** Capsule and tab action slots own their floating presentation and geometry. */ +export const FloatingActionContext = createContext(false); diff --git a/design-system/packages/ui/src/registry.ts b/design-system/packages/ui/src/registry.ts index cad1627db1..abda186802 100644 --- a/design-system/packages/ui/src/registry.ts +++ b/design-system/packages/ui/src/registry.ts @@ -4,6 +4,7 @@ import { activityItemMeta } from "./components/ActivityItem/ActivityItem.meta"; import { alertMeta } from "./components/Alert/Alert.meta"; import { avatarMeta } from "./components/Avatar/Avatar.meta"; import { buttonMeta } from "./components/Button/Button.meta"; +import { capsuleMeta } from "./components/Capsule/Capsule.meta"; import { checkboxMeta } from "./components/Checkbox/Checkbox.meta"; import { cardMeta } from "./components/Card/Card.meta"; import { composerMeta } from "./components/Composer/Composer.meta"; @@ -127,6 +128,7 @@ export const componentRegistry = [ askUserMeta, avatarMeta, buttonMeta, + capsuleMeta, cardMeta, checkboxMeta, chatComposerMeta, diff --git a/design-system/packages/ui/tests/capsule.test.mjs b/design-system/packages/ui/tests/capsule.test.mjs new file mode 100644 index 0000000000..7de04336c7 --- /dev/null +++ b/design-system/packages/ui/tests/capsule.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { createElement as h } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; +import { Button, Capsule, Icon, IconButton, MultiSelect, TabGroup, Tooltip } from "../dist/index.js"; + +const action = (props = {}) => h(IconButton, { + "aria-label": "Remove provider", icon: h(Icon, { name: "delete" }), ...props, +}); + +test("a tooltip-wrapped capsule action keeps its accessible name and destructive state with shared glass presentation", () => { + const markup = renderToStaticMarkup(h(Capsule, { + size: "sm", + action: h(Tooltip, { content: "Remove provider" }, action({ tone: "danger", variant: "fill", loading: true })), + }, h(Button, { size: "sm", "aria-label": "Edit provider" }, "Long provider name"))); + assert.match(markup, /data-openbitfun-component="capsule"/); + assert.match(markup, /data-openbitfun-part="action"/); + assert.match(markup, /data-openbitfun-variant="glass"/); + assert.match(markup, /data-openbitfun-shape="circle"/); + assert.match(markup, /data-openbitfun-tone="danger"/); + assert.match(markup, /aria-label="Remove provider"/); + assert.match(markup, /aria-busy="true"/); + assert.match(markup, /disabled=""/); + assert.match(markup, /Long provider name/); + assert.equal((markup.match(/") < markup.lastIndexOf(" { + const markup = renderToStaticMarkup(h(Capsule, { size: "xs" }, "A tag")); + assert.match(markup, />A tag<\/span>/); + assert.doesNotMatch(markup, /tabindex|role=|data-openbitfun-part="action"/); +}); + +test("tab actions and removable MultiSelect labels adopt the same glass presentation", () => { + const tabs = renderToStaticMarkup(h(TabGroup, { items: [{ value: "a", label: "A long tab", endAction: action({ variant: "outline" }) }] })); + assert.match(tabs, /data-openbitfun-variant="glass"/); + assert.match(tabs, /role="tab"/); + assert.match(tabs, /A long tab/); + + const select = renderToStaticMarkup(h(MultiSelect, { + "aria-label": "Tags", value: ["a"], options: [{ value: "a", label: "A long tag" }], disabled: true, + })); + assert.match(select, /data-openbitfun-component="capsule"/); + assert.match(select, /data-openbitfun-variant="glass"/); + assert.match(select, /A long tag/); + assert.match(select, /disabled=""/); +}); + +test("a standalone icon retains its chosen variant outside a floating action slot", () => { + const markup = renderToStaticMarkup(action({ variant: "outline" })); + assert.match(markup, /data-openbitfun-variant="outline"/); + assert.doesNotMatch(markup, /data-openbitfun-variant="glass"/); +}); diff --git a/design-system/packages/ui/tests/registry.test.mjs b/design-system/packages/ui/tests/registry.test.mjs index 858094e302..e7539b8c92 100644 --- a/design-system/packages/ui/tests/registry.test.mjs +++ b/design-system/packages/ui/tests/registry.test.mjs @@ -24,6 +24,7 @@ test("registry exposes only the formal stable components", () => { "AskUser", "Avatar", "Button", + "Capsule", "Card", "Checkbox", "ChatComposer", diff --git a/design-system/packages/ui/tests/tab-group.test.mjs b/design-system/packages/ui/tests/tab-group.test.mjs index acaeb2ef83..9c7d6e4eab 100644 --- a/design-system/packages/ui/tests/tab-group.test.mjs +++ b/design-system/packages/ui/tests/tab-group.test.mjs @@ -102,7 +102,7 @@ test("product wrappers preserve the standard tablist, selection and end-action a assert.equal((markup.match(/role="tab"/g) ?? []).length, 2); assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1); assert.match(markup, /data-document="settings" data-index="1" draggable="true"/); - assert.match(markup, /<\/button>]+data-openbitfun-part="endAction"> - - removeSelectedModelDraft(draft.modelName)} - disabled={isEditorSaving} - icon={} - /> - - + ))} {isAddingCustomModel && ( {
    {apiProviderGroups.map(group => (
  • - - - + + void requestProviderDelete(group)} + icon={} + /> + + )}> + + + +
  • ))}
@@ -4102,11 +4121,26 @@ const ModelSettingsPage: React.FC = () => {
    {subscriptionProviderGroups.map(group => (
  • - + + void requestProviderDelete(group)} + icon={} + /> + + )}> + +
  • ))}
diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx index 5fcbc7475f..fb1c7e28d3 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx @@ -2,8 +2,8 @@ import { pendingQueueManager } from '../services/flow-chat-manager/PendingQueueM import type { QueuedMessage } from '../types/flow-chat'; import { useEffect, useId, useState, useSyncExternalStore } from 'react'; import { useI18n } from '@/infrastructure/i18n'; -import { Button, IconButton, OverflowText, Tooltip } from '@openbitfun/ui'; -import { ArrowUp, ChevronDown, ChevronUp, Copy, Info, Pencil, RotateCw, X } from 'lucide-react'; +import { Button, Icon, IconButton, OverflowText, Tooltip } from '@openbitfun/ui'; +import { RotateCw } from 'lucide-react'; import './HostPendingQueuePanel.scss'; import { HostDialogQueue, observeHostQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue'; import { @@ -55,11 +55,11 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ return } onClick={() => setShowHelp(value => !value)} /> + icon={} onClick={() => setShowHelp(value => !value)} /> {showHelp &&

{t('hostQueue.memoryNotice')}

} {visibleError &&
{visibleError}
} @@ -72,13 +72,13 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ {item.reason && {item.reason}} - } disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(async () => { + } disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(async () => { const saved = await queue.savedDraft(item.turnId); if (!saved || saved.request.action !== 'submit') throw new Error(t('hostQueue.noDraft')); await restoreDraft(saved, item); })} /> - } disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'promote'))} /> - } disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'cancel'))} /> + } disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'promote'))} /> + } disabled={busy || !!view.error || item.status === 'steering_pending'} onClick={() => void run(() => queue.act(item, 'cancel'))} /> )} {view.pending.map(record => @@ -87,9 +87,9 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ {record.restoreIntent && record.request.action === 'submit' - ? } disabled={busy} onClick={() => void run(() => restoreDraft(record))} /> - : } disabled={busy} onClick={() => void run(() => queue.retry(record))} />} - {record.request.action === 'submit' && } disabled={busy} onClick={() => { + ? } disabled={busy} onClick={() => void run(() => restoreDraft(record))} /> + : } disabled={busy} onClick={() => void run(() => queue.retry(record))} />} + {record.request.action === 'submit' && } disabled={busy} onClick={() => { if (record.request.action !== 'submit') return; const message = record.request.message; const cache = record.draft as Partial | undefined; @@ -101,7 +101,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ status: 'queued', retryCount: cache?.retryCount ?? 0, userMessageMetadata: message.metadata }); }} />} - } disabled={busy} onClick={() => void run(() => queue.dismiss(record))} /> + } disabled={busy} onClick={() => void run(() => queue.dismiss(record))} /> )} diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index e6e15a706b..89ff84fd42 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -17,19 +17,17 @@ import { useI18n } from '@/infrastructure/i18n'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { - aiApi, type AIModelCatalog, type ReasoningCatalogProjection, } from '@/infrastructure/api/service-api/AIApi'; import { ACPClientAPI, type AcpSessionOptions } from '@/infrastructure/api/service-api/ACPClientAPI'; import { getProviderDisplayName, getProviderGroupKey } from '@/infrastructure/config/services/modelConfigs'; -import { globalEventBus } from '@/infrastructure/event-bus'; import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { Tooltip, Icon } from '@openbitfun/ui'; import { RetainedMountBoundary } from '@/shared/presence'; import { notificationService } from '@/shared/notification-system'; import { FlowChatStore } from '../store/FlowChatStore'; -import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule'; +import { modelCatalogStore } from '@/infrastructure/config/services/ModelCatalogStore'; import { acpClientIdFromAgentType } from '../utils/acpSession'; import { buildAcpFastModeValue, @@ -67,6 +65,7 @@ import { import { resolveModelReference, resolveModelSelection, + resolveModelMaxTokens, type ModelAvailabilityStatus, } from '../utils/modelResolution'; import { getRecentManualModel, setRecentManualModel } from '../utils/manualModelSelection'; @@ -75,6 +74,8 @@ import './ModelSelector.scss'; const log = createLogger('ModelSelector'); const ACP_SESSION_OPTIONS_TIMEOUT_MS = 65_000; const MODEL_SELECTOR_EXIT_DURATION_MS = 100; +const EMPTY_MODELS: AIModelConfig[] = []; +const EMPTY_DEFAULT_MODELS: DefaultModelsConfig = {}; export interface ExternalModelSelection { models: string[]; @@ -161,7 +162,7 @@ interface ProviderGroupInfo { models: ModelInfo[]; } -type ModelSelectorPanel = 'modes' | 'models'; +type ModelSelectorPanel = 'modes' | 'models' | 'providers'; type UpcomingModelMode = 'smart' | 'pool'; type ModelSelectorLevelDirection = 'none' | 'forward' | 'back'; @@ -337,23 +338,29 @@ export const ModelSelector: React.FC = ({ const modelSelectionScopeRef = useRef(modelSelectionScopeKey); modelSelectionScopeRef.current = modelSelectionScopeKey; const manualModelRef = useRef<{ scopeKey: string; modelId: string } | null>(null); - const [allModels, setAllModels] = useState([]); - const [modelCatalog, setModelCatalog] = useState(null); - const [defaultModels, setDefaultModels] = useState({}); - const [modeModel, setModeModel] = useState('primary'); - const [configLoadState, setConfigLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); - const [catalogLoadState, setCatalogLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [configEpoch, setConfigEpoch] = useState(deviceSurfaceScope.epoch); + const [loadedModels, setAllModels] = useState([]); + const allModels = configEpoch === deviceSurfaceScope.epoch ? loadedModels : EMPTY_MODELS; + const { catalog: modelCatalog, status: catalogLoadState } = useSyncExternalStore( + modelCatalogStore.subscribe, modelCatalogStore.getSnapshot, modelCatalogStore.getSnapshot, + ); + const [loadedDefaults, setDefaultModels] = useState({}); + const defaultModels = configEpoch === deviceSurfaceScope.epoch ? loadedDefaults : EMPTY_DEFAULT_MODELS; + const [loadedModeModel, setModeModel] = useState('primary'); + const modeModel = configEpoch === deviceSurfaceScope.epoch ? loadedModeModel : 'primary'; + const [configState, setConfigLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + const configLoadState = configEpoch === deviceSurfaceScope.epoch ? configState : 'loading'; const [acpOptions, setAcpOptions] = useState(null); const [acpOptionsLoadState, setAcpOptionsLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); const [dropdownOpen, setDropdownOpen] = useState(false); const [keyboardNavigationOpen, setKeyboardNavigationOpen] = useState(false); /** Provider whose models the menu is currently showing; null is the provider level. */ const [activeProviderKey, setActiveProviderKey] = useState(null); - /** Models use a detail view; modes expand horizontally inside the summary card. */ + /** Providers and models use detail views; modes expand inside the summary card. */ const [activePanel, setActivePanel] = useState(null); const [modeSelectionPending, setModeSelectionPending] = useState(false); const [modeFocusRevision, setModeFocusRevision] = useState(0); - const [providerFromSummary, setProviderFromSummary] = useState(false); + const [modelsFromSummary, setModelsFromSummary] = useState(false); // Upcoming modes execute through primary until the runtime supports routing. const [upcomingMode, setUpcomingMode] = useState<{ id: UpcomingModelMode; @@ -369,8 +376,9 @@ export const ModelSelector: React.FC = ({ } | null>(null); const acpRestoreToastShownRef = useRef(null); const acpOptionsRef = useRef(null); + const configEpochRef = useRef(configEpoch); + configEpochRef.current = configEpoch; const configLoadRequestRef = useRef(0); - const catalogLoadRequestRef = useRef(0); const acpOptionsLoadRequestRef = useRef(0); const dropdownRef = useRef(null); @@ -382,10 +390,19 @@ export const ModelSelector: React.FC = ({ const modeRetryFocusRef = useRef(undefined); const providerFieldRef = useRef(null); const focusPanelOnOpenRef = useRef(false); - const focusSummaryOnReturnRef = useRef(null); + const focusSummaryOnReturnRef = useRef(null); const triggerRef = useRef(null); const menuId = useId(); + useEffect(() => { + setLoading(false); + setReasoningLoading(false); + setUpcomingMode(null); + setPreSessionReasoningSelection(null); + setDropdownOpen(false); + setActivePanel(null); + }, [deviceSurfaceScope.epoch]); + useEffect(() => { onLoadingChange?.(loading || reasoningLoading); }, [loading, onLoadingChange, reasoningLoading]); @@ -432,28 +449,10 @@ export const ModelSelector: React.FC = ({ const isAcpSession = Boolean(acpClientId && sessionId); const targetIsSubagent = isSubagentSession || activeSession?.sessionKind === 'subagent'; - const loadModelCatalog = useCallback(async () => { - const requestId = ++catalogLoadRequestRef.current; - setCatalogLoadState('loading'); - try { - const catalog = await aiApi.getModelCatalog(); - if (requestId !== catalogLoadRequestRef.current) return false; - setModelCatalog(catalog); - setCatalogLoadState('ready'); - return true; - } catch (error) { - if (requestId !== catalogLoadRequestRef.current) return false; - setModelCatalog(null); - setCatalogLoadState('error'); - log.warn('Failed to load AI model catalog', { error }); - return false; - } - }, []); - // Load configuration data. const loadConfigData = useCallback(async () => { const requestId = ++configLoadRequestRef.current; - setConfigLoadState('loading'); + const scope = getActiveSurfaceScope(); try { const configData = await configManager.getConfigs([ 'ai.models', @@ -466,53 +465,40 @@ export const ModelSelector: React.FC = ({ const defaultModelsData = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - if (requestId !== configLoadRequestRef.current) return false; + if (requestId !== configLoadRequestRef.current || !scope.isCurrent()) return false; + setConfigEpoch(scope.epoch); setAllModels(models); setDefaultModels(defaultModelsData); setModeModel(agentModelDefaults?.mode?.trim() || 'primary'); setConfigLoadState('ready'); - await loadModelCatalog(); - if (requestId !== configLoadRequestRef.current) return false; + if (requestId !== configLoadRequestRef.current || !scope.isCurrent()) return false; log.debug('Configuration loaded', { modelsCount: models.length }); return true; } catch (error) { - if (requestId !== configLoadRequestRef.current) return false; + if (requestId !== configLoadRequestRef.current || !scope.isCurrent()) return false; + if (configEpochRef.current !== scope.epoch) { + setAllModels([]); setDefaultModels({}); setModeModel('primary'); + } + setConfigEpoch(scope.epoch); setConfigLoadState('error'); log.error('Failed to load configuration', error); return false; } - }, [loadModelCatalog]); + }, []); useEffect(() => { - const unsubscribeCatalog = aiApi.onModelCatalogUpdated(() => { - void loadModelCatalog(); - }); void loadConfigData(); - - const handleConfigUpdate = () => { - log.debug('Configuration update detected, reloading'); - void loadConfigData(); - }; - - globalEventBus.on('mode:config:updated', handleConfigUpdate); - const unsubscribe = configManager.onConfigChange((path) => { - if (path === 'ai' || path.startsWith('ai.')) { - log.debug('AI configuration changed', { path }); - void loadConfigData(); - } + if (!path || path === 'ai' || ['ai.models', 'ai.default_models', 'ai.agent_model_defaults'].some( + owned => path === owned || path.startsWith(`${owned}.`), + )) void loadConfigData(); }); - - return () => { - globalEventBus.off('mode:config:updated', handleConfigUpdate); - unsubscribe(); - unsubscribeCatalog(); - }; - }, [loadConfigData, loadModelCatalog]); + return () => { configLoadRequestRef.current += 1; unsubscribe(); }; + }, [loadConfigData, deviceSurfaceScope.epoch]); const loadAcpOptions = useCallback(async () => { const requestId = ++acpOptionsLoadRequestRef.current; @@ -1009,7 +995,7 @@ export const ModelSelector: React.FC = ({ focusSummaryOnReturnRef.current = null; focusPanelOnOpenRef.current = false; setActiveProviderKey(null); - setProviderFromSummary(false); + setModelsFromSummary(false); setActivePanel(null); setLevelDirection('none'); }, []); @@ -1019,7 +1005,7 @@ export const ModelSelector: React.FC = ({ focusPanelOnOpenRef.current = moveFocus; focusSummaryOnReturnRef.current = null; setActiveProviderKey(null); - setProviderFromSummary(false); + setModelsFromSummary(false); setLevelDirection('forward'); if (activePanel === kind && moveFocus && !activeProviderKey) { focusPanelOnOpenRef.current = false; @@ -1032,7 +1018,7 @@ export const ModelSelector: React.FC = ({ focusSummaryOnReturnRef.current = restoreFocus ? activePanel : null; focusPanelOnOpenRef.current = false; setActiveProviderKey(null); - setProviderFromSummary(false); + setModelsFromSummary(false); setActivePanel(null); setLevelDirection('back'); }, [activePanel]); @@ -1043,25 +1029,24 @@ export const ModelSelector: React.FC = ({ const openProviderLevel = useCallback((providerKey: string, fromSummary = false) => { setActiveProviderKey(providerKey); - setProviderFromSummary(fromSummary); + setModelsFromSummary(fromSummary); setLevelDirection('forward'); }, []); const closeProviderLevel = useCallback(() => { - if (providerFromSummary) { + if (modelsFromSummary) { closePanel(true); - focusSummaryOnReturnRef.current = 'provider'; return; } setActiveProviderKey(null); setLevelDirection('back'); - }, [closePanel, providerFromSummary]); + }, [closePanel, modelsFromSummary]); // Keep the last view intact during exit; trigger handlers reset it on reopening. // Catalog refreshes must not leave an open menu on a missing provider view. useEffect(() => { if (!dropdownOpen) return; - if (activePanel !== 'models' && activeProviderKey) { + if (activePanel !== 'models' && activePanel !== 'providers' && activeProviderKey) { setActiveProviderKey(null); return; } @@ -1072,13 +1057,16 @@ export const ModelSelector: React.FC = ({ const currentNativeModelId = getCurrentModelId(); const concreteModelId = resolveConcreteModelId(currentNativeModelId, defaultModels); - /** Provider that owns the pinned model, so the provider level can mark it. */ + const currentModelId = externalSelection ? externalCurrentModelId + : isAcpSession ? acpOptions?.currentModelId || acpAvailableModels[0]?.id || '' + : currentNativeModelId; + /** Resolve symbolic defaults and target-owned models to their provider. */ const selectedProviderKey = useMemo((): string | null => { - if (isSpecialModel(currentNativeModelId)) return null; + const modelId = externalSelection || isAcpSession ? currentModelId : concreteModelId; return providerGroups.find( - group => group.models.some(model => model.id === currentNativeModelId), + group => group.models.some(model => model.id === modelId), )?.key ?? null; - }, [currentNativeModelId, providerGroups]); + }, [concreteModelId, currentModelId, externalSelection, isAcpSession, providerGroups]); const currentReasoningProjection = useMemo((): ReasoningCatalogProjection | null => { if (!concreteModelId) return null; return modelCatalog?.models.find(model => model.id === concreteModelId)?.reasoning ?? null; @@ -1245,12 +1233,14 @@ export const ModelSelector: React.FC = ({ const previousReasoningPreset = sessionId ? store.getState().sessions.get(sessionId)?.config.reasoningPreset : undefined; + const previousMaxContextTokens = sessionId ? store.getState().sessions.get(sessionId)?.maxContextTokens : undefined; const nextReasoningPreset = mode ? undefined : recentPresetForModel(modelId); let sessionModelWrittenOptimistically = false; try { if (externalSelection) { await externalSelection.onSelect(modelId); + selectionScope.assertCurrent('select external model'); return true; } if (isAcpSession && acpClientId && sessionId) { @@ -1263,6 +1253,7 @@ export const ModelSelector: React.FC = ({ remoteSshHost: activeSession?.remoteSshHost, modelId, }); + selectionScope.assertCurrent('select ACP model'); setAcpOptions(options); syncAcpContextUsageToStore(sessionId, options); store.updateSessionModelName(sessionId, modelId); @@ -1272,13 +1263,14 @@ export const ModelSelector: React.FC = ({ const updateTargetSessionModel = async () => { if (!sessionId) return; + if (previousSessionModelName === modelId && previousReasoningPreset === nextReasoningPreset) return; // Update the frontend session model immediately so the UI reflects the // switch without waiting for the backend IPC round-trip. store.updateSessionModelName(sessionId, modelId); store.updateSessionReasoningPreset(sessionId, nextReasoningPreset); sessionModelWrittenOptimistically = true; - const maxContextTokens = await getModelMaxTokens(modelId, currentMode); + const maxContextTokens = resolveModelMaxTokens(modelId, allModels, defaultModels); selectionScope.assertCurrent('select model'); store.updateSessionMaxContextTokens(sessionId, maxContextTokens); const session = store.getState().sessions.get(sessionId); @@ -1305,13 +1297,26 @@ export const ModelSelector: React.FC = ({ return true; } - if (persistSharedModeDefault) { - await configManager.setConfig('ai.agent_model_defaults.mode', modelId); - selectionScope.assertCurrent('select model'); + await updateTargetSessionModel(); + if (persistSharedModeDefault && modeModel !== modelId) { + const saveDefault = configManager.setConfig('ai.agent_model_defaults.mode', modelId); + if (!sessionId) { + // A selector without a draft/session has no other owner for the next + // submission. Its preference is the selection commit itself. + await saveDefault; + selectionScope.assertCurrent('select initial model'); + } else { + // ConfigManager captures the device and serializes related writes. + // Remembering a future default must not hold the current session UI. + void saveDefault.catch(error => { + if (!selectionScope.isCurrent()) return; + log.warn('Session model selected but default preference was not saved', { error }); + notificationService.warning(t('modelSelector.defaultSaveFailed')); + void loadConfigData(); + }); + } setModeModel(modelId); - globalEventBus.emit('mode:config:updated'); } - await updateTargetSessionModel(); rememberManualModel(); setUpcomingMode(mode ? { id: mode, scopeKey: modelSelectionScopeKey } : null); setPreSessionReasoningSelection({ @@ -1327,20 +1332,22 @@ export const ModelSelector: React.FC = ({ } catch (error) { if (!selectionScope.isCurrent()) return false; log.error('Failed to switch model', error); - // Only a previously pinned selection can be restored: the store has no - // way to express "never pinned" without claiming a session binding. - if (sessionId && sessionModelWrittenOptimistically && previousSessionModelName) { + if (sessionId && sessionModelWrittenOptimistically) { store.updateSessionModelName(sessionId, previousSessionModelName); } if (sessionId && sessionModelWrittenOptimistically) { store.updateSessionReasoningPreset(sessionId, previousReasoningPreset); + store.updateSessionMaxContextTokens(sessionId, previousMaxContextTokens); } notificationService.error(t('modelSelector.switchFailed')); return false; } finally { - setLoading(false); + if (selectionScope.isCurrent()) setLoading(false); } }, [ + allModels, + modeModel, + loadConfigData, activeSession?.config.workspacePath, activeSession?.remoteConnectionId, activeSession?.remoteSshHost, @@ -1411,6 +1418,7 @@ export const ModelSelector: React.FC = ({ const previousPreset = session.config.reasoningPreset; if (previousPreset === normalizedPreset) return true; + const selectionScope = getActiveSurfaceScope(); setReasoningLoading(true); store.updateSessionReasoningPreset(sessionId, normalizedPreset); try { @@ -1426,6 +1434,7 @@ export const ModelSelector: React.FC = ({ includeInternal: shouldIncludeInternalModelSession(session), }); } + selectionScope.assertCurrent('select reasoning preset'); if (!targetIsSubagent) { setRecentReasoningPreset(concreteModelId, normalizedPreset); setPreSessionReasoningSelection({ @@ -1440,12 +1449,13 @@ export const ModelSelector: React.FC = ({ }); return true; } catch (error) { + if (!selectionScope.isCurrent()) return false; store.updateSessionReasoningPreset(sessionId, previousPreset); log.error('Failed to update session reasoning preset', error); notificationService.error(t('reasoningSelector.updateFailed')); return false; } finally { - setReasoningLoading(false); + if (selectionScope.isCurrent()) setReasoningLoading(false); } }, [ concreteModelId, @@ -1727,7 +1737,7 @@ export const ModelSelector: React.FC = ({ if (event.key === 'ArrowRight') { const focusedElement = document.activeElement as HTMLElement | null; const focusedTarget = focusedElement?.dataset?.modelMenuTarget; - if (focusedTarget === 'modes' || focusedTarget === 'models') { + if (focusedTarget === 'modes' || focusedTarget === 'models' || focusedTarget === 'providers') { event.preventDefault(); openPanel(focusedTarget, true); } @@ -1752,7 +1762,7 @@ export const ModelSelector: React.FC = ({ return; } - if (event.key === 'ArrowRight' && activePanel === 'models' && !activeProviderKey) { + if (event.key === 'ArrowRight' && !activeProviderKey) { const focusedElement = document.activeElement as HTMLElement | null; const focusedProviderKey = focusedElement?.dataset?.providerKey; if (focusedProviderKey) { @@ -1801,14 +1811,14 @@ export const ModelSelector: React.FC = ({ const returnTarget = focusSummaryOnReturnRef.current; focusSummaryOnReturnRef.current = null; if (returnTarget) { - const field = returnTarget === 'provider' ? providerFieldRef.current + const field = returnTarget === 'providers' ? providerFieldRef.current : returnTarget === 'modes' ? modeFieldRef.current : modelFieldRef.current; (field ?? modeFieldRef.current ?? modelFieldRef.current)?.focus({ preventScroll: true }); } return; } if (activePanel === 'modes' && (modeSelectionPending || loading || reasoningLoading)) return; - const providerLevelChanged = activePanel === 'models' + const providerLevelChanged = (activePanel === 'models' || activePanel === 'providers') && previousProviderKey !== activeProviderKey; if (!focusPanelOnOpenRef.current && !providerLevelChanged) return; focusPanelOnOpenRef.current = false; @@ -1827,7 +1837,7 @@ export const ModelSelector: React.FC = ({ return; } - if (activePanel === 'models' && previousProviderKey) { + if (previousProviderKey) { const providerRows = Array.from( menu.querySelectorAll('button[data-provider-key]'), ); @@ -1858,9 +1868,6 @@ export const ModelSelector: React.FC = ({ const resolvedContextUsageSource: ContextUsageSource = contextUsageSource ?? (isAcpSession ? 'acp_context' : 'agent_prompt'); - const currentModelId = externalSelection ? externalCurrentModelId - : isAcpSession ? acpOptions?.currentModelId || acpAvailableModels[0]?.id || '' - : currentNativeModelId; const displayedModel = externalSelection ? externalCurrentModel : isAcpSession ? acpCurrentModel ?? acpAvailableModels[0] ?? null : currentModel; const isAcpSelection = isAcpSession && !externalSelection; @@ -1871,7 +1878,7 @@ export const ModelSelector: React.FC = ({ const displayedAvailability = externalSelection ? externalAvailability : isAcpSession ? acpAvailability : nativeAvailability; const showModelLoadingState = modelInformationLoading && ( - isNativeModelSelection ? !modelCatalog : isAcpSelection ? !acpOptions : !displayedModel + isNativeModelSelection ? configLoadState === 'loading' || !modelCatalog : isAcpSelection ? !acpOptions : !displayedModel ); useEffect(() => { // An ACP response may hand model ownership entirely to its separate mode control. @@ -1890,14 +1897,13 @@ export const ModelSelector: React.FC = ({ )); const manualModelLabel = manualModelConfig?.model_name || getAvailabilityLabel(rememberedManualModelId ? 'target-model-unavailable' : nativeAvailability.status, t); - const displayedProviderGroup = providerGroups.find(group => group.models.some(model => ( - model.id === (isNativeModelSelection ? concreteModelId : currentModelId) - ))); - const openCurrentProvider = () => { - if (!displayedProviderGroup || disabled || loading || externalSelection?.disabled) return; + const displayedProviderGroup = providerGroups.find(group => group.key === selectedProviderKey); + const openCurrentProviderModels = () => { + if (disabled || loading || externalSelection?.disabled) return; openPanel('models', true); - openProviderLevel(displayedProviderGroup.key, true); + if (displayedProviderGroup) openProviderLevel(displayedProviderGroup.key, true); }; + const isSelectionPanel = activePanel === 'models' || activePanel === 'providers'; const acpModeLabel = acpMode?.option.options.find(option => option.value === acpMode.currentValue)?.name ?? acpMode?.currentValue ?? ''; const targetTooltip = buildContextUsageTooltip({ @@ -2055,16 +2061,16 @@ export const ModelSelector: React.FC = ({ data-keyboard-open={keyboardNavigationOpen ? 'true' : 'false'} data-placement={resolvedDropdownPlacement} data-open={dropdownOpen ? 'true' : 'false'} - data-menu-level={activeProviderGroup ? 'provider' : activePanel === 'models' ? 'models' : 'settings'} + data-menu-level={activeProviderGroup ? 'provider' : isSelectionPanel ? activePanel : 'settings'} data-model-mode={currentModelMode ?? 'manual'} aria-busy={modelInformationLoading || undefined} aria-hidden={!dropdownOpen} {...(!dropdownOpen ? { inert: '' } : {})} aria-label={activeProviderGroup?.providerName ?? (activePanel === 'modes' ? t('modelSelector.modes.selection') : t('modelSelector.modelSettings'))} - onKeyDown={activePanel === 'models' ? handlePanelKeyDown : handleDropdownKeyDown} + onKeyDown={isSelectionPanel ? handlePanelKeyDown : handleDropdownKeyDown} > - {!showModelLoadingState && activePanel !== 'models' && !activeProviderGroup + {!showModelLoadingState && !isSelectionPanel && !activeProviderGroup && (currentModelMode === 'smart' || currentModelMode === 'pool') && ( )} @@ -2078,7 +2084,7 @@ export const ModelSelector: React.FC = ({ > {t('modelSelector.status.loading')} - ) : activePanel === 'models' ? ( + ) : isSelectionPanel ? (
= ({ className="openbitfun-model-selector__selection-back" data-testid="chat-model-selector-summary-back" aria-label={t('modelSelector.backToSettings')} - aria-describedby={activePanel === 'models' ? `${menuId}-current-selection` : undefined} + aria-describedby={`${menuId}-current-selection`} leading={} - metadata={activePanel === 'models' ? ( + metadata={( = ({ {settingsModelLabel} - ) : undefined} + )} onClick={() => closePanel(true)} > - {t('modelSelector.model')} + {activePanel === 'providers' + ? displayedModel?.providerName || t('modelSelector.modelSettings') + : t('modelSelector.model')} )} - {(externalSelection || isAcpSession) && !activeProviderGroup ? ( + {(externalSelection || isAcpSession) && activePanel === 'models' && !activeProviderGroup ? ( <> {showModelChoices && ( = ({ data-testid="chat-model-selector-back" data-openbitfun-component="model-selector" data-openbitfun-part="back" - aria-label={t(providerFromSummary ? 'modelSelector.backToSettings' : 'modelSelector.backToProviders')} + aria-label={t(modelsFromSummary ? 'modelSelector.backToSettings' : 'modelSelector.backToProviders')} leading={} onClick={closeProviderLevel} > @@ -2179,77 +2187,10 @@ export const ModelSelector: React.FC = ({ ); })} - ) : availableModels.length === 0 ? ( - <>{renderUnavailableModelMenu(nativeAvailability, true)} + ) : providerGroups.length === 0 ? ( + <>{renderUnavailableModelMenu(displayedAvailability, isNativeModelSelection)} ) : ( <> - {(() => { - const primaryModel = allModels.find( - m => m.id === defaultModels.primary && isSelectableTextChatModel(m), - ); - const primaryTooltip = primaryModel - ? buildResolvedModelTooltipText(primaryModel.model_name, { - providerName: getProviderDisplayName(primaryModel), - contextWindow: primaryModel.context_window - }, t('modelSelector.primaryModelDesc')) - : t('modelSelector.primaryModelDesc'); - return ( - - : null} - disabled={!primaryModel} - onClick={() => handleSelectModelFromMenu('primary')} - > - {t('modelSelector.primaryModel')} - - - ); - })()} - - {(() => { - const fastModel = allModels.find( - m => m.id === defaultModels.fast && isSelectableTextChatModel(m), - ) ?? allModels.find( - m => m.id === defaultModels.primary && isSelectableTextChatModel(m), - ); - const fastTooltip = fastModel - ? buildResolvedModelTooltipText(fastModel.model_name, { - providerName: getProviderDisplayName(fastModel), - contextWindow: fastModel.context_window - }, t('modelSelector.fastModelDesc')) - : t('modelSelector.fastModelDesc'); - return ( - - : null} - onClick={() => handleSelectModelFromMenu('fast')} - > - {t('modelSelector.fastModel')} - - - ); - })()} - - - {providerGroups.map(group => { const isSelected = selectedProviderKey === group.key; @@ -2355,8 +2296,13 @@ export const ModelSelector: React.FC = ({ data-testid="chat-model-selector-settings-model" data-model-menu-target="models" aria-label={`${t('modelSelector.model')}: ${settingsModelLabel}`} - onClick={() => openPanelFromClick('models')} - onKeyDown={(event) => handlePanelTriggerKeyDown('models', event)} + onClick={openCurrentProviderModels} + onKeyDown={event => { + if (event.key !== 'ArrowRight') return; + event.preventDefault(); + event.stopPropagation(); + openCurrentProviderModels(); + }} disabled={disabled || loading || externalSelection?.disabled} > @@ -2397,15 +2343,11 @@ export const ModelSelector: React.FC = ({ className="openbitfun-model-selector__provider-control" labelBehavior="static" data-testid="chat-model-selector-settings-provider" + data-model-menu-target="providers" data-provider-key={displayedProviderGroup?.key} - disabled={disabled || loading || externalSelection?.disabled || !displayedProviderGroup} - onClick={openCurrentProvider} - onKeyDown={event => { - if (event.key !== 'ArrowRight') return; - event.preventDefault(); - event.stopPropagation(); - openCurrentProvider(); - }} + disabled={disabled || loading || externalSelection?.disabled || providerGroups.length === 0} + onClick={() => openPanelFromClick('providers')} + onKeyDown={event => handlePanelTriggerKeyDown('providers', event)} > {displayedModel.providerName} diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx index 1f1ecd0d4e..625c8aa7fe 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx @@ -8,6 +8,7 @@ * both, and the agent that has fixed the mode and left exactly one choice. */ +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -108,6 +109,7 @@ describe('ModelSelector ACP mode picker', () => { let root: Root; beforeEach(() => { + activateSurface('local'); flowChatStoreMocks.sessions.clear(); flowChatStoreMocks.sessions.set('acp-session', { workspacePath: '/tmp/project', @@ -375,7 +377,7 @@ describe('ModelSelector ACP mode picker', () => { await act(async () => { button?.click(); }); }; - it('opens only the current ACP provider and switches models through the remote adapter', async () => { + it.each(['model', 'provider'])('selects ACP models through the %s entry and its transport', async entry => { Object.assign(flowChatStoreMocks.sessions.get('acp-session')!, { remoteConnectionId: 'ssh-connection', remoteSshHost: 'a100', }); @@ -389,7 +391,17 @@ describe('ModelSelector ACP mode picker', () => { }; await renderWithOptions([reasoningOption], sessionOptions); await click('chat-model-selector-btn'); - await click('chat-model-selector-settings-provider'); + await click(`chat-model-selector-settings-${entry}`); + if (entry === 'provider') { + const providers = document.body.querySelectorAll('[data-testid="chat-model-selector-provider"]'); + expect(providers).toHaveLength(2); + expect(document.body.querySelector('[data-testid="chat-model-selector-option"]')).toBeNull(); + const currentProvider = document.body.querySelector( + '[data-testid="chat-model-selector-provider"][data-provider-key="acp:Acme"]', + )!; + expect(currentProvider.getAttribute('data-selected')).toBe('true'); + await act(async () => currentProvider.click()); + } expect(document.body.querySelector('[data-testid="chat-model-selector-provider"]')).toBeNull(); expect(document.body.querySelector('[data-model-id="other-model"]')).toBeNull(); @@ -420,7 +432,7 @@ describe('ModelSelector ACP mode picker', () => { expect(document.body.querySelector('[data-testid="chat-model-selector-settings-model"]')).not.toBeNull(); await click('chat-model-selector-settings-model'); expect(document.body.querySelectorAll('[data-testid="chat-model-selector-option"]')).toHaveLength(1); - await click('chat-model-selector-summary-back'); + await click('chat-model-selector-back'); await click('chat-model-selector-settings-reasoning'); expect(document.body.querySelector('[data-openbitfun-value="preset:auto"]')).toBeNull(); vi.mocked(ACPClientAPI.setSessionConfigOption).mockResolvedValue({ diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 056259ddec..19b1769c4b 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -143,6 +144,7 @@ describe('ModelSelector external transport reuse', () => { let catalogUpdated: (() => void) | undefined; beforeEach(() => { + activateSurface('local'); catalogUpdated = undefined; flowChatStoreMocks.sessions.clear(); flowChatStoreMocks.subscribers.clear(); @@ -311,11 +313,6 @@ describe('ModelSelector external transport reuse', () => { '[data-testid="chat-model-selector-settings-model"]', )?.click(); }); - await act(async () => { - document.body.querySelector( - '[data-testid="chat-model-selector-provider"][data-provider-key="provider-shared"]', - )?.click(); - }); await act(async () => { document.body.querySelector( '[data-testid="chat-model-selector-option"][data-model-id="model-b"]', @@ -739,11 +736,21 @@ describe('ModelSelector external transport reuse', () => { await act(async () => { control?.click(); }); }; - it('opens only the current target provider and applies choices through its transport', async () => { + it.each(['model', 'provider'])('selects target models through the %s entry and its transport', async entry => { const selection = targetSelection({ models: ['model-a', 'remote-model', 'remote-plain'] }); await renderTarget(selection); await clickControl('chat-model-selector-btn'); - await clickControl('chat-model-selector-settings-provider'); + await clickControl(`chat-model-selector-settings-${entry}`); + if (entry === 'provider') { + const providers = document.body.querySelectorAll('[data-testid="chat-model-selector-provider"]'); + expect(providers).toHaveLength(2); + expect(document.body.querySelector('[data-testid="chat-model-selector-option"]')).toBeNull(); + const currentProvider = document.body.querySelector( + '[data-testid="chat-model-selector-provider"][data-provider-key="external:a100"]', + )!; + expect(currentProvider.getAttribute('data-selected')).toBe('true'); + await act(async () => currentProvider.click()); + } expect(document.body.querySelector('[data-testid="chat-model-selector-provider"]')).toBeNull(); expect(document.body.querySelector('[data-model-id="model-a"]')).toBeNull(); diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx index 68f72bdad1..d08cbfff74 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { act, cloneElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -147,11 +148,13 @@ describe('ModelSelector provider levels', () => { const openMenu = async () => { await openSettingsMenu(); - const settingsModel = document.body.querySelector( + const entry = document.body.querySelector( + '[data-testid="chat-model-selector-settings-provider"]:not(:disabled)', + ) ?? document.body.querySelector( '[data-testid="chat-model-selector-settings-model"]', ); - if (settingsModel) { - await act(async () => settingsModel.click()); + if (entry) { + await act(async () => entry.click()); } }; @@ -210,6 +213,11 @@ describe('ModelSelector provider levels', () => { }; beforeEach(() => { + activateSurface('local'); + vi.mocked(configManager.getConfigs).mockReset(); + vi.mocked(configManager.setConfig).mockReset().mockResolvedValue(undefined); + aiApiMocks.getModelCatalog.mockReset(); + vi.mocked(agentAPI.updateSessionModel).mockReset().mockResolvedValue(undefined); notificationStore.clearActiveNotifications(); notificationStore.clearHistory(); flowChatStoreMocks.sessions.clear(); @@ -338,7 +346,7 @@ describe('ModelSelector provider levels', () => { })); await act(async () => { aiApiMocks.onModelCatalogUpdated.mock.calls[0][0](); }); - expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-busy')).toBe('true'); + expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.hasAttribute('aria-busy')).toBe(false); expect(document.body.querySelector('[data-testid="chat-model-selector-loading"]')).toBeNull(); expect(document.body.querySelector('[data-testid="chat-model-selector-settings-model"]')).toBe(modelField); expect(modelField?.textContent).toContain('acme-fast-native'); @@ -418,40 +426,41 @@ describe('ModelSelector provider levels', () => { }); - it('opens the resolved primary provider directly and returns focus to its shortcut', async () => { - await renderSelector(); + it.each(['primary', 'fast', 'acme-deep'])('opens the current provider models from the model entry for %s', async modelId => { + await renderSelector(CATALOG_MODELS, modelId); await openSettingsMenu(); - const providerControl = () => document.body.querySelector( - '[data-testid="chat-model-selector-settings-provider"]', + const modelControl = () => document.body.querySelector( + '[data-testid="chat-model-selector-settings-model"]', )!; - expect(providerControl().textContent).toBe('Acme'); - await act(async () => providerControl().click()); + const isUmbra = modelId === 'fast'; + const selectedModelId = modelId === 'primary' ? 'acme-fast' : isUmbra ? 'umbra-main' : modelId; + await act(async () => modelControl().click()); expect(document.body.querySelector('[data-testid="chat-model-selector-menu"]') ?.getAttribute('data-menu-level')).toBe('provider'); expect(providerRows()).toHaveLength(0); - expect(modelOption('acme-fast')?.getAttribute('aria-checked')).toBe('true'); - expect(document.activeElement).toBe(modelOption('acme-fast')); - expect(modelOption('acme-deep')).not.toBeNull(); - expect(modelOption('umbra-main')).toBeNull(); + expect(modelOption(selectedModelId)?.getAttribute('aria-checked')).toBe('true'); + expect(document.activeElement).toBe(modelOption(selectedModelId)); + expect(Boolean(modelOption('acme-deep'))).toBe(!isUmbra); + expect(Boolean(modelOption('umbra-main'))).toBe(isUmbra); expect(modelOption('primary')).toBeNull(); await act(async () => document.body.querySelector( '[data-testid="chat-model-selector-back"]', )!.click()); expect(optionsPanel()).toBeNull(); - expect(document.activeElement).toBe(providerControl()); + expect(document.activeElement).toBe(modelControl()); - await act(async () => providerControl().dispatchEvent( + await act(async () => modelControl().dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), )); - expect(modelOption('acme-deep')).not.toBeNull(); + expect(Boolean(modelOption('acme-deep'))).toBe(!isUmbra); await act(async () => optionsPanel()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), )); expect(optionsPanel()).toBeNull(); - expect(document.activeElement).toBe(providerControl()); + expect(document.activeElement).toBe(modelControl()); expect(configManager.setConfig).not.toHaveBeenCalled(); }); @@ -766,7 +775,7 @@ describe('ModelSelector provider levels', () => { { id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [{ type: 'effort', value: 'high' }] }, ] } }], }); - await renderSelector(); + await renderSelector(CATALOG_MODELS, 'acme-fast'); await openSettingsMenu(); const sky = document.body.querySelector('[data-openbitfun-part="reasoningSliderSky"]'); expect(sky).not.toBeNull(); @@ -854,7 +863,7 @@ describe('ModelSelector provider levels', () => { expect(document.activeElement).toBe(autoChoice); }); - it('offers providers first and keeps the symbolic selectors on that level', async () => { + it('opens only providers from the provider entry and marks the resolved default provider', async () => { await renderSelector(); await openMenu(); @@ -867,32 +876,111 @@ describe('ModelSelector provider levels', () => { expect(document.body.querySelector( '[data-testid="chat-model-selector-settings"]', )).toBeNull(); - expect(optionsPanel()?.dataset.panelKind).toBe('models'); + expect(optionsPanel()?.dataset.panelKind).toBe('providers'); expect(sharedPanelItems()).not.toBeNull(); expect(providerRows().every(row => sharedPanelItems()?.contains(row))).toBe(true); - expect(sharedPanelItems()?.contains(modelOption('primary'))).toBe(true); - expect(sharedPanelItems()?.contains(modelOption('fast'))).toBe(true); expect(providerRows().map(row => row.dataset.providerKey)) .toEqual(['provider-acme', 'provider-umbra']); - expect(modelOption('primary')).not.toBeNull(); - expect(modelOption('fast')).not.toBeNull(); - const submenuButtons = Array.from( - sharedPanelItems()?.querySelectorAll('button') ?? [], - ); - expect(submenuButtons.indexOf(modelOption('primary')!)) - .toBeLessThan(submenuButtons.indexOf(modelOption('fast')!)); - expect(submenuButtons.indexOf(modelOption('fast')!)) - .toBeLessThan(submenuButtons.indexOf(providerRows()[0])); + expect(modelOption('primary')).toBeNull(); + expect(modelOption('fast')).toBeNull(); + expect(document.body.querySelectorAll('[data-testid="chat-model-selector-option"]')).toHaveLength(0); + expect(providerRows().find(row => row.dataset.selected === 'true')?.dataset.providerKey) + .toBe('provider-acme'); expect(document.body.querySelector( '[data-testid="chat-model-selector-provider-selected-model"]', )).toBeNull(); // A concrete model is only reachable through its provider now. expect(modelOption('acme-deep')).toBeNull(); expect(modelOption('umbra-main')).toBeNull(); + await act(async () => document.body.querySelector( + '[data-testid="chat-model-selector-summary-back"]', + )!.click()); + const providerControl = document.body.querySelector( + '[data-testid="chat-model-selector-settings-provider"]', + )!; + expect(document.activeElement).toBe(providerControl); + await act(async () => providerControl.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + )); + expect(document.activeElement).toBe(providerRows()[0]); + await act(async () => { + providerRows()[1].focus(); + providerRows()[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + }); + expect(modelOption('umbra-main')).not.toBeNull(); + expect(modelOption('acme-fast')).toBeNull(); + await act(async () => optionsPanel()!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), + )); + expect(document.activeElement).toBe(providerRows()[1]); + expect(configManager.setConfig).not.toHaveBeenCalled(); + }); + + it('commits the session before remembering defaults and leaves the selector usable during that save', async () => { + flowChatStoreMocks.sessions.set('session-a', { config: { modelName: 'umbra-main' } }); + let commitSession!: () => void; + let saveDefault!: () => void; + vi.mocked(agentAPI.updateSessionModel).mockReturnValueOnce(new Promise(resolve => { commitSession = resolve; })); + vi.mocked(configManager.setConfig).mockReturnValueOnce(new Promise(resolve => { saveDefault = resolve; })); + await renderSelector(CATALOG_MODELS, 'umbra-main', 'session-a'); + await openMenu(); + await openProvider('provider-acme'); + await act(async () => modelOption('acme-deep')!.click()); + expect(agentAPI.updateSessionModel).toHaveBeenCalledTimes(1); + expect(configManager.setConfig).not.toHaveBeenCalled(); + await act(async () => commitSession()); + expect(configManager.setConfig).toHaveBeenCalledWith('ai.agent_model_defaults.mode', 'acme-deep'); + expect(optionsPanel()).toBeNull(); + expect(container.querySelector('[data-testid="chat-model-selector-btn"]')!.disabled).toBe(false); + expect(flowChatStoreMocks.sessions.get('session-a')?.config.modelName).toBe('acme-deep'); + await act(async () => saveDefault()); + }); + + it('preserves a committed session selection when remembering the default fails', async () => { + flowChatStoreMocks.sessions.set('session-a', { config: { modelName: 'umbra-main' } }); + vi.mocked(configManager.setConfig).mockRejectedValueOnce(new Error('default save failed')); + await renderSelector(CATALOG_MODELS, 'umbra-main', 'session-a'); + await openMenu(); + await openProvider('provider-acme'); + await act(async () => modelOption('acme-deep')!.click()); + expect(flowChatStoreMocks.sessions.get('session-a')?.config.modelName).toBe('acme-deep'); + expect(notificationStore.getState().activeNotifications.some(item => item.message === 'modelSelector.defaultSaveFailed')).toBe(true); + expect(optionsPanel()).toBeNull(); + }); + + it('skips an identical model and preset but clears a preset when entering an automatic selection mode', async () => { + flowChatStoreMocks.sessions.set('session-a', { config: { modelName: 'acme-fast' } }); + await renderSelector(CATALOG_MODELS, 'acme-fast', 'session-a'); + await openMenu(); + await openProvider('provider-acme'); + await act(async () => modelOption('acme-fast')!.click()); + expect(agentAPI.updateSessionModel).not.toHaveBeenCalled(); + expect(configManager.setConfig).not.toHaveBeenCalled(); + flowChatStoreMocks.sessions.get('session-a')!.config.modelName = 'primary'; + flowChatStoreMocks.sessions.get('session-a')!.config.reasoningPreset = 'high'; + await act(async () => flowChatStoreMocks.subscribers.forEach(callback => callback())); + await act(async () => modeTrigger().click()); + await act(async () => modeOption('smart')!.click()); + expect(agentAPI.updateSessionModel).toHaveBeenCalledWith(expect.objectContaining({ modelName: 'primary', reasoningPreset: null })); + await act(async () => modeTrigger().click()); + await act(async () => modeOption('pool')!.click()); + expect(agentAPI.updateSessionModel).toHaveBeenCalledTimes(1); + }); + + it('rolls back an unpinned selection and context limit when the session update fails', async () => { + flowChatStoreMocks.sessions.set('session-a', { config: {} }); + vi.mocked(agentAPI.updateSessionModel).mockRejectedValueOnce(new Error('session write failed')); + await renderSelector(CATALOG_MODELS, 'umbra-main', 'session-a'); + await openMenu(); + await openProvider('provider-acme'); + await act(async () => modelOption('acme-deep')!.click()); + expect(flowChatStoreMocks.store.updateSessionModelName).toHaveBeenLastCalledWith('session-a', undefined); + expect(flowChatStoreMocks.store.updateSessionMaxContextTokens).toHaveBeenLastCalledWith('session-a', undefined); + expect(configManager.setConfig).not.toHaveBeenCalled(); }); it('shows only the chosen provider\'s models and applies a selection', async () => { - await renderSelector(); + await renderSelector(CATALOG_MODELS, 'umbra-main'); await openMenu(); await openProvider('provider-acme'); @@ -904,7 +992,7 @@ describe('ModelSelector provider levels', () => { expect(sharedPanelItems()).not.toBeNull(); expect(sharedPanelItems()?.contains(modelOption('acme-fast'))).toBe(true); expect(sharedPanelItems()?.contains(modelOption('acme-deep'))).toBe(true); - // The symbolic selectors belong to the provider level and are not repeated. + // Model choices are concrete models owned by this provider. expect(modelOption('primary')).toBeNull(); const card = document.body.querySelector('[data-testid="chat-model-selector-menu"]'); @@ -925,12 +1013,12 @@ describe('ModelSelector provider levels', () => { expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); expect(document.body.querySelector('[data-testid="chat-model-selector-menu"]')).toBe(card); expect(optionsPanel()).toBeNull(); - expect(document.activeElement).toBe(document.body.querySelector('[data-testid="chat-model-selector-settings-model"]')); + expect(document.activeElement).toBe(document.body.querySelector('[data-testid="chat-model-selector-settings-provider"]')); await act(async () => document.body.querySelector( '[data-testid="chat-model-selector-settings-model"]', )!.click()); - expect(document.body.querySelector('[data-testid="chat-model-selector-current-selection"]')?.textContent) - .toBe('acme-deep-native'); + expect(modelOption('acme-deep')?.getAttribute('aria-checked')).toBe('true'); + expect(modelOption('umbra-main')).toBeNull(); }); it('marks the provider that owns the pinned model without repeating the model beneath it', async () => { @@ -975,7 +1063,7 @@ describe('ModelSelector provider levels', () => { await openProvider('provider-acme'); expect(modelOption('acme-deep')).not.toBeNull(); - // Reopening starts at the summary; opening models resets the provider level. + // Reopening starts at the summary; opening models goes straight to the current provider. await act(async () => { container.querySelector( '[data-testid="chat-model-selector-btn"]', @@ -990,8 +1078,8 @@ describe('ModelSelector provider levels', () => { '[data-testid="chat-model-selector-settings-model"]', )!.click()); expect(optionsPanel()?.dataset.panelKind).toBe('models'); - expect(providerRows()).toHaveLength(2); - expect(modelOption('acme-deep')).toBeNull(); + expect(providerRows()).toHaveLength(0); + expect(modelOption('acme-deep')).not.toBeNull(); }); it('keeps the selector visible and actionable when no model is configured', async () => { @@ -1177,11 +1265,11 @@ describe('ModelSelector provider levels', () => { .toContain('first-model-native'); const listeners = [...flowChatStoreMocks.configChangeListeners]; - expect(listeners).toHaveLength(1); + expect(listeners).toHaveLength(2); await act(async () => { // ModelSettingsPage writes ai.models through ConfigManager's ai-scoped // mutation notification, so this is the post-save event to handle. - listeners[0]?.('ai'); + listeners.forEach(listener => listener('ai')); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -1195,7 +1283,7 @@ describe('ModelSelector provider levels', () => { }); }); - it('lets Escape step from a provider to the model list, then the summary, then close', async () => { + it('lets Escape step from provider models to the provider list, then the summary, then close', async () => { await renderSelector(); await openMenu(); await openProvider('provider-acme'); @@ -1215,16 +1303,16 @@ describe('ModelSelector provider levels', () => { expect(document.body.querySelector( '[data-testid="chat-model-selector-settings"]', )).not.toBeNull(); - const modelField = document.body.querySelector( - '[data-testid="chat-model-selector-settings-model"]', + const providerField = document.body.querySelector( + '[data-testid="chat-model-selector-settings-provider"]', )!; - expect(document.activeElement).toBe(modelField); + expect(document.activeElement).toBe(providerField); const trigger = container.querySelector( '[data-testid="chat-model-selector-btn"]', )!; expect(trigger.getAttribute('aria-expanded')).toBe('true'); - await act(async () => modelField.dispatchEvent( + await act(async () => providerField.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), )); expect(trigger.getAttribute('aria-expanded')).toBe('false'); @@ -1316,7 +1404,7 @@ describe('ModelSelector provider levels', () => { expect(document.body.querySelectorAll('[role="menu"]')).toHaveLength(1); await act(async () => document.body.querySelector( - '[data-testid="chat-model-selector-summary-back"]', + '[data-testid="chat-model-selector-back"]', )!.click()); const reasoningRow = document.body.querySelector( '[data-testid="chat-model-selector-settings-reasoning"]', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index d472c39507..5ffa44f48b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -1241,6 +1241,8 @@ describe('MessageModule model synchronization', () => { }); const { context, session } = modelSyncContext('asdf'); await syncSessionModelSelection(context, session.sessionId, 'Standard'); + expect(mockGetConfigs).toHaveBeenCalledTimes(1); + expect(mockGetConfigs).toHaveBeenCalledWith(['ai.models', 'ai.default_models']); expect(context.flowChatStore.updateSessionModelName).not.toHaveBeenCalled(); expect(context.flowChatStore.updateSessionMaxContextTokens).toHaveBeenCalledWith(session.sessionId, 64000); expect(mockUpdateSessionModel).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 893a382bd2..2fbe6ac04a 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -4669,12 +4669,12 @@ export class FlowChatStore { }); } - public updateSessionModelName(sessionId: string, modelName: string): void { + public updateSessionModelName(sessionId: string, modelName: string | undefined): void { this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; - const normalizedModelName = modelName.trim() || 'primary'; + const normalizedModelName = modelName === undefined ? undefined : modelName.trim() || 'primary'; if (session.config.modelName?.trim() === normalizedModelName) { return prev; } @@ -6788,7 +6788,7 @@ export class FlowChatStore { public rollbackTokenUsage(): void { } - public updateSessionMaxContextTokens(sessionId: string, maxContextTokens: number): void { + public updateSessionMaxContextTokens(sessionId: string, maxContextTokens: number | undefined): void { this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.test.ts b/src/web-ui/src/flow_chat/utils/modelResolution.test.ts index 4576deaf39..4bc8fb1fbd 100644 --- a/src/web-ui/src/flow_chat/utils/modelResolution.test.ts +++ b/src/web-ui/src/flow_chat/utils/modelResolution.test.ts @@ -75,6 +75,7 @@ describe('configured model identity', () => { 'ai.models': models, 'ai.default_models': { primary: 'model-first' }, }); await expect(getModelMaxTokens('asdf')).resolves.toBe(64000); + expect(configManager.getConfigs).toHaveBeenLastCalledWith(['ai.models', 'ai.default_models']); }); }); diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.ts b/src/web-ui/src/flow_chat/utils/modelResolution.ts index 4ffe050eab..b80d8599b0 100644 --- a/src/web-ui/src/flow_chat/utils/modelResolution.ts +++ b/src/web-ui/src/flow_chat/utils/modelResolution.ts @@ -162,44 +162,33 @@ export function resolveModelSelection({ }; } +/** Resolve from already loaded facts so selection and send do not read settings twice. */ +export function resolveModelMaxTokens( + modelName: string | undefined, + models: readonly AIModelConfig[], + defaultModels: DefaultModelsConfig, + modeDefaultModel?: string, +): number { + const selection = modelName?.trim() || modeDefaultModel; + return resolveModelForContextWindow(selection, models, defaultModels)?.context_window + || resolveModelForContextWindow('primary', models, defaultModels)?.context_window + || 128128; +} + export async function getModelMaxTokens(modelName?: string, agentType?: string): Promise { try { const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); const configData = await configManager.getConfigs([ 'ai.models', 'ai.default_models', - 'ai.agent_model_defaults', + ...(!modelName?.trim() ? ['ai.agent_model_defaults'] : []), ]); - const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; - const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - - const normalizedModelName = modelName?.trim(); - const explicitModel = resolveModelForContextWindow(modelName, models, defaultModels); - if (explicitModel?.context_window) { - return explicitModel.context_window; - } - - // Only legacy sessions without a model selector inherit the current mode - // default. Session-owned selectors are resolved above. - if (!normalizedModelName) { - const modeModel = resolveModelForContextWindow( - agentModelDefaults?.mode, - models, - defaultModels, - ); - if (modeModel?.context_window) { - return modeModel.context_window; - } - } - - const primaryModel = resolveModelForContextWindow('primary', models, defaultModels); - if (primaryModel?.context_window) { - return primaryModel.context_window; - } - - log.debug('Model context_window config not found, using default', { modelName, agentType }); - return 128128; + return resolveModelMaxTokens( + modelName, + (configData['ai.models'] as AIModelConfig[] | undefined) || [], + (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}, + (configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined)?.mode, + ); } catch (error) { log.warn('Failed to get model max tokens', { modelName, agentType, error }); return 128128; @@ -214,7 +203,7 @@ export async function resolveReasoningPresetForSessionCreation( const configData = await configManager.getConfigs([ 'ai.models', 'ai.default_models', - 'ai.agent_model_defaults', + ...(!modelName?.trim() ? ['ai.agent_model_defaults'] : []), ]); const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; diff --git a/src/web-ui/src/flow_chat/utils/modelSync.ts b/src/web-ui/src/flow_chat/utils/modelSync.ts index 639120a43f..4bf1a8bf06 100644 --- a/src/web-ui/src/flow_chat/utils/modelSync.ts +++ b/src/web-ui/src/flow_chat/utils/modelSync.ts @@ -10,7 +10,7 @@ import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext } from '../services/flow-chat-manager/types'; -import { getModelMaxTokens, resolveModelReference } from './modelResolution'; +import { resolveModelMaxTokens, resolveModelReference } from './modelResolution'; import { sessionProjectWorkspacePath, sessionWorkspaceId } from './sessionWorkspace'; import { getActiveSurfaceScope, @@ -52,8 +52,9 @@ export async function syncSessionModelSelection( throw new Error(`Session does not exist: ${sessionId}`); } + const sessionModelId = session.config.modelName?.trim(); const configData = (await configManager.getConfigs([ - 'ai.agent_model_defaults', + ...(!sessionModelId ? ['ai.agent_model_defaults'] : []), 'ai.models', 'ai.default_models', ])) ?? {}; @@ -62,10 +63,9 @@ export async function syncSessionModelSelection( const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const sessionModelId = session.config.modelName?.trim(); const requestedModelId = sessionModelId || agentModelDefaults?.mode; const desiredModelId = normalizeModelSelection(requestedModelId, allModels, defaultModels); - const desiredMaxContextTokens = await getModelMaxTokens(desiredModelId, agentType); + const desiredMaxContextTokens = resolveModelMaxTokens(desiredModelId, allModels, defaultModels); surfaceScope.assertCurrent('resolve session model context window'); const shouldSyncContextWindow = session.maxContextTokens !== desiredMaxContextTokens; diff --git a/src/web-ui/src/infrastructure/config/AGENTS.md b/src/web-ui/src/infrastructure/config/AGENTS.md index f78ad52237..8ec95b019f 100644 --- a/src/web-ui/src/infrastructure/config/AGENTS.md +++ b/src/web-ui/src/infrastructure/config/AGENTS.md @@ -52,3 +52,14 @@ pnpm --dir src/web-ui run test:run src/infrastructure/config/components/modelEdi These checks cover draft isolation and source contracts, not rendered visuals or remote runtime behavior. + +For the device-scoped model catalog and non-blocking session/default selection: + +```bash +pnpm --dir src/web-ui run test:run src/infrastructure/config/services/ModelCatalogStore.test.ts src/infrastructure/config/services/ConfigManager.test.ts src/flow_chat/components/ModelSelectorProviderLevels.test.tsx src/flow_chat/components/ModelSelectorExternal.test.tsx src/flow_chat/components/ModelSelectorAcpMode.test.tsx src/flow_chat/utils/modelResolution.test.ts src/flow_chat/services/flow-chat-manager/MessageModule.test.ts src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +``` + +Keep warm catalogs while refreshing, invalidate them only for catalog/model +facts, and isolate all snapshots and pending work by surface activation. An +existing session commits its selection before remembering future defaults; +explicit model reads before send must not await the future-default write. diff --git a/src/web-ui/src/infrastructure/config/components/ModelTagsField.tsx b/src/web-ui/src/infrastructure/config/components/ModelTagsField.tsx index cd7339c9a7..1051f95178 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelTagsField.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelTagsField.tsx @@ -1,4 +1,4 @@ -import { Button, Field, Icon, IconButton, Input, StatusPill, Tooltip } from '@openbitfun/ui'; +import { Button, Capsule, Field, Icon, IconButton, Input, StatusPill, Tooltip } from '@openbitfun/ui'; import { useRef, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { MAX_MODEL_USER_TAGS, normalizeModelTags } from '../services/modelTags'; @@ -90,46 +90,46 @@ export default function ModelTagsField({ tags, recommendedTags, disabled, layout
{tags.map(tag => ( -
- {tagDraft?.originalTag === tag ? tagInput : ( - <> - - - { - onChange(tags.filter(value => value !== tag)); - restoreTagFocus(tags.find(value => value !== tag)); - }} - icon={} - /> - - + shape="circle" + variant="quiet" + aria-label={t('pool.removeTag', { tag })} + disabled={disabled} + onClick={() => { + onChange(tags.filter(value => value !== tag)); + restoreTagFocus(tags.find(value => value !== tag)); + }} + icon={} + /> + + )}> + {tagDraft?.originalTag === tag ? tagInput : ( + )} -
+ ))} {tagDraft?.originalTag === null && ( -
+ {tagInput} -
+ )}
diff --git a/src/web-ui/src/infrastructure/config/services/ModelCatalogStore.test.ts b/src/web-ui/src/infrastructure/config/services/ModelCatalogStore.test.ts new file mode 100644 index 0000000000..19a435883b --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/ModelCatalogStore.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { aiApi, type AIModelCatalog } from '@/infrastructure/api/service-api/AIApi'; +import { configManager } from './ConfigManager'; +import { createModelCatalogStore } from './ModelCatalogStore'; + +vi.mock('@/infrastructure/api/service-api/AIApi', () => ({ aiApi: { + getModelCatalog: vi.fn(), onModelCatalogUpdated: vi.fn(() => () => {}), +} })); +vi.mock('./ConfigManager', () => ({ configManager: { + onConfigChange: vi.fn(() => () => {}), +} })); +const catalog = (version: number): AIModelCatalog => ({ version, models: [] }); +const deferred = () => { + let resolve!: (value: AIModelCatalog) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +}; +describe('shared model catalog', () => { + beforeEach(() => { activateSurface('local'); vi.clearAllMocks(); }); + + it('shares concurrent selectors, retains warm data on errors, and ignores preference writes', async () => { + vi.mocked(aiApi.getModelCatalog).mockResolvedValue(catalog(1)); + const store = createModelCatalogStore(); + const offA = store.subscribe(vi.fn()); + const offB = store.subscribe(vi.fn()); + await vi.waitFor(() => expect(store.getSnapshot().status).toBe('ready')); + expect(aiApi.getModelCatalog).toHaveBeenCalledTimes(1); + vi.mocked(configManager.onConfigChange).mock.calls[0][0]('ai.agent_model_defaults.mode'); + expect(aiApi.getModelCatalog).toHaveBeenCalledTimes(1); + vi.mocked(aiApi.getModelCatalog).mockRejectedValueOnce(new Error('offline')); + await store.refresh(); + expect(store.getSnapshot()).toEqual({ catalog: catalog(1), status: 'ready' }); + offA(); offB(); + }); + + it('replays an invalidation arriving during a read and discards the superseded result', async () => { + const first = deferred(); + vi.mocked(aiApi.getModelCatalog).mockReturnValueOnce(first.promise).mockResolvedValue(catalog(2)); + const store = createModelCatalogStore(); + const off = store.subscribe(vi.fn()); + await Promise.resolve(); + vi.mocked(configManager.onConfigChange).mock.calls[0][0]('ai.models'); + first.resolve(catalog(1)); + await vi.waitFor(() => expect(store.getSnapshot().catalog?.version).toBe(2)); + expect(aiApi.getModelCatalog).toHaveBeenCalledTimes(2); + off(); + }); + + it('never publishes an old host response on a new activation', async () => { + const old = deferred(); + vi.mocked(aiApi.getModelCatalog).mockReturnValueOnce(old.promise).mockResolvedValue(catalog(2)); + const store = createModelCatalogStore(); + const off = store.subscribe(vi.fn()); + await Promise.resolve(); + activateSurface('peer'); + expect(store.getSnapshot().catalog).toBeNull(); + await vi.waitFor(() => expect(store.getSnapshot().catalog?.version).toBe(2)); + old.resolve(catalog(1)); + await Promise.resolve(); + expect(store.getSnapshot().catalog?.version).toBe(2); + off(); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/ModelCatalogStore.ts b/src/web-ui/src/infrastructure/config/services/ModelCatalogStore.ts new file mode 100644 index 0000000000..2f023b5153 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/ModelCatalogStore.ts @@ -0,0 +1,84 @@ +import { aiApi, type AIModelCatalog } from '@/infrastructure/api/service-api/AIApi'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { createLogger } from '@/shared/utils/logger'; +import { configManager } from './ConfigManager'; + +const log = createLogger('ModelCatalogStore'); +type Snapshot = { catalog: AIModelCatalog | null; status: 'loading' | 'ready' | 'error' }; +const EMPTY: Snapshot = { catalog: null, status: 'loading' }; + +/** One catalog projection per device activation. Refreshes keep the last good + * projection usable; a config/catalog event during a read schedules another + * read so an older response cannot consume a newer invalidation. + */ +export function createModelCatalogStore() { + let epoch = -1; + let snapshot = EMPTY; + let revision = 0; + let pending: Promise | undefined; + let stop: (() => void) | undefined; + const listeners = new Set<() => void>(); + const notify = () => listeners.forEach(listener => listener()); + const getSnapshot = () => epoch === getActiveSurfaceScope().epoch ? snapshot : EMPTY; + + const refresh = (): Promise => { + const scope = getActiveSurfaceScope(); + if (epoch !== scope.epoch) { + epoch = scope.epoch; + snapshot = EMPTY; + pending = undefined; + } + revision += 1; + if (pending) return pending; + const read = async () => { + do { + const requestedRevision = revision; + try { + scope.assertCurrent('load model catalog'); + const catalog = await aiApi.getModelCatalog(); + if (!scope.isCurrent()) return; + if (requestedRevision !== revision) continue; + snapshot = { catalog, status: 'ready' }; + } catch (error) { + if (!scope.isCurrent()) return; + if (requestedRevision !== revision) continue; + snapshot = { catalog: snapshot.catalog, status: snapshot.catalog ? 'ready' : 'error' }; + log.warn('Failed to refresh model catalog', { error }); + } + notify(); + return; + } while (scope.isCurrent()); + }; + // Publish the promise before calling any loader that can emit events. + const promise = Promise.resolve().then(read).finally(() => { + if (pending === promise) pending = undefined; + }); + pending = promise; + return promise; + }; + + const subscribe = (listener: () => void) => { + listeners.add(listener); + if (!stop) { + const offCatalog = aiApi.onModelCatalogUpdated(() => { void refresh(); }); + const offConfig = configManager.onConfigChange(path => { + if (!path || path === 'ai' || ['ai.models', 'ai.default_models'].some( + owned => path === owned || path.startsWith(`${owned}.`), + )) void refresh(); + }); + const offSurface = onSurfaceActivated(() => { + void refresh(); + notify(); + }); + stop = () => { offCatalog(); offConfig(); offSurface(); }; + void refresh(); + } + return () => { + listeners.delete(listener); + if (!listeners.size) { stop?.(); stop = undefined; } + }; + }; + return { subscribe, getSnapshot, refresh }; +} + +export const modelCatalogStore = createModelCatalogStore(); diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index be4f9094c3..43ea76a65d 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -3331,6 +3331,7 @@ "acpMode": "Mode", "acpModeLocked": "This conversation has already started, so its mode is fixed.", "acpModeFailed": "Failed to switch mode", + "defaultSaveFailed": "Model selected, but the default for future chats could not be saved.", "switchFailed": "Failed to switch model", "modelNotConfigured": "Not Configured", "openModelSettings": "Open model settings", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 335c390e4b..d6b0c1bb44 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -3331,6 +3331,7 @@ "acpMode": "模式", "acpModeLocked": "会话已经开始,模式不能再改。", "acpModeFailed": "切换模式失败", + "defaultSaveFailed": "模型已切换,但未能保存后续会话的默认选择。", "switchFailed": "切换模型失败", "modelNotConfigured": "未配置", "openModelSettings": "前往模型设置", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 23f90ac627..0da8ccc993 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -3331,6 +3331,7 @@ "acpMode": "模式", "acpModeLocked": "對話已經開始,模式不能再改。", "acpModeFailed": "切換模式失敗", + "defaultSaveFailed": "模型已切換,但未能儲存後續對話的預設選擇。", "switchFailed": "切換模型失敗", "modelNotConfigured": "未設定", "openModelSettings": "前往模型設定", diff --git a/third_party/tao/OPENBITFUN-PATCH.md b/third_party/tao/OPENBITFUN-PATCH.md index 9b7bb1d442..0ca53d76d2 100644 --- a/third_party/tao/OPENBITFUN-PATCH.md +++ b/third_party/tao/OPENBITFUN-PATCH.md @@ -23,6 +23,14 @@ Untitled and fullscreen style masks are excluded from the layout override; windows without a custom inset retain upstream behavior. No notification retry, timer, new private API or product-specific geometry is added. +The vendored macOS sources also receive compiler-warning maintenance: current +event constants and ivar reads, removal of unused private state/helpers and +redundant unsafe blocks, and explicit retention of validated per-window icon +data. The obsolete fullscreen button lookup is omitted because AppKit always +returns nil for it; the zoom button remains in the list. The legacy filename-list +pasteboard format is retained at its three registration/read sites, with local +deprecation annotations, so this cleanup does not change drag payload semantics. + OpenBitFun keeps its original toolbar height and tab spacing. The desktop host derives the configured inset from the shared design height and native metrics; the framework owns applying that inset during its native window lifecycle. diff --git a/third_party/tao/src/platform/macos.rs b/third_party/tao/src/platform/macos.rs index 32b8cbbf3f..1e58321b6a 100644 --- a/third_party/tao/src/platform/macos.rs +++ b/third_party/tao/src/platform/macos.rs @@ -416,7 +416,7 @@ impl EventLoopWindowTargetExtMacOS for EventLoopWindowTarget { fn show_application(&self) { // TODO: Safety. let mtm = unsafe { objc2_foundation::MainThreadMarker::new_unchecked() }; - unsafe { objc2_app_kit::NSApplication::sharedApplication(mtm).unhide(None) } + objc2_app_kit::NSApplication::sharedApplication(mtm).unhide(None) } fn hide_other_applications(&self) { diff --git a/third_party/tao/src/platform_impl/macos/app.rs b/third_party/tao/src/platform_impl/macos/app.rs index a83363eefe..ea218e5c59 100644 --- a/third_party/tao/src/platform_impl/macos/app.rs +++ b/third_party/tao/src/platform_impl/macos/app.rs @@ -35,7 +35,7 @@ extern "C" fn send_event(this: &NSApplication, _sel: Sel, event: &NSEvent) { // but that doesn't really matter here. let event_type = event.r#type(); let modifier_flags = event.modifierFlags(); - if event_type == appkit::NSKeyUp + if event_type == NSEventType::KeyUp && util::has_flag(modifier_flags, appkit::NSEventModifierFlags::Command) { if let Some(key_window) = this.keyWindow() { diff --git a/third_party/tao/src/platform_impl/macos/app_delegate.rs b/third_party/tao/src/platform_impl/macos/app_delegate.rs index 5107814c94..b2b92915da 100644 --- a/third_party/tao/src/platform_impl/macos/app_delegate.rs +++ b/third_party/tao/src/platform_impl/macos/app_delegate.rs @@ -137,11 +137,9 @@ extern "C" fn application_will_terminate(_: &Object, _: Sel, _: id) { extern "C" fn application_open_urls(_: &Object, _: Sel, _: id, urls: &NSArray) { trace!("Trigger `application:openURLs:`"); - let urls = unsafe { - (0..urls.count()) - .flat_map(|i| url::Url::parse(&urls.objectAtIndex(i).absoluteString().unwrap().to_string())) - .collect::>() - }; + let urls = (0..urls.count()) + .flat_map(|i| url::Url::parse(&urls.objectAtIndex(i).absoluteString().unwrap().to_string())) + .collect::>(); trace!("Get `application:openURLs:` URLs: {:?}", urls); AppState::open_urls(urls); trace!("Completed `application:openURLs:`"); diff --git a/third_party/tao/src/platform_impl/macos/event.rs b/third_party/tao/src/platform_impl/macos/event.rs index 6b1ce97f52..6063bc5202 100644 --- a/third_party/tao/src/platform_impl/macos/event.rs +++ b/third_party/tao/src/platform_impl/macos/event.rs @@ -162,7 +162,7 @@ pub fn create_key_event( //#[cfg(debug_assertions)] println!("Couldn't get key from code: {:?}", physical_key); key_without_modifiers = get_modifierless_char(scancode); - let modifiers = unsafe { NSEvent::modifierFlags(ns_event) }; + let modifiers = NSEvent::modifierFlags(ns_event); let has_alt = modifiers.contains(NSEventModifierFlags::Option); let has_ctrl = modifiers.contains(NSEventModifierFlags::Control); if has_alt || has_ctrl || text_with_all_modifiers.is_none() || !is_press { @@ -304,7 +304,7 @@ pub fn extra_function_key_to_code(scancode: u16, string: &str) -> KeyCode { } pub fn event_mods(event: &NSEvent) -> ModifiersState { - let flags = unsafe { NSEvent::modifierFlags(event) }; + let flags = NSEvent::modifierFlags(event); let mut m = ModifiersState::empty(); m.set( ModifiersState::SHIFT, diff --git a/third_party/tao/src/platform_impl/macos/event_loop.rs b/third_party/tao/src/platform_impl/macos/event_loop.rs index 8179ea91fd..58644326dc 100644 --- a/third_party/tao/src/platform_impl/macos/event_loop.rs +++ b/third_party/tao/src/platform_impl/macos/event_loop.rs @@ -35,7 +35,7 @@ use crate::{ observer::*, util::{self, IdRef}, }, - set_badge_label, set_progress_indicator, + set_progress_indicator, }, window::{ProgressBarState, Theme}, }; @@ -123,16 +123,6 @@ impl EventLoopWindowTarget { set_progress_indicator(progress); } - #[inline] - pub fn set_badge_count(&self, count: Option, _desktop_filename: Option) { - set_badge_label(count.map(|c| c.to_string())); - } - - #[inline] - pub fn set_badge_label(&self, label: Option) { - set_badge_label(label); - } - #[inline] pub fn set_theme(&self, theme: Option) { set_ns_theme(theme) diff --git a/third_party/tao/src/platform_impl/macos/icon.rs b/third_party/tao/src/platform_impl/macos/icon.rs index c5c7439d06..edb4b37ac6 100644 --- a/third_party/tao/src/platform_impl/macos/icon.rs +++ b/third_party/tao/src/platform_impl/macos/icon.rs @@ -5,10 +5,15 @@ use crate::icon::{BadIcon, RgbaIcon}; #[derive(Debug, Clone)] -pub struct PlatformIcon(RgbaIcon); +pub struct PlatformIcon { + // macOS ignores per-window icons; retain validated pixels for the shared Icon API. + _rgba: RgbaIcon, +} impl PlatformIcon { pub fn from_rgba(rgba: Vec, width: u32, height: u32) -> Result { - Ok(PlatformIcon(RgbaIcon::from_rgba(rgba, width, height)?)) + Ok(PlatformIcon { + _rgba: RgbaIcon::from_rgba(rgba, width, height)?, + }) } } diff --git a/third_party/tao/src/platform_impl/macos/monitor.rs b/third_party/tao/src/platform_impl/macos/monitor.rs index 7cdbd3986c..c9c60943c8 100644 --- a/third_party/tao/src/platform_impl/macos/monitor.rs +++ b/third_party/tao/src/platform_impl/macos/monitor.rs @@ -20,7 +20,7 @@ use core_graphics::{ }; use objc2::{msg_send, rc::Retained}; use objc2_app_kit::NSScreen; -use objc2_foundation::{ns_string, MainThreadMarker, NSString, NSUInteger}; +use objc2_foundation::{ns_string, MainThreadMarker, NSUInteger}; #[derive(Clone)] pub struct VideoMode { diff --git a/third_party/tao/src/platform_impl/macos/util/async.rs b/third_party/tao/src/platform_impl/macos/util/async.rs index 4513b6bc7c..e584971554 100644 --- a/third_party/tao/src/platform_impl/macos/util/async.rs +++ b/third_party/tao/src/platform_impl/macos/util/async.rs @@ -8,7 +8,7 @@ use std::{ }; use core_graphics::base::CGFloat; -use dispatch2::{DispatchQueue, DispatchQueueAttr}; +use dispatch2::DispatchQueue; use objc2::{rc::autoreleasepool, Message}; use objc2_app_kit::{NSScreen, NSView, NSWindow, NSWindowStyleMask}; use objc2_foundation::{MainThreadMarker, NSPoint, NSSize, NSString}; diff --git a/third_party/tao/src/platform_impl/macos/util/mod.rs b/third_party/tao/src/platform_impl/macos/util/mod.rs index b9a39147e6..32714fbcf2 100644 --- a/third_party/tao/src/platform_impl/macos/util/mod.rs +++ b/third_party/tao/src/platform_impl/macos/util/mod.rs @@ -12,7 +12,7 @@ use std::ops::{BitAnd, Deref}; use core_graphics::display::CGDisplay; use objc2::{ class, - runtime::{AnyClass as Class, AnyObject as Object, Sel}, + runtime::{AnyClass as Class, AnyObject as Object}, }; use objc2_app_kit::{NSApp, NSView, NSWindow, NSWindowStyleMask}; use objc2_foundation::{MainThreadMarker, NSAutoreleasePool, NSPoint, NSRange, NSRect, NSUInteger}; @@ -20,7 +20,7 @@ use objc2_foundation::{MainThreadMarker, NSAutoreleasePool, NSPoint, NSRange, NS use crate::{ dpi::{LogicalPosition, PhysicalPosition}, error::ExternalError, - platform_impl::platform::ffi::{self, id, nil, BOOL, YES}, + platform_impl::platform::ffi::{self, id, nil}, }; // Replace with `!` once stable @@ -123,10 +123,6 @@ pub unsafe fn open_emoji_picker() { let () = msg_send![&NSApp(mtm), orderFrontCharacterPalette: nil]; } -pub extern "C" fn yes(_: &Object, _: Sel) -> BOOL { - YES -} - pub unsafe fn toggle_style_mask( window: &NSWindow, view: &NSView, diff --git a/third_party/tao/src/platform_impl/macos/window.rs b/third_party/tao/src/platform_impl/macos/window.rs index 06067e60d8..7f5d9a25e2 100644 --- a/third_party/tao/src/platform_impl/macos/window.rs +++ b/third_party/tao/src/platform_impl/macos/window.rs @@ -6,7 +6,6 @@ use std::{ collections::VecDeque, convert::TryInto, - f64, ffi::CStr, os::raw::c_void, sync::{ @@ -40,11 +39,7 @@ use crate::{ WindowAttributes, WindowId as RootWindowId, WindowSizeConstraints, }, }; -use core_graphics::{ - base::CGFloat, - display::{CGDisplay, CGDisplayMode}, - geometry::CGPoint, -}; +use core_graphics::{base::CGFloat, display::CGDisplay, geometry::CGPoint}; use objc2::{ msg_send, rc::Retained, @@ -53,8 +48,8 @@ use objc2::{ use objc2_app_kit::{ self as appkit, NSApp, NSApplicationPresentationOptions, NSBackingStoreType, NSColor, NSEvent, NSEventModifierFlags, NSEventSubtype, NSEventType, NSRequestUserAttentionType, NSScreen, NSView, - NSWindow, NSWindowButton, NSWindowCollectionBehavior, NSWindowFullScreenButton, - NSWindowOrderingMode, NSWindowSharingType, NSWindowStyleMask, + NSWindow, NSWindowButton, NSWindowCollectionBehavior, NSWindowOrderingMode, NSWindowSharingType, + NSWindowStyleMask, }; use objc2_foundation::{ ns_string, MainThreadMarker, NSArray, NSAutoreleasePool, NSDictionary, NSInteger, NSPoint, @@ -266,7 +261,6 @@ fn create_window( } if pl_attrs.titlebar_buttons_hidden { for titlebar_button in &[ - NSWindowFullScreenButton, NSWindowButton::MiniaturizeButton, NSWindowButton::CloseButton, NSWindowButton::ZoomButton, @@ -435,9 +429,18 @@ extern "C" fn is_focusable(this: &Object, _: Sel) -> Bool { // Window chrome belongs to the window, not its replaceable content view. // Wry replaces TaoView with its WebView parent during window construction. pub(super) unsafe fn traffic_light_inset(window: &NSWindow) -> Option> { - let configured: Bool = *window.get_ivar("hasTrafficLightInset"); + // These ivars are registered on TaoWindow above and accessed on the AppKit main thread. + let configured = window + .class() + .instance_variable(CStr::from_bytes_with_nul(b"hasTrafficLightInset\0").unwrap()) + .unwrap(); + let configured: Bool = *configured.load(window); if configured.as_bool() { - let position: NSPoint = *window.get_ivar("trafficLightInset"); + let inset = window + .class() + .instance_variable(CStr::from_bytes_with_nul(b"trafficLightInset\0").unwrap()) + .unwrap(); + let position: NSPoint = *inset.load(window); Some(LogicalPosition::new(position.x, position.y)) } else { None @@ -511,7 +514,6 @@ pub struct SharedState { /// bar in exclusive fullscreen but want to restore the original options when /// transitioning back to borderless fullscreen. save_presentation_opts: Option, - pub saved_desktop_display_mode: Option<(CGDisplay, CGDisplayMode)>, pub current_theme: Theme, } @@ -548,7 +550,6 @@ pub struct UnownedWindow { decorations: AtomicBool, cursor_state: Weak>, transparent: bool, - pub inner_rect: Option>, } unsafe impl Send for UnownedWindow {} @@ -615,7 +616,10 @@ impl UnownedWindow { } // register for drag and drop operations. - ns_window.registerForDraggedTypes(&NSArray::arrayWithObject(appkit::NSFilenamesPboardType)); + // Keep the filename-list format paired with the legacy drag delegates. + #[allow(deprecated)] + let filenames_type = appkit::NSFilenamesPboardType; + ns_window.registerForDraggedTypes(&NSArray::arrayWithObject(filenames_type)); } // Since `win_attribs` is put into a mutex below, we'll just copy these @@ -630,9 +634,6 @@ impl UnownedWindow { let focused = win_attribs.focused; let decorations = win_attribs.decorations; let visible_on_all_workspaces = win_attribs.visible_on_all_workspaces; - let inner_rect = win_attribs - .inner_size - .map(|size| size.to_physical(scale_factor)); let cloned_preferred_theme = win_attribs.preferred_theme; @@ -643,7 +644,6 @@ impl UnownedWindow { shared_state: Arc::new(Mutex::new(win_attribs.into())), decorations: AtomicBool::new(decorations), cursor_state, - inner_rect, transparent, }); diff --git a/third_party/tao/src/platform_impl/macos/window_delegate.rs b/third_party/tao/src/platform_impl/macos/window_delegate.rs index fa6e63085d..001f2ad4da 100644 --- a/third_party/tao/src/platform_impl/macos/window_delegate.rs +++ b/third_party/tao/src/platform_impl/macos/window_delegate.rs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::{ - f64, ffi::CStr, os::raw::c_void, sync::{Arc, Weak}, @@ -420,6 +419,8 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, sender: id) -> BOOL { use std::path::PathBuf; let pb: Retained = unsafe { msg_send![sender, draggingPasteboard] }; + // Match the legacy filename-list format registered by UnownedWindow::new. + #[allow(deprecated)] let filenames = unsafe { NSPasteboard::propertyListForType(&pb, appkit::NSFilenamesPboardType) }.unwrap(); @@ -454,6 +455,8 @@ extern "C" fn perform_drag_operation(this: &Object, _: Sel, sender: id) -> BOOL use std::path::PathBuf; let pb: Retained = unsafe { msg_send![sender, draggingPasteboard] }; + // Match the legacy filename-list format registered by UnownedWindow::new. + #[allow(deprecated)] let filenames = unsafe { NSPasteboard::propertyListForType(&pb, appkit::NSFilenamesPboardType) }.unwrap();