From 91490f6d5e90f3b1065b9f74197a541c2433aa98 Mon Sep 17 00:00:00 2001 From: GCWing Date: Tue, 6 Oct 2026 09:25:00 +0800 Subject: [PATCH 1/3] feat(flow-chat): refine model controls and add turn usage metrics Keep model and reasoning selection in one card with provider context, guarded async saves, and a reasoning intensity slider. Add turn token/cache/output-rate metrics, hover-aware footers, and shared click-open tooltips that dismiss on scrolling. --- design-system/packages/ui/README.md | 34 + .../ui/src/components/Icon/Icon.meta.ts | 2 +- .../packages/ui/src/components/Icon/Icon.tsx | 10 +- .../components/Icon/assets/reasoning-auto.svg | 4 + .../ui/src/components/Tooltip/Tooltip.meta.ts | 5 +- .../ui/src/components/Tooltip/Tooltip.tsx | 118 +- .../conversation/FlowChatTurnMetrics.meta.ts | 25 + .../FlowChatTurnMetrics.module.css | 120 ++ .../conversation/FlowChatTurnMetrics.tsx | 91 ++ .../ui/src/flow-chat/conversation/index.ts | 4 + design-system/packages/ui/src/registry.ts | 2 + .../ui/tests/fixtures/icon-assets.json | 6 + .../ui/tests/flow-chat-turn-metrics.test.mjs | 111 ++ design-system/packages/ui/tests/icon.test.mjs | 18 +- .../packages/ui/tests/registry.test.mjs | 1 + .../packages/ui/tests/tooltip.test.mjs | 1 + .../ChatInputWorkspaceStripLayout.test.ts | 7 +- .../components/ModelModeAnimation.scss | 310 ++++ .../components/ModelModeAnimation.tsx | 168 +++ .../components/ModelSelector.appearance.ts | 7 + .../flow_chat/components/ModelSelector.scss | 447 +++++- .../flow_chat/components/ModelSelector.tsx | 1333 +++++++++-------- .../components/ModelSelectorAcpMode.test.tsx | 167 ++- .../components/ModelSelectorExternal.test.tsx | 91 +- .../ModelSelectorPortalLayer.test.ts | 7 +- .../ModelSelectorProviderLevels.test.tsx | 692 +++++++-- .../components/ReasoningIntensitySlider.scss | 281 ++++ .../ReasoningIntensitySlider.test.tsx | 200 +++ .../components/ReasoningIntensitySlider.tsx | 166 ++ .../components/ReasoningPresetSelector.scss | 37 - .../ReasoningPresetSelector.test.tsx | 58 +- .../components/ReasoningPresetSelector.tsx | 82 +- .../assets/reasoning-rail-stars.png | Bin 0 -> 36788 bytes .../components/btw/BtwVirtualSessionList.tsx | 1 + .../modern/FLOWCHAT_VERIFICATION.md | 4 + .../modern/FLOWCHAT_VIRTUALIZATION.md | 35 + .../components/modern/ModelRoundItem.scss | 30 +- .../components/modern/ModelRoundItem.tsx | 162 +- .../modern/ModernFlowChatContainer.tsx | 1 + .../components/modern/TurnFooter.test.tsx | 91 ++ .../components/modern/TurnFooter.tsx | 20 + .../components/modern/TurnUsageMetrics.tsx | 48 + .../components/modern/VirtualItemRenderer.tsx | 8 +- .../components/modern/VirtualMessageList.tsx | 4 + .../modern/modelRoundItemMemo.test.ts | 8 + .../components/modern/modelRoundItemMemo.ts | 2 + .../modern/useTurnFooterInteraction.ts | 26 + .../components/reasoningPresetPresentation.ts | 63 +- .../EventHandlerModule.test.ts | 26 + .../flow-chat-manager/EventHandlerModule.ts | 3 + .../PersistenceModule.test.ts | 10 + .../flow-chat-manager/PersistenceModule.ts | 2 + .../src/flow_chat/store/FlowChatStore.test.ts | 26 + .../src/flow_chat/store/FlowChatStore.ts | 8 +- .../src/flow_chat/timeline/readerState.tsx | 17 + src/web-ui/src/flow_chat/types/flow-chat.ts | 2 + .../flow_chat/utils/manualModelSelection.ts | 20 + .../flow_chat/utils/tokenUsageDisplay.test.ts | 10 + .../src/flow_chat/utils/tokenUsageDisplay.ts | 4 +- .../flow_chat/utils/turnUsageMetrics.test.ts | 43 + .../src/flow_chat/utils/turnUsageMetrics.ts | 20 + .../IconUsageIntegration.test.tsx | 5 +- src/web-ui/src/locales/en-US/flow-chat.json | 28 + src/web-ui/src/locales/zh-CN/flow-chat.json | 30 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 30 +- src/web-ui/src/shared/ui/Tooltip.test.tsx | 243 +++ 66 files changed, 4537 insertions(+), 1098 deletions(-) create mode 100644 design-system/packages/ui/src/components/Icon/assets/reasoning-auto.svg create mode 100644 design-system/packages/ui/src/flow-chat/conversation/FlowChatTurnMetrics.meta.ts create mode 100644 design-system/packages/ui/src/flow-chat/conversation/FlowChatTurnMetrics.module.css create mode 100644 design-system/packages/ui/src/flow-chat/conversation/FlowChatTurnMetrics.tsx create mode 100644 design-system/packages/ui/tests/flow-chat-turn-metrics.test.mjs create mode 100644 src/web-ui/src/flow_chat/components/ModelModeAnimation.scss create mode 100644 src/web-ui/src/flow_chat/components/ModelModeAnimation.tsx create mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.scss create mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.tsx create mode 100644 src/web-ui/src/flow_chat/components/assets/reasoning-rail-stars.png create mode 100644 src/web-ui/src/flow_chat/components/modern/TurnFooter.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/TurnFooter.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/TurnUsageMetrics.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/useTurnFooterInteraction.ts create mode 100644 src/web-ui/src/flow_chat/utils/manualModelSelection.ts create mode 100644 src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts create mode 100644 src/web-ui/src/shared/ui/Tooltip.test.tsx diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 2fcb300e38..a540e78f99 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -105,6 +105,15 @@ do not add press scaling in components or host-wide semantic selectors. Explicit content zoom, loading indicators, and enter/exit motion remain owned 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 +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 +availability; the icon catalog does not imply runtime support for these modes. + ## Native scrollbars `styles.css` owns scrollbar presentation inside `ThemeRoot` (or @@ -659,3 +668,28 @@ Collapsed ambient tool runs use their 22px minimum line boxes without extra spacing between adjacent rows; expanded/prominent cards retain the section gap. The enclosing composition owns those gaps, and card bodies own their internal padding. The Lab tool sequence demonstrates both arrangements with real cards. + +`FlowChatTurnMetrics` pairs total usage with an input-cache ring and a four-level +output-speed glyph. It uses `Icon` with Lucide `Circle` and `Signal` variants, +the shared FlowChat metadata typography, and semantic colors. Total usage and the +cache ring form one unit, hidden only when both values are missing. An unknown +cache ratio retains a neutral dashed ring; a missing total adds no placeholder. +Other unavailable metrics and detail rows are omitted; reported zero remains +visible. The host owns measurements, speed thresholds and localized descriptions. `FlowChatMetric` and +`FlowChatMetricDetails` provide the same capsule trigger and independent details +card for elapsed time. Native button triggers support hover, focus, click and +keyboard activation without resizing the row. `Tooltip openOnClick` opens +immediately and keeps the card open until another trigger click, an outside +press, Escape, or scrolling; ordinary action tooltips still dismiss on click. +All shared Tooltips dismiss immediately on window or container scrolling, +including click-open and cursor-following cards. Scrolling also cancels pending +opening and clears click persistence; scrolling back does not reopen the card. +Viewport-exit observation additionally closes cards when their trigger is clipped +without a scroll event. +Cards share the selected-text capsule's 80% raised surface, subtle +half-width border, small shadow and base blur, using the existing Tooltip +portal, positioning, dismissal and keyboard behavior. +The outer animated surface owns both paint and backdrop blur; its content is +transparent and unfiltered. An opaque fallback covers unsupported filters and +reduced-transparency preferences. Tooltip declares ownership of its entrance +motion to avoid the product's additional popup animation. diff --git a/design-system/packages/ui/src/components/Icon/Icon.meta.ts b/design-system/packages/ui/src/components/Icon/Icon.meta.ts index 80cecd8613..2813947fb0 100644 --- a/design-system/packages/ui/src/components/Icon/Icon.meta.ts +++ b/design-system/packages/ui/src/components/Icon/Icon.meta.ts @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types"; export const iconMeta = { category: "primitive", - description: "Lucide icons with shared size, tone and accessibility; authored artwork is reserved for the four harness modes and Git branch.", + description: "Lucide icons with shared size, tone and accessibility, plus reviewed brand-derived mode, reasoning, thinking and Git artwork.", maturity: "stable", name: "Icon", props: [ diff --git a/design-system/packages/ui/src/components/Icon/Icon.tsx b/design-system/packages/ui/src/components/Icon/Icon.tsx index a531653b00..2714bd8588 100644 --- a/design-system/packages/ui/src/components/Icon/Icon.tsx +++ b/design-system/packages/ui/src/components/Icon/Icon.tsx @@ -99,6 +99,7 @@ import creativeUrl from "./assets/creative.svg"; import ultimateUrl from "./assets/ultimate.svg"; import standardUrl from "./assets/standard.svg"; import minimalUrl from "./assets/minimal.svg"; +import reasoningAutoUrl from "./assets/reasoning-auto.svg"; import styles from "./Icon.module.css"; export const iconNames = [ @@ -112,6 +113,7 @@ export const iconNames = [ "ultimate", "standard", "minimal", + "reasoning-auto", "arrow-left", "arrow-right", "arrow-up", @@ -222,7 +224,7 @@ export type IconSource = | { glyph: LucideIcon; name?: never } | { glyph?: never; name: IconName }; -// Only the four harness modes, Git branch, and user-authored thinking mark retain authored artwork. +// Brand-derived mode and reasoning marks retain their reviewed SVG geometry. const iconSources: Partial> = { creative: creativeUrl, ultimate: ultimateUrl, @@ -230,6 +232,7 @@ const iconSources: Partial> = { minimal: minimalUrl, git: gitUrl, thinking: thinkingUrl, + "reasoning-auto": reasoningAutoUrl, }; const lineGlyphs = { @@ -324,7 +327,10 @@ const lineGlyphs = { "wrench": Wrench, "workflow": Workflow, "xmark": X, -} satisfies Record, LucideIcon>; +} satisfies Record, LucideIcon>; interface IconBaseProps extends Omit, "aria-label" | "children"> { diff --git a/design-system/packages/ui/src/components/Icon/assets/reasoning-auto.svg b/design-system/packages/ui/src/components/Icon/assets/reasoning-auto.svg new file mode 100644 index 0000000000..3d0cdfca82 --- /dev/null +++ b/design-system/packages/ui/src/components/Icon/assets/reasoning-auto.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts b/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts index e7a5c8e334..57878e0296 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts @@ -2,18 +2,19 @@ import type { ComponentMeta } from "../../registry.types"; export const tooltipMeta = { category: "feedback", - description: "A portaled, delay-managed hover label that flips placement at viewport edges and supports cursor-following and interactive content.", + description: "A portaled, delay-managed label with optional click persistence, cursor-following and interactive content; dismisses immediately on scrolling or when its trigger leaves the visible viewport.", maturity: "stable", name: "Tooltip", props: [ { name: "children", type: "ReactElement" }, { name: "content", type: "ReactNode" }, { defaultValue: "top", name: "placement", type: "top | bottom | left | right" }, - { defaultValue: "hover", name: "trigger", type: "hover | click | focus" }, + { defaultValue: "hover", name: "trigger", type: "hover | click | focus | hover-focus" }, { defaultValue: "450", name: "delay", type: "number" }, { defaultValue: "false", name: "disabled", type: "boolean" }, { defaultValue: "false", name: "followCursor", type: "boolean" }, { defaultValue: "false", name: "interactive", type: "boolean" }, + { defaultValue: "false", name: "openOnClick", type: "boolean" }, { name: "portalContainer", type: "TooltipPortalTarget" }, ], states: ["default", "visible", "interactive", "instant"], diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx index 2e83cc2e97..160ebc6417 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -51,6 +51,8 @@ export interface TooltipProps { followCursor?: boolean; /** Keep the tooltip open while hovered so its content can be selected or clicked. */ interactive?: boolean; + /** Click also opens immediately; another click, outside press, Escape, or scrolling dismisses it. */ + openOnClick?: boolean; /** Preferred side of the trigger; flips to the opposite side when space runs out. */ placement?: TooltipPlacement; trigger?: TooltipTrigger; @@ -175,6 +177,7 @@ export function Tooltip({ disabled = false, followCursor = false, interactive = false, + openOnClick = false, placement = "top", trigger = "hover", triggerRef: externalTriggerRef, @@ -204,8 +207,15 @@ export function Tooltip({ const latestMousePositionRef = useRef<{ x: number; y: number } | null>(null); const recalcFrameRef = useRef(null); const instantRef = useRef(false); + const clickOpenRef = useRef(false); const activationRef = useRef(false); const hideCurrentRef = useRef<() => void>(() => {}); + const scrollCleanupRef = useRef<(() => void) | null>(null); + + const stopWatchingScroll = useCallback(() => { + scrollCleanupRef.current?.(); + scrollCleanupRef.current = null; + }, []); const calculatePosition = useCallback(() => { if (!tooltipRef.current) return; @@ -234,7 +244,7 @@ export function Tooltip({ setLayout({ top: pos.top, left: pos.left, placement: bestPlacement, ready: true }); }, [placement, followCursor, mousePosition]); - // rAF-merged recalculation for scroll/resize storms: at most one + // rAF-merged recalculation for resize storms: at most one // getBoundingClientRect pass per frame. const scheduleCalculatePosition = useCallback(() => { if (recalcFrameRef.current !== null) return; @@ -244,27 +254,45 @@ export function Tooltip({ }); }, [calculatePosition]); - const showTooltip = useCallback((event?: Pick) => { + const showTooltip = useCallback((event?: Pick, fromClick = false) => { if (disabled) return; const element = triggerRef.current; if (!element || element.closest('[hidden], [aria-hidden="true"]')) return; if (onBeforeShow && !onBeforeShow()) return; + const view = element.ownerDocument.defaultView; + // Capture nested, non-bubbling scrolls during both delayed and visible states. + // Dormant tooltips do not need a document-level scroll subscription. + if (view && !scrollCleanupRef.current) { + const dismissOnScroll = () => hideCurrentRef.current(); + view.addEventListener("scroll", dismissOnScroll, { capture: true, passive: true }); + view.visualViewport?.addEventListener("scroll", dismissOnScroll, { passive: true }); + scrollCleanupRef.current = () => { + view.removeEventListener("scroll", dismissOnScroll, true); + view.visualViewport?.removeEventListener("scroll", dismissOnScroll); + }; + } if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); + showTimeoutRef.current = null; if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } + if (fromClick) clickOpenRef.current = true; + if (clickOpenRef.current && visible) return; if (followCursor && event) { latestMousePositionRef.current = { x: event.clientX, y: event.clientY }; } - const openDelay = (trigger === "hover" || trigger === "hover-focus") && Date.now() < tooltipWarmUntil + const openDelay = fromClick || ((trigger === "hover" || trigger === "hover-focus") && Date.now() < tooltipWarmUntil) ? 0 : resolvedDelayMs; instantRef.current = openDelay === 0; - showTimeoutRef.current = setTimeout(() => { + const reveal = () => { showTimeoutRef.current = null; - if (!element.isConnected || element.closest('[hidden], [aria-hidden="true"]')) return; - if (onBeforeShow && !onBeforeShow()) return; + if (!element.isConnected || element.closest('[hidden], [aria-hidden="true"]') + || (onBeforeShow && !onBeforeShow())) { + stopWatchingScroll(); + return; + } const previous = activeTooltips.get(element.ownerDocument); if (previous && previous.id !== tooltipId) previous.hide(); activeTooltips.set(element.ownerDocument, { id: tooltipId, hide: () => hideCurrentRef.current() }); @@ -273,10 +301,14 @@ export function Tooltip({ } setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev)); setVisible(true); - }, openDelay); - }, [disabled, followCursor, onBeforeShow, resolvedDelayMs, tooltipId, trigger, triggerRef]); + }; + if (fromClick) reveal(); + else showTimeoutRef.current = setTimeout(reveal, openDelay); + }, [disabled, followCursor, onBeforeShow, resolvedDelayMs, stopWatchingScroll, tooltipId, trigger, triggerRef, visible]); const hideTooltip = useCallback(() => { + stopWatchingScroll(); + clickOpenRef.current = false; if (showTimeoutRef.current) { clearTimeout(showTimeoutRef.current); showTimeoutRef.current = null; @@ -296,11 +328,34 @@ export function Tooltip({ latestMousePositionRef.current = null; setMousePosition(null); } - }, [followCursor, tooltipId, triggerRef, visible]); + }, [followCursor, stopWatchingScroll, tooltipId, triggerRef, visible]); useEffect(() => { hideCurrentRef.current = hideTooltip; }, [hideTooltip]); + useEffect(() => { + const element = triggerRef.current; + const view = element?.ownerDocument.defaultView; + if (!visible || !element || !view?.IntersectionObserver) return; + let observing = true; + // The browser intersects the window and every clipping/scrolling ancestor. + // Watch only an open tooltip, including cursor-following and external triggers. + const observer = new view.IntersectionObserver(entries => { + if (observing && entries.some(entry => entry.target === element && !entry.isIntersecting)) { + hideCurrentRef.current(); + } + }); + observer.observe(element); + return () => { observing = false; observer.disconnect(); }; + }, [triggerRef, visible]); + + const toggleFromClick = useCallback((event: Pick) => { + if (openOnClick && !clickOpenRef.current) showTooltip(event, true); + else if (!openOnClick && trigger === "click" && !visible) showTooltip(event); + else hideTooltip(); + }, [hideTooltip, openOnClick, showTooltip, trigger, visible]); + const scheduleHideTooltip = useCallback(() => { + if (clickOpenRef.current) return; if (!interactive || !visible) { hideTooltip(); return; @@ -328,32 +383,27 @@ export function Tooltip({ if (!visible) return; scheduleCalculatePosition(); - if (!followCursor) { - window.addEventListener("scroll", scheduleCalculatePosition, { capture: true, passive: true }); - } window.addEventListener("resize", scheduleCalculatePosition, { passive: true }); return () => { - if (!followCursor) { - window.removeEventListener("scroll", scheduleCalculatePosition, { capture: true }); - } window.removeEventListener("resize", scheduleCalculatePosition); if (recalcFrameRef.current !== null) { cancelAnimationFrame(recalcFrameRef.current); recalcFrameRef.current = null; } }; - }, [visible, followCursor, scheduleCalculatePosition]); + }, [visible, scheduleCalculatePosition]); useEffect(() => { const ownerDocument = triggerRef.current?.ownerDocument; return () => { + stopWatchingScroll(); if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); showTimeoutRef.current = hideTimeoutRef.current = null; activationRef.current = false; if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument); }; - }, [tooltipId, triggerRef]); + }, [stopWatchingScroll, tooltipId, triggerRef]); // Delegated text slots use the owning button/row for hover and keyboard focus. // Keep the actual label in place so this also works inside portalled listboxes. @@ -376,25 +426,21 @@ export function Tooltip({ }; const onBlur = (event: FocusEvent) => { if (event.relatedTarget && element.contains(event.relatedTarget as Node)) return; - if (trigger === "focus" || trigger === "hover-focus") hideTooltip(); - }; - const onClick = () => { - if (trigger === "click" && !visible) showTooltip(); - else hideTooltip(); + if (!clickOpenRef.current && (trigger === "focus" || trigger === "hover-focus")) hideTooltip(); }; element.addEventListener("mouseenter", onEnter); element.addEventListener("mouseleave", onLeave); element.addEventListener("focusin", onFocus); element.addEventListener("focusout", onBlur); - element.addEventListener("click", onClick); + element.addEventListener("click", toggleFromClick); return () => { element.removeEventListener("mouseenter", onEnter); element.removeEventListener("mouseleave", onLeave); element.removeEventListener("focusin", onFocus); element.removeEventListener("focusout", onBlur); - element.removeEventListener("click", onClick); + element.removeEventListener("click", toggleFromClick); }; - }, [externalTriggerRef, hideTooltip, interactive, scheduleHideTooltip, showTooltip, trigger, visible]); + }, [externalTriggerRef, hideTooltip, interactive, scheduleHideTooltip, showTooltip, toggleFromClick, trigger]); // A measured text slot can mount after focus has already reached its owner. // Only replay focus/virtual activation on a transition, not on visibility updates. @@ -416,10 +462,11 @@ export function Tooltip({ if (event.key !== "Escape" || isImeOwnedKeyboardEvent(event) || showTimeoutRef.current === null) return; clearTimeout(showTimeoutRef.current); showTimeoutRef.current = null; + if (!visible) stopWatchingScroll(); }; ownerDocument?.addEventListener("keydown", cancelPendingShow, true); return () => ownerDocument?.removeEventListener("keydown", cancelPendingShow, true); - }, [triggerRef]); + }, [stopWatchingScroll, triggerRef, visible]); const childProps = (children?.props ?? {}) as Record; const childRef = (children as (ReactElement & { ref?: Ref }) | undefined)?.ref; @@ -447,18 +494,8 @@ export function Tooltip({ }; const handleClick = (event: ReactMouseEvent) => { - // Always cancel any pending show timer so a click before the tooltip - // appears cannot surface a stale tooltip after the trigger is covered - // by a menu or backdrop (which prevents the natural mouseleave). - if (showTimeoutRef.current) { - clearTimeout(showTimeoutRef.current); - showTimeoutRef.current = null; - } - if (visible) { - hideTooltip(); - } else if (trigger === "click") { - showTooltip(); - } + // Both click paths replace pending hover timers; ordinary action hints still dismiss. + toggleFromClick(event); (childProps.onClick as ((event: ReactMouseEvent) => void) | undefined)?.(event); }; @@ -468,7 +505,7 @@ export function Tooltip({ }; const handleBlur = (event: ReactFocusEvent) => { - if (trigger === "focus" || trigger === "hover-focus") hideTooltip(); + if (!clickOpenRef.current && (trigger === "focus" || trigger === "hover-focus")) hideTooltip(); (childProps.onBlur as ((event: ReactFocusEvent) => void) | undefined)?.(event); }; @@ -514,11 +551,12 @@ export function Tooltip({ {visible && ( + surfaceRef={tooltipRef} onDismiss={hideTooltip} dismissOnPointerOutside={openOnClick}> ; +} + +describe('completed-turn footer presentation', () => { + let host: HTMLDivElement; + let root: Root; + let reader: FlowChatReaderState; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); document.body.append(host); + root = createRoot(host); reader = new FlowChatReaderState(); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + function render({ latest = 'new', body = true, ready = true, store = reader } = {}) { + act(() => root.render( + {body && } + Old metrics + + New metrics + )); + } + const footer = (turn: string) => host.querySelector(`#${turn}-footer .model-round-item__footer`)!; + function pointer(type: 'pointerover' | 'pointerout', id: string) { + act(() => host.querySelector(`#${id}`)!.dispatchEvent(new MouseEvent(type, { bubbles: true }))); + } + + it('shows the session latest turn, including when it is outside the rendered history window', () => { + render(); + expect(footer('old').dataset.footerReveal).toBe('hover'); + expect(footer('new').dataset.footerReveal).toBe('visible'); + render({ latest: 'unrendered-latest' }); + expect(footer('old').dataset.footerReveal).toBe('hover'); + expect(footer('new').dataset.footerReveal).toBe('hover'); + render({ latest: 'old' }); + expect(footer('old').dataset.footerReveal).toBe('visible'); + expect(footer('new').dataset.footerReveal).toBe('hover'); + }); + + it('reveals a sibling footer from any row in its turn without changing timeline projection', () => { + render({ latest: 'unrendered-latest' }); + const originalFooter = footer('old'); + const revision = reader.getProjectionRevision(); + pointer('pointerover', 'old-body'); + expect(footer('old').dataset.footerReveal).toBe('visible'); + expect(footer('new').dataset.footerReveal).toBe('hover'); + pointer('pointerout', 'old-body'); + expect(footer('old').dataset.footerReveal).toBe('hover'); + expect(footer('old')).toBe(originalFooter); + expect(reader.getProjectionRevision()).toBe(revision); + }); + + it('keeps keyboard access after the pointer leaves and releases focus independently', () => { + render(); + pointer('pointerover', 'old-body'); + act(() => host.querySelector('#old-body button')!.focus()); + pointer('pointerout', 'old-body'); + expect(footer('old').dataset.footerReveal).toBe('visible'); + act(() => host.querySelector('#new-body button')!.focus()); + expect(footer('old').dataset.footerReveal).toBe('hover'); + }); + + it('releases recycled rows and never carries a hover into another session view', () => { + render(); + pointer('pointerover', 'old-body'); + render({ body: false }); + expect(footer('old').dataset.footerReveal).toBe('hover'); + render(); pointer('pointerover', 'old-body'); + const nextReader = new FlowChatReaderState(); + render({ store: nextReader }); + expect(reader.get('interaction:old', true)).toBe(false); + expect(footer('old').dataset.footerReveal).toBe('hover'); + }); + + it('keeps the existing typewriter reveal gate authoritative on hover', () => { + render({ ready: false }); + pointer('pointerover', 'old-body'); + expect(footer('old').classList.contains('model-round-item__footer--pending')).toBe(true); + expect(footer('old').getAttribute('aria-hidden')).toBe('true'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFooter.tsx b/src/web-ui/src/flow_chat/components/modern/TurnFooter.tsx new file mode 100644 index 0000000000..dd5e069c7f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFooter.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { useFlowChatReaderValue } from '../../timeline/readerState'; + +/** Only the footer subscribes; pointer movement never reprojects the transcript. */ +export function TurnFooter({ turnId, isLatestTurn, ready, children }: { + turnId: string; + isLatestTurn: boolean; + ready: boolean; + children: ReactNode; +}) { + const [interacting] = useFlowChatReaderValue(`interaction:${turnId}`, false); + return
{children}
; +} diff --git a/src/web-ui/src/flow_chat/components/modern/TurnUsageMetrics.tsx b/src/web-ui/src/flow_chat/components/modern/TurnUsageMetrics.tsx new file mode 100644 index 0000000000..26707ca15c --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnUsageMetrics.tsx @@ -0,0 +1,48 @@ +import { FlowChatMetricDetails, FlowChatTurnMetrics } from '@openbitfun/ui/flow-chat'; +import { useI18n } from '@/infrastructure/i18n'; +import { formatTokenCount } from '@/shared/utils/tokenUsageFormatting'; +import type { TokenUsage } from '../../types/flow-chat'; +import { getTurnUsageMetrics } from '../../utils/turnUsageMetrics'; + +export function TurnUsageMetrics({ usage, durationMs, focusable }: { + usage?: TokenUsage; + durationMs?: number; + focusable: boolean; +}) { + const { t, formatNumber } = useI18n('flow-chat'); + const { totalTokens, cacheHitRate, outputRate, speedLevel } = getTurnUsageMetrics(usage, durationMs); + const count = (value?: number | null) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? formatTokenCount(value, formatNumber) : null; + const rate = outputRate === null ? null : formatNumber(outputRate, { maximumFractionDigits: 1 }); + const speedLabels = { + 1: t('modelRound.metrics.slow'), + 2: t('modelRound.metrics.normal'), + 3: t('modelRound.metrics.fast'), + 4: t('modelRound.metrics.veryFast'), + }; + const cacheValue = cacheHitRate === null ? null + : formatNumber(cacheHitRate, { style: 'percent', maximumFractionDigits: 1 }); + const tokenRows = [ + { label: t('modelRound.metrics.total'), value: count(totalTokens) }, + { label: t('modelRound.metrics.input'), value: count(usage?.inputTokens) }, + { label: t('modelRound.metrics.output'), value: count(usage?.outputTokens) }, + { label: t('modelRound.metrics.cache'), value: cacheValue }, + ].filter((row): row is { label: string; value: string } => row.value !== null); + const rateRows = rate !== null && speedLevel !== null ? [ + { label: t('modelRound.metrics.averageRate'), value: t('modelRound.metrics.rateValue', { value: rate }) }, + { label: t('modelRound.metrics.speedLevel'), value: speedLabels[speedLevel] }, + ] : []; + + return `${row.label}: ${row.value}`).join(' · ')} + tokenDetails={} + cacheHitRate={cacheHitRate} + rateValue={rate === null ? null : t('modelRound.metrics.rateValue', { value: rate })} + rateDescription={rateRows.map(row => `${row.label}: ${row.value}`).join(' · ')} + rateDetails={} + speedLevel={speedLevel} + focusable={focusable} + />; +} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index acb157689e..1ada858561 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -20,10 +20,12 @@ import { ConversationExcerptMarkers } from '../../selection/ConversationExcerptM import { getKnownVirtualItemHeightPx } from './virtualItemHeightEstimators'; import { Icon } from '@openbitfun/ui'; import { TimelineContentBlock } from '../../timeline/TimelineContentBlock'; +import { useTurnFooterInteraction } from './useTurnFooterInteraction'; interface VirtualItemRendererProps { item: VirtualItem; index: number; + isLatestTurn?: boolean; /** Stable projection facts used for spacing across virtual-row boundaries. */ endsBeforeUserTurn?: boolean; continuesAmbientToolRunAfter?: boolean; @@ -37,7 +39,8 @@ interface VirtualItemRendererProps { } export const VirtualItemRenderer = React.memo( - ({ item, index, endsBeforeUserTurn = false, continuesAmbientToolRunAfter = false, measureRef }) => { + ({ item, index, isLatestTurn = false, endsBeforeUserTurn = false, continuesAmbientToolRunAfter = false, measureRef }) => { + const turnInteraction = useTurnFooterInteraction(item.turnId); const { searchQuery, searchMatchesByVirtualIndex, searchCurrentMatch } = useFlowChatVolatileContext(); const sourceIndex = item.timeline?.sourceIndex ?? index; const ownsMatch = (match: { flowItemId?: string }) => !item.timeline || !match.flowItemId || item.timeline.memberIds.includes(match.flowItemId); @@ -87,6 +90,7 @@ export const VirtualItemRenderer = React.memo( projectedGroups={item.projectedGroups} turnId={item.turnId} isLastRound={item.isLastRound} + isLatestTurn={isLatestTurn} isTurnComplete={item.isTurnComplete} turnStartedAt={item.turnStartedAt} turnEndedAt={item.turnEndedAt} @@ -136,6 +140,7 @@ export const VirtualItemRenderer = React.memo( // data-turn-id is used for long-image export. return (
( (prev, next) => ( prev.item === next.item && prev.index === next.index && + prev.isLatestTurn === next.isLatestTurn && prev.endsBeforeUserTurn === next.endsBeforeUserTurn && prev.continuesAmbientToolRunAfter === next.continuesAmbientToolRunAfter && prev.measureRef === next.measureRef diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 9fd905a61a..af35ea8d04 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -224,6 +224,8 @@ export interface VirtualMessageListRef { export interface VirtualMessageListProps { items?: VirtualItem[]; + /** Session catalog identity, independent from the current history window. */ + latestTurnId?: string; isViewportActive?: boolean; presentationMode?: 'tail' | 'history-window'; viewportMode?: 'live-tail' | 'history-reading'; @@ -376,6 +378,7 @@ function isElementVisibleInScroller(element: HTMLElement, scroller: HTMLElement) const VirtualMessageListSession = forwardRef(({ items, + latestTurnId, isViewportActive = true, presentationMode = 'tail', viewportMode = presentationMode === 'history-window' ? 'history-reading' : 'live-tail', @@ -2689,6 +2692,7 @@ const VirtualMessageListSession = forwardRef { renderHints: { disableExploreGrouping: true } } })).toBe(false); }); + it('refreshes the previous and next latest-turn footers without changing their round contents', () => { + const latest = { ...props, isLatestTurn: true }; + const historical = { ...props, isLatestTurn: false }; + expect(areModelRoundItemPropsEqual(latest, historical)).toBe(false); + expect(areModelRoundItemPropsEqual(historical, latest)).toBe(false); + expect(areModelRoundItemPropsEqual(latest, { ...latest })).toBe(true); + }); + it('refreshes a settled native retry when its grouping hint becomes an explicit host policy', () => { const legacy: ModelRoundItemProps = { ...props, round: { ...props.round, renderHints: { disableExploreGrouping: true }, attempts: [ diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts index 604bd56f28..4060db0eb8 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts @@ -8,6 +8,7 @@ export interface ModelRoundItemProps { projectedGroups?: ModelRoundItemGroup[]; turnId: string; isLastRound?: boolean; + isLatestTurn?: boolean; isTurnComplete?: boolean; turnStartedAt?: number; turnEndedAt?: number; @@ -52,6 +53,7 @@ export function areModelRoundItemPropsEqual(prev: ModelRoundItemProps, next: Mod prev.round.attemptDiagnostics === next.round.attemptDiagnostics && prev.round.historyRounds === next.round.historyRounds && prev.isLastRound === next.isLastRound && + prev.isLatestTurn === next.isLatestTurn && prev.isTurnComplete === next.isTurnComplete && prev.expandedThinkingItemIds === next.expandedThinkingItemIds && prev.turnStartedAt === next.turnStartedAt && diff --git a/src/web-ui/src/flow_chat/components/modern/useTurnFooterInteraction.ts b/src/web-ui/src/flow_chat/components/modern/useTurnFooterInteraction.ts new file mode 100644 index 0000000000..75da2e4bbd --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useTurnFooterInteraction.ts @@ -0,0 +1,26 @@ +import { useLayoutEffect, useMemo, type FocusEvent } from 'react'; +import { useFlowChatReaderStore } from '../../timeline/readerState'; + +/** Release each row's leases on recycling, session switching, and pointer/focus exit. */ +export function useTurnFooterInteraction(turnId: string) { + const reader = useFlowChatReaderStore(); + const interaction = useMemo(() => { + let releasePointer: (() => void) | undefined; + let releaseFocus: (() => void) | undefined; + const leavePointer = () => { releasePointer?.(); releasePointer = undefined; }; + const leaveFocus = () => { releaseFocus?.(); releaseFocus = undefined; }; + return { + handlers: { + onPointerEnter: () => { releasePointer ??= reader?.holdTurnInteraction(turnId); }, + onPointerLeave: leavePointer, + onFocusCapture: () => { releaseFocus ??= reader?.holdTurnInteraction(turnId); }, + onBlurCapture: (event: FocusEvent) => { + if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) leaveFocus(); + }, + }, + dispose: () => { leavePointer(); leaveFocus(); }, + }; + }, [reader, turnId]); + useLayoutEffect(() => interaction.dispose, [interaction]); + return interaction.handlers; +} diff --git a/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts index 58fe4f2bfc..adfe2a84c2 100644 --- a/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts +++ b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts @@ -1,26 +1,25 @@ -import type { useTranslation } from 'react-i18next'; import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; +type Translate = (key: string, options?: Record) => string; + export function presetLabel( preset: ReasoningPresetDescriptor, - t: ReturnType['t'], + t: Translate, ): string { return t(`reasoningEffort.${preset.id}`, { defaultValue: preset.label || preset.id }); } export function presetDisplayLabel( preset: ReasoningPresetDescriptor, - t: ReturnType['t'], + t: Translate, ): string { const fallback = presetLabel(preset, t); const semanticKey = presetSemanticKey(preset); return semanticKey - ? t(`reasoningSelector.levels.${semanticKey}`, { defaultValue: fallback }) + ? t(`reasoningSelector.levels.${semanticKey === 'on' ? 'low' : semanticKey}`, { defaultValue: fallback }) : fallback; } -export type ReasoningIntensityLevel = 0 | 1 | 2 | 3 | 4; - type ReasoningPresetSemanticKey = | 'off' | 'on' @@ -77,30 +76,38 @@ function presetSemanticKey( return undefined; } -function presetDisablesReasoning(preset: ReasoningPresetDescriptor): boolean { - if (presetSemanticKey(preset) === 'off') return true; - return preset.actions.some(action => ( - action.type === 'toggle' && !action.enabled - )); +/** Collapse the enabled toggle into Low without changing persisted or wire ids. */ +export function reasoningPresetChoices(presets: ReasoningPresetDescriptor[]): ReasoningPresetDescriptor[] { + return presets.some(preset => presetSemanticKey(preset) === 'low') + ? presets.filter(preset => presetSemanticKey(preset) !== 'on') + : presets; } -export function reasoningIntensityLevel( +export function resolveReasoningPresetChoice( preset: ReasoningPresetDescriptor | undefined, - orderedPresets: ReasoningPresetDescriptor[], -): ReasoningIntensityLevel { - if (!preset) return 0; - if (presetDisablesReasoning(preset)) return 0; - - const activePresets = orderedPresets.filter(item => !presetDisablesReasoning(item)); - const activeIndex = activePresets.findIndex(item => item.id === preset.id); - if (activeIndex < 0) return 0; - if (activePresets.length === 1) return 1; + presets: ReasoningPresetDescriptor[], +): ReasoningPresetDescriptor | undefined { + return preset && presetSemanticKey(preset) === 'on' + ? presets.find(candidate => presetSemanticKey(candidate) === 'low') ?? preset + : preset; +} - // The catalog may merge toggle, effort and token-budget presets. Ranking by - // the catalog order keeps the visual series monotonic even when ids come from - // different action families (off, on, low, high, max). - return Math.min( - 4, - Math.max(1, Math.round((activeIndex / (activePresets.length - 1)) * 3) + 1), - ) as 1 | 2 | 3 | 4; +/** Fixed slider positions; use the advertised enabled toggle when no Low effort exists. */ +export function reasoningSliderPresets( + presets: ReasoningPresetDescriptor[], +): Array { + const byMeaning = new Map(); + for (const preset of presets) { + const meaning = presetSemanticKey(preset); + if (meaning && !byMeaning.has(meaning)) byMeaning.set(meaning, preset); + } + // Custom presets retain their named picker. Toggle-only models expose Off/Low + // while still sending the original off/on ids to their provider. + return [ + byMeaning.get('off'), + byMeaning.get('low') ?? byMeaning.get('on') ?? byMeaning.get('minimal'), + byMeaning.get('medium'), + byMeaning.get('high'), + byMeaning.get('xhigh') ?? byMeaning.get('max'), + ]; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index ec61bb6815..aede20b7c9 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -1939,6 +1939,32 @@ describe('handleTokenUsageUpdate', () => { stateMachineManager.clear(); }); + it('accumulates reported cache hits across camel and snake case events without treating missing telemetry as zero', () => { + putFinishingSessionInStore(); + const context = createFlowChatContext(); + handleTokenUsageUpdate(context, { + sessionId: 'session-1', turnId: 'turn-1', inputTokens: 100, + outputTokens: 20, totalTokens: 120, cachedTokens: 0, + }); + handleTokenUsageUpdate(context, { + session_id: 'session-1', turn_id: 'turn-1', input_tokens: 200, + output_tokens: 30, total_tokens: 230, cached_tokens: 150, + }); + const current = () => FlowChatStore.getInstance().getState().sessions.get('session-1'); + expect(current()?.dialogTurns[0].tokenUsage).toMatchObject({ + inputTokens: 300, outputTokens: 50, totalTokens: 350, cachedTokens: 150, + }); + handleTokenUsageUpdate(context, { + sessionId: 'session-1', turnId: 'turn-1', inputTokens: 50, totalTokens: 60, outputTokens: 10, + }); + expect(current()?.dialogTurns[0].tokenUsage?.cachedTokens).toBeUndefined(); + handleTokenUsageUpdate(context, { + sessionId: 'session-1', turnId: 'turn-1', inputTokens: 10, totalTokens: 12, outputTokens: 2, cachedTokens: 5, + }); + expect(current()?.dialogTurns[0].tokenUsage?.cachedTokens).toBeUndefined(); + expect(current()?.currentTokenUsage?.cachedTokens).toBe(5); + }); + it('tracks the source turn on current usage without adding provenance to accumulated turn usage', () => { putFinishingSessionInStore(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 17048dbd51..38162d5e7c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -2432,6 +2432,7 @@ function handleTokenUsageUpdate(context: FlowChatContext, event: any): void { const inputTokens = event.inputTokens ?? event.input_tokens; const outputTokens = event.outputTokens ?? event.output_tokens; const totalTokens = event.totalTokens ?? event.total_tokens; + const cachedTokens = event.cachedTokens ?? event.cached_tokens; const maxContextTokens = event.maxContextTokens ?? event.max_context_tokens; const store = FlowChatStore.getInstance(); @@ -2456,6 +2457,8 @@ function handleTokenUsageUpdate(context: FlowChatContext, event: any): void { store.updateTokenUsage(sessionId, { inputTokens, outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined, + cachedTokens: typeof cachedTokens === 'number' && Number.isFinite(cachedTokens) && cachedTokens >= 0 + ? cachedTokens : undefined, totalTokens, turnId, source: 'model_request', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index e117899fa4..b1eb817fe5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -174,6 +174,16 @@ describe('PersistenceModule', () => { }); }); + it.each([0, 900])('preserves reported cache tokens %s in the saved turn payload', cachedTokens => { + const turn = createDialogTurn('completed'); + turn.tokenUsage = { inputTokens: 1200, outputTokens: 320, totalTokens: 1520, cachedTokens, timestamp: 2400 }; + const persisted = JSON.parse(JSON.stringify(convertDialogTurnToBackendFormat(turn, 0))); + expect(persisted.tokenUsage.cachedTokens).toBe(cachedTokens); + delete turn.tokenUsage.cachedTokens; + expect(JSON.parse(JSON.stringify(convertDialogTurnToBackendFormat(turn, 0))).tokenUsage) + .not.toHaveProperty('cachedTokens'); + }); + it('persists finish reason when present', () => { const turn = createDialogTurn('completed'); turn.finishReason = 'max_rounds'; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 3bb8fdb4d8..d674fff477 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -520,6 +520,8 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd ? { inputTokens: dialogTurn.tokenUsage.inputTokens, outputTokens: dialogTurn.tokenUsage.outputTokens, + ...(dialogTurn.tokenUsage.cachedTokens !== undefined + ? { cachedTokens: dialogTurn.tokenUsage.cachedTokens } : {}), totalTokens: dialogTurn.tokenUsage.totalTokens, timestamp: dialogTurn.tokenUsage.timestamp, } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index aab015d64b..2f3a10a955 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -6577,6 +6577,32 @@ describe('FlowChatStore historical session hydration state', () => { expect(apiMocks.loadSessionTurnWindow).toHaveBeenCalledTimes(1); }); + it.each([ + [{ inputTokens: 100, outputTokens: 20, totalTokens: 120, cachedTokens: 75 }, 75], + [{ input_tokens: 100, output_tokens: 20, total_tokens: 120, cached_tokens: 75 }, 75], + [{ inputTokens: 100, outputTokens: 20, totalTokens: 120, cachedTokens: 0 }, 0], + [{ inputTokens: 100, outputTokens: 20, totalTokens: 120 }, undefined], + ])('restores optional cache telemetry from current and legacy turn payloads: %j', async (tokenUsage, cachedTokens) => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { sessionId: 'history-1', sessionName: 'History 1', agentType: 'Standard', state: 'Idle', turnCount: 1, createdAt: 1 }, + turns: [{ ...createPersistedTurn(0), endTime: 2, tokenUsage: JSON.parse(JSON.stringify(tokenUsage)) }], + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([['history-1', createSession({ sessionId: 'history-1', isHistorical: true, historyState: 'metadata-only' })]]), + activeSessionId: 'history-1', + })); + await flowChatStore.loadSessionHistory('history-1'); + const restored = flowChatStore.getState().sessions.get('history-1')?.dialogTurns[0].tokenUsage; + expect(restored).toMatchObject({ inputTokens: 100, outputTokens: 20, totalTokens: 120 }); + expect(restored?.cachedTokens).toBe(cachedTokens); + // Both cached zero and absent telemetry survive the next JSON persistence boundary. + const saved = JSON.parse(JSON.stringify(restored)); + expect(saved.cachedTokens).toBe(cachedTokens); + if (cachedTokens === undefined) expect(saved).not.toHaveProperty('cachedTokens'); + }); + it('backfills currentTokenUsage from the last completed turn after hydration', async () => { peerModeFlagMock.active = true; apiMocks.restoreSessionView.mockResolvedValueOnce({ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e9e9f2922d..9b86f46e40 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -6668,7 +6668,7 @@ export class FlowChatStore { sessionId: string, tokenUsage: Pick< TokenUsage, - 'inputTokens' | 'outputTokens' | 'totalTokens' | 'turnId' | 'source' + 'inputTokens' | 'outputTokens' | 'cachedTokens' | 'totalTokens' | 'turnId' | 'source' >, dialogTurnId?: string ): void { @@ -6679,6 +6679,7 @@ export class FlowChatStore { const nextTokenUsage: TokenUsage = { inputTokens: tokenUsage.inputTokens, outputTokens: tokenUsage.outputTokens, + cachedTokens: tokenUsage.cachedTokens, totalTokens: tokenUsage.totalTokens, timestamp: Date.now(), ...(tokenUsage.turnId ? { turnId: tokenUsage.turnId } : {}), @@ -6700,6 +6701,10 @@ export class FlowChatStore { const accumulatedTurnUsage: TokenUsage = { inputTokens: (previousTurnUsage?.inputTokens ?? 0) + nextTokenUsage.inputTokens, outputTokens: accumulatedOutputTokens, + cachedTokens: previousTurnUsage + ? (typeof previousTurnUsage.cachedTokens === 'number' && typeof nextTokenUsage.cachedTokens === 'number' + ? previousTurnUsage.cachedTokens + nextTokenUsage.cachedTokens : undefined) + : nextTokenUsage.cachedTokens, totalTokens: (previousTurnUsage?.totalTokens ?? 0) + nextTokenUsage.totalTokens, timestamp: nextTokenUsage.timestamp, }; @@ -9225,6 +9230,7 @@ export class FlowChatStore { ? { inputTokens: rawTokenUsage.inputTokens ?? rawTokenUsage.input_tokens, outputTokens: rawTokenUsage.outputTokens ?? rawTokenUsage.output_tokens, + cachedTokens: rawTokenUsage.cachedTokens ?? rawTokenUsage.cached_tokens, totalTokens: rawTokenUsage.totalTokens ?? rawTokenUsage.total_tokens, timestamp: rawTokenUsage.timestamp, } diff --git a/src/web-ui/src/flow_chat/timeline/readerState.tsx b/src/web-ui/src/flow_chat/timeline/readerState.tsx index e62f0edc20..ac4e248f02 100644 --- a/src/web-ui/src/flow_chat/timeline/readerState.tsx +++ b/src/web-ui/src/flow_chat/timeline/readerState.tsx @@ -11,6 +11,23 @@ export class FlowChatReaderState { private revision = 0; private groupHolds = new Map>(); private reveals = new Map>(); + private turnInteractions = new Map>(); + + /** Ephemeral hover/focus leases shared by a turn's independently virtualized rows. */ + holdTurnInteraction(turn: string): () => void { + const lease = Symbol('turn-interaction'); + const owners = this.turnInteractions.get(turn) ?? new Set(); + owners.add(lease); + this.turnInteractions.set(turn, owners); + this.set(`interaction:${turn}`, true); + return () => { + if (!owners.delete(lease)) return; + if (!owners.size) { + this.turnInteractions.delete(turn); + this.set(`interaction:${turn}`, false); + } + }; + } reportReveal(turn: string, block: string) { const owners = this.reveals.get(turn) ?? new Set(); diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 060a604347..18e794e738 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -233,6 +233,8 @@ export interface ModelRound { export interface TokenUsage { inputTokens: number; outputTokens?: number; + /** Cache-read tokens; absent when any request in the turn did not report them. */ + cachedTokens?: number; totalTokens: number; timestamp: number; /** Persisted source turn used to invalidate usage after history rewrites. */ diff --git a/src/web-ui/src/flow_chat/utils/manualModelSelection.ts b/src/web-ui/src/flow_chat/utils/manualModelSelection.ts new file mode 100644 index 0000000000..220b7bf36a --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/manualModelSelection.ts @@ -0,0 +1,20 @@ +const STORAGE_PREFIX = 'openbitfun.model-selection.manual.v1:'; + +/** Presentation preference only; the existing host model API owns actual selection. */ +export function getRecentManualModel(scopeKey: string): string | undefined { + try { + const value = window.localStorage.getItem(`${STORAGE_PREFIX}${scopeKey}`)?.trim(); + return value || undefined; + } catch { + return undefined; + } +} + +export function setRecentManualModel(scopeKey: string, modelId: string): void { + if (!modelId.trim()) return; + try { + window.localStorage.setItem(`${STORAGE_PREFIX}${scopeKey}`, modelId.trim()); + } catch { + // The mounted selector also retains the choice when storage is unavailable. + } +} diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts index a51dd0c1a4..ff69df7aed 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts @@ -188,6 +188,16 @@ describe('tokenUsageDisplay', () => { ]); }); + it('omits missing or invalid completion metadata and preserves reported zero duration', () => { + const formatTime = () => '12:00:00'; + expect(buildModelRoundCompletionMeta({ formatTime, t })).toEqual([]); + expect(buildModelRoundCompletionMeta({ completedAt: NaN, durationMs: Infinity, formatTime, t })).toEqual([]); + expect(buildModelRoundCompletionMeta({ durationMs: -1, formatTime, t })).toEqual([]); + expect(buildModelRoundCompletionMeta({ durationMs: 0, formatTime, t })).toEqual([ + { key: 'duration', label: 'Duration', value: '0ms' }, + ]); + }); + it('keeps compact token formatting stable for tooltip strings', () => { expect(formatCompactTokenCount(950)).toBe('950'); expect(formatCompactTokenCount(1234)).toBe('1.23K'); diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts index b6cd996c42..4de6fea3ea 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts @@ -270,7 +270,7 @@ export function buildModelRoundCompletionMeta(params: { const { completedAt, durationMs, status, formatTime, t } = params; const items: ModelRoundCompletionMetaItem[] = []; - if (typeof completedAt === 'number') { + if (typeof completedAt === 'number' && Number.isFinite(completedAt)) { items.push({ key: 'completed', label: status === 'cancelled' @@ -280,7 +280,7 @@ export function buildModelRoundCompletionMeta(params: { }); } - if (typeof durationMs === 'number') { + if (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0) { items.push({ key: 'duration', label: t('modelRound.meta.duration'), diff --git a/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts new file mode 100644 index 0000000000..402cf8185d --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { TokenUsage } from '../types/flow-chat'; +import { getTurnUsageMetrics } from './turnUsageMetrics'; + +const usage: TokenUsage = { + inputTokens: 10_000, cachedTokens: 7_200, outputTokens: 2_800, + totalTokens: 12_800, timestamp: 1, +}; + +describe('getTurnUsageMetrics', () => { + it('uses input tokens as the cache denominator and output tokens as the rate numerator', () => { + expect(getTurnUsageMetrics(usage, 40_000)).toEqual({ + totalTokens: 12_800, cacheHitRate: 0.72, outputRate: 70, speedLevel: 3, + }); + }); + + it('keeps legacy and unknown values distinct from reported zeros', () => { + expect(getTurnUsageMetrics(undefined, 1_000)).toEqual({ + totalTokens: null, cacheHitRate: null, outputRate: null, speedLevel: null, + }); + expect(getTurnUsageMetrics({ ...usage, cachedTokens: undefined, outputTokens: undefined }, 1_000)) + .toMatchObject({ totalTokens: 12_800, cacheHitRate: null, outputRate: null, speedLevel: null }); + expect(getTurnUsageMetrics({ ...usage, cachedTokens: 0, outputTokens: 0 }, 1_000)) + .toMatchObject({ cacheHitRate: 0, outputRate: 0, speedLevel: 1 }); + }); + + it.each([undefined, 0, -1, Infinity, NaN])('does not invent throughput for duration %s', duration => { + expect(getTurnUsageMetrics(usage, duration)).toMatchObject({ outputRate: null, speedLevel: null }); + }); + + it.each([[14.9, 1], [15, 2], [39.9, 2], [40, 3], [79.9, 3], [80, 4]])( + 'classifies %s tokens/s without rounding across a tier boundary', (rate, level) => { + expect(getTurnUsageMetrics({ ...usage, outputTokens: rate * 10 }, 10_000).speedLevel).toBe(level); + }, + ); + + it('rejects inconsistent cache telemetry and non-finite token counts', () => { + expect(getTurnUsageMetrics({ ...usage, cachedTokens: 10_001 }).cacheHitRate).toBeNull(); + expect(getTurnUsageMetrics({ ...usage, inputTokens: 0 }).cacheHitRate).toBeNull(); + expect(getTurnUsageMetrics({ ...usage, cachedTokens: NaN, totalTokens: Infinity, outputTokens: -1 }, 1_000)) + .toEqual({ totalTokens: null, cacheHitRate: null, outputRate: null, speedLevel: null }); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts new file mode 100644 index 0000000000..64dbd4a3a7 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts @@ -0,0 +1,20 @@ +import type { TokenUsage } from '../types/flow-chat'; + +function isCount(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function getTurnUsageMetrics(usage?: TokenUsage, durationMs?: number) { + const totalTokens = isCount(usage?.totalTokens) ? usage.totalTokens : null; + const cacheHitRate = isCount(usage?.cachedTokens) && isCount(usage?.inputTokens) + && usage.inputTokens > 0 && usage.cachedTokens <= usage.inputTokens + ? usage.cachedTokens / usage.inputTokens : null; + // Turn wall time is available live and after hydration, including tool/wait time. + // It must not be presented as the provider's pure streaming throughput. + const rate = isCount(usage?.outputTokens) && isCount(durationMs) && durationMs > 0 + ? usage.outputTokens / (durationMs / 1000) : null; + const outputRate = rate !== null && Number.isFinite(rate) ? rate : null; + const speedLevel: 1 | 2 | 3 | 4 | null = outputRate === null ? null + : outputRate < 15 ? 1 : outputRate < 40 ? 2 : outputRate < 80 ? 3 : 4; + return { totalTokens, cacheHitRate, outputRate, speedLevel }; +} diff --git a/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx b/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx index 483c2a2aba..4bfed8dde0 100644 --- a/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx +++ b/src/web-ui/src/infrastructure/design-system/IconUsageIntegration.test.tsx @@ -24,7 +24,10 @@ function filesIn(directory: string): string[] { describe('catalog icon consumer integration', () => { it('renders general-purpose icons through Lucide while retaining reviewed authored artwork', () => { - const preserved = new Set(['minimal', 'standard', 'ultimate', 'creative', 'git', 'thinking']); + const preserved = new Set([ + 'minimal', 'standard', 'ultimate', 'creative', 'git', 'thinking', + 'reasoning-auto', + ]); for (const name of iconNames) { const markup = renderToStaticMarkup(createElement(Icon, { name })); if (preserved.has(name)) { 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 6a37ba384c..f8de637c03 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1702,6 +1702,21 @@ } }, "modelRound": { + "metrics": { + "label": "Turn usage and output speed", + "total": "Total tokens", + "input": "Input tokens", + "output": "Output tokens", + "cache": "Input cache hit", + "averageRate": "Average output", + "speedLevel": "Speed level", + "rateValue": "{{value}} t/s", + "rateBasis": "Output tokens divided by total turn time, including tools and waiting; speed levels change at 15, 40 and 80 tokens/s", + "slow": "Slow", + "normal": "Normal", + "fast": "Fast", + "veryFast": "Very fast" + }, "copyDialog": "Copy dialog", "copiedDialog": "Copied!", "forkDialog": "Fork session from here", @@ -3284,6 +3299,16 @@ "projectCowork": "working in the project." }, "modelSelector": { + "modes": { + "selection": "Select mode", + "manual": "Manual selection", + "manualUnavailable": "Your previous model is unavailable. Choose another model.", + "smart": "Smart mode", + "pool": "Battery mode", + "comingSoon": "Coming soon", + "developmentNotice": "This mode is not ready yet. It currently uses your primary model directly.", + "primaryRequired": "Configure a primary model first" + }, "model": "Model", "modelExplanation": "Model explanation", "modelSettings": "Model settings", @@ -3340,6 +3365,9 @@ "xhighDesc": "Maximum reasoning depth (slower)" }, "reasoningSelector": { + "thinking": "Thinking", + "automatic": "Automatic", + "manual": "Manual", "title": "Reasoning effort", "auto": "Auto", "modelDefault": "model default", 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 da7730ae85..15ee631d0e 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1702,6 +1702,21 @@ } }, "modelRound": { + "metrics": { + "label": "本轮用量与输出速度", + "total": "Token 总量", + "input": "输入 Token", + "output": "输出 Token", + "cache": "输入缓存命中", + "averageRate": "平均输出", + "speedLevel": "速度档位", + "rateValue": "{{value}} t/s", + "rateBasis": "输出 Token 除以本轮总耗时,包含工具执行与等待;档位分界为 15、40、80 Token/秒", + "slow": "慢", + "normal": "一般", + "fast": "快", + "veryFast": "极快" + }, "copyDialog": "复制对话", "copiedDialog": "已复制!", "forkDialog": "从这里分叉会话", @@ -3284,6 +3299,16 @@ "projectCowork": "项目里工作。" }, "modelSelector": { + "modes": { + "selection": "选择模式", + "manual": "手动指定", + "manualUnavailable": "上次选择的模型暂不可用,请重新选择模型。", + "smart": "智能模式", + "pool": "电池模式", + "comingSoon": "即将上线", + "developmentNotice": "当前模式尚未完成,暂时直接使用主力模型。", + "primaryRequired": "请先配置主力模型" + }, "model": "模型", "modelExplanation": "模型说明", "modelSettings": "模型设置", @@ -3340,6 +3365,9 @@ "xhighDesc": "最大推理深度(较慢)" }, "reasoningSelector": { + "thinking": "思考", + "automatic": "自动推理", + "manual": "手动", "title": "推理强度", "auto": "自动", "modelDefault": "模型默认值", @@ -3351,7 +3379,7 @@ "on": "开启", "none": "关闭", "minimal": "极低", - "low": "低", + "low": "轻", "medium": "中", "high": "高", "xhigh": "极高", 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 7b4e3e8ff5..066da1140f 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1702,6 +1702,21 @@ } }, "modelRound": { + "metrics": { + "label": "本輪用量與輸出速度", + "total": "Token 總量", + "input": "輸入 Token", + "output": "輸出 Token", + "cache": "輸入快取命中", + "averageRate": "平均輸出", + "speedLevel": "速度檔位", + "rateValue": "{{value}} t/s", + "rateBasis": "輸出 Token 除以本輪總耗時,包含工具執行與等待;檔位分界為 15、40、80 Token/秒", + "slow": "慢", + "normal": "一般", + "fast": "快", + "veryFast": "極快" + }, "copyDialog": "複製對話", "copiedDialog": "已複製!", "forkDialog": "從這裡分叉會話", @@ -3284,6 +3299,16 @@ "projectCowork": "項目裡工作。" }, "modelSelector": { + "modes": { + "selection": "選擇模式", + "manual": "手動指定", + "manualUnavailable": "上次選擇的模型暫不可用,請重新選擇模型。", + "smart": "智慧模式", + "pool": "電池模式", + "comingSoon": "即將推出", + "developmentNotice": "目前模式尚未完成,暫時直接使用主力模型。", + "primaryRequired": "請先設定主力模型" + }, "model": "模型", "modelExplanation": "模型說明", "modelSettings": "模型設定", @@ -3340,6 +3365,9 @@ "xhighDesc": "最大推理深度(較慢)" }, "reasoningSelector": { + "thinking": "思考", + "automatic": "自動推理", + "manual": "手動", "title": "推理強度", "auto": "自動", "modelDefault": "模型預設值", @@ -3351,7 +3379,7 @@ "on": "開啟", "none": "關閉", "minimal": "極低", - "low": "低", + "low": "輕", "medium": "中", "high": "高", "xhigh": "極高", diff --git a/src/web-ui/src/shared/ui/Tooltip.test.tsx b/src/web-ui/src/shared/ui/Tooltip.test.tsx new file mode 100644 index 0000000000..c13447c768 --- /dev/null +++ b/src/web-ui/src/shared/ui/Tooltip.test.tsx @@ -0,0 +1,243 @@ +// @vitest-environment jsdom +import React, { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesignSystemProvider, Tooltip } from '@openbitfun/ui'; +import { FlowChatMetric, FlowChatTurnMetrics } from '@openbitfun/ui/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('shared tooltip visibility', () => { + let host: HTMLDivElement; + let root: Root; + const popup = () => document.querySelector('[role="tooltip"]'); + const render = (children: React.ReactNode) => act(() => root.render( + {children}, + )); + const enter = (element: Element) => act(() => element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))); + const leave = (element: Element) => act(() => element.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }))); + const press = (element: HTMLElement) => act(() => { + element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + element.click(); + }); + const advance = (ms = 500) => act(() => vi.advanceTimersByTime(ms)); + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + host = document.createElement('div'); document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); host.remove(); + document.querySelectorAll('[data-openbitfun-overlay-host]').forEach(element => element.remove()); + vi.unstubAllGlobals(); vi.useRealTimers(); + }); + + function metrics(focusable = true) { + render(<> + + 1m16s + ); + return Array.from(host.querySelectorAll('button')); + } + + it.each([[0, 'Usage details'], [1, 'Speed details'], [2, 'Duration details']] as const)( + 'opens metric %s from its content immediately, including during a pending hover', (index, content) => { + const button = metrics()[index]; + enter(button); + act(() => button.focus()); + expect(popup()).toBeNull(); + press(button.querySelector('span') ?? button); + expect(popup()?.textContent).toBe(content); + advance(1); + expect(button.getAttribute('aria-describedby')).toBe(popup()?.id); + }, + ); + + it('pins an already hovered card, permits content clicks, and closes on another trigger click', () => { + const button = metrics()[0]; + enter(button); advance(); advance(1); + const card = popup(); + press(button); + expect(popup()).toBe(card); + leave(button); + act(() => button.blur()); + advance(); + press(card!); + expect(popup()).toBe(card); + press(button); + expect(popup()).toBeNull(); + advance(); + expect(popup()).toBeNull(); + }); + + it.each(['outside', 'Escape', 'scroll'])('dismisses a clicked card with %s and cancels delayed reopening', method => { + const button = metrics()[0]; + enter(button); press(button); + if (method === 'outside') press(document.body); + else if (method === 'scroll') act(() => window.dispatchEvent(new Event('scroll'))); + else act(() => button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(popup()).toBeNull(); + advance(); + expect(popup()).toBeNull(); + press(button); + expect(popup()?.textContent).toBe('Usage details'); + }); + + it.each([false, true])('nested scrolling cancels pending and visible cursor-following hints (external ref: %s)', external => { + function Example() { + const triggerRef = useRef(null); + const options = { content: 'Shared details', followCursor: true }; + return external ? <> + : ; + } + render(); + const button = host.querySelector('button')!; + const hover = () => external + ? act(() => button.dispatchEvent(new MouseEvent('mouseenter'))) + : enter(button); + hover(); + act(() => host.dispatchEvent(new Event('scroll'))); + advance(); + expect(popup()).toBeNull(); + hover(); advance(); advance(1); + expect(popup()?.textContent).toBe('Shared details'); + act(() => host.dispatchEvent(new Event('scroll'))); + expect(popup()).toBeNull(); + advance(); + expect(popup()).toBeNull(); + }); + + it('dismisses pinned cards on visual viewport scrolling', () => { + const viewport = new EventTarget(); + vi.stubGlobal('visualViewport', viewport); + const button = metrics()[0]; + press(button); + act(() => viewport.dispatchEvent(new Event('scroll'))); + expect(popup()).toBeNull(); + press(button); + expect(popup()?.textContent).toBe('Usage details'); + }); + + it('switches independent metric cards and preserves ordinary hover and focus previews', () => { + const [usage, speed, duration] = metrics(); + press(usage); press(speed); + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1); + expect(popup()?.textContent).toBe('Speed details'); + press(document.body); + enter(usage); advance(); + expect(popup()?.textContent).toBe('Usage details'); + leave(usage); advance(); + expect(popup()).toBeNull(); + act(() => duration.focus()); advance(); + expect(popup()?.textContent).toBe('Duration details'); + act(() => duration.blur()); + expect(popup()).toBeNull(); + }); + + it('does not open unrevealed metric controls', () => { + for (const button of metrics(false)) { + expect(button.disabled).toBe(true); + press(button); enter(button); advance(); + expect(popup()).toBeNull(); + } + }); + + it.each([false, true])('supports explicit click opening without changing ordinary hints (external ref: %s)', external => { + function Example({ openOnClick }: { openOnClick: boolean }) { + const triggerRef = useRef(null); + return external ? <> + + + : + + ; + } + render(); + const button = host.querySelector('button')!; + if (external) act(() => button.dispatchEvent(new MouseEvent('mouseenter'))); + else enter(button); + press(button); advance(); + expect(popup()).toBeNull(); + render(); + press(button); + expect(popup()?.textContent).toBe('Details card'); + press(document.body); + expect(popup()).toBeNull(); + }); + + it.each([ + { trigger: 'hover', external: false, followCursor: false }, + { trigger: 'hover', external: true, followCursor: true }, + { trigger: 'focus', external: true, followCursor: false }, + { trigger: 'click', external: false, followCursor: false }, + ] as const)('closes $trigger cards when their anchor is clipped (external: $external, cursor: $followCursor)', ({ trigger, external, followCursor }) => { + let notify: ((visible: boolean) => void) | undefined; + const disconnect = vi.fn(); + const observe = vi.fn(); + vi.stubGlobal('IntersectionObserver', class { + constructor(callback: IntersectionObserverCallback) { + notify = visible => callback([{ + target: host.querySelector('button')!, isIntersecting: visible, + intersectionRatio: visible ? 0.1 : 0, + } as IntersectionObserverEntry], this as unknown as IntersectionObserver); + } + observe = observe; + disconnect = disconnect; + }); + function Example() { + const triggerRef = useRef(null); + const options = { trigger, followCursor, content: 'Shared details' }; + return external ? <> + : ; + } + render(); + expect(observe).not.toHaveBeenCalled(); + const button = host.querySelector('button')!; + if (trigger === 'click') press(button); + else if (trigger === 'focus') act(() => button.focus()); + else if (external) act(() => button.dispatchEvent(new MouseEvent('mouseenter'))); + else enter(button); + advance(); advance(1); + expect(popup()?.textContent).toBe('Shared details'); + expect(observe).toHaveBeenCalledWith(button); + act(() => notify!(true)); + expect(popup()).not.toBeNull(); + act(() => notify!(false)); + expect(popup()).toBeNull(); + expect(button.hasAttribute('aria-describedby')).toBe(false); + expect(disconnect).toHaveBeenCalled(); + act(() => notify!(true)); advance(); + expect(popup()).toBeNull(); + }); + + it('clears click persistence on viewport exit and ignores late observations after reopening', () => { + const notifications: Array<(visible: boolean) => void> = []; + vi.stubGlobal('IntersectionObserver', class { + constructor(callback: IntersectionObserverCallback) { + notifications.push(visible => callback([{ + target: host.querySelector('button')!, isIntersecting: visible, + } as IntersectionObserverEntry], this as unknown as IntersectionObserver)); + } + observe() {} + disconnect() {} + }); + const button = metrics()[0]; + press(button); advance(1); + const first = notifications.at(-1)!; + act(() => first(false)); + expect(popup()).toBeNull(); + act(() => first(true)); + expect(popup()).toBeNull(); + press(button); + expect(popup()?.textContent).toBe('Usage details'); + act(() => first(false)); + expect(popup()?.textContent).toBe('Usage details'); + }); +}); From e7630bed6143e2235c066fa5b18feb6726dcf86c Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 7 Oct 2026 09:36:31 +0800 Subject: [PATCH 2/3] feat(ai): organize model pool and reasoning controls --- .../design-lab/src/i18n/componentMetadata.ts | 1 + .../design-lab/src/i18n/flowChatMessages.ts | 27 + .../apps/design-lab/src/i18n/messages.ts | 8 +- .../src/pages/ComponentDetailPage.tsx | 28 +- .../src/preview/FlowChatPreviewRegistry.tsx | 35 + .../vite/component-detail-contract.test.mjs | 6 +- design-system/packages/ui/README.md | 15 +- .../components/IconButton/IconButton.meta.ts | 7 +- .../IconButton/IconButton.module.css | 12 + .../src/components/IconButton/IconButton.tsx | 3 +- src/crates/adapters/ai-adapters/AGENTS.md | 1 + src/crates/adapters/ai-adapters/src/client.rs | 3 + .../adapters/ai-adapters/src/models_dev.rs | 135 ++- .../src/providers/openai/responses.rs | 1 + .../infrastructure/ai/reasoning_catalog.rs | 1 + src/crates/contracts/core-types/src/ai.rs | 23 + .../tests/remote_connect_contracts.rs | 1 + .../pages/ai/ModelPoolCardActions.tsx | 97 ++ .../settings/pages/ai/ModelSettingsPage.scss | 200 +++- .../settings/pages/ai/ModelSettingsPage.tsx | 861 ++++++++++++------ .../app/scenes/settings/settingsRegistry.ts | 8 +- .../components/ModelModeAnimation.scss | 446 ++++----- .../components/ModelModeAnimation.tsx | 329 ++++--- .../components/ModelSelector.appearance.ts | 4 + .../flow_chat/components/ModelSelector.scss | 479 +++++++--- .../flow_chat/components/ModelSelector.tsx | 351 +++---- .../components/ModelSelectorAcpMode.test.tsx | 19 +- .../components/ModelSelectorExternal.test.tsx | 42 +- .../ModelSelectorProviderLevels.test.tsx | 188 ++-- .../components/ReasoningIntensityControl.scss | 590 ++++++++++++ .../ReasoningIntensityControl.test.tsx | 271 ++++++ .../components/ReasoningIntensityControl.tsx | 283 ++++++ .../components/ReasoningIntensitySlider.scss | 281 ------ .../ReasoningIntensitySlider.test.tsx | 200 ---- .../components/ReasoningIntensitySlider.tsx | 166 ---- .../ReasoningPresetSelector.test.tsx | 9 +- .../components/ReasoningPresetSelector.tsx | 21 +- .../reasoningPresetPresentation.test.ts | 56 ++ .../components/reasoningPresetPresentation.ts | 182 ++-- .../config/components/DefaultModelConfig.scss | 25 +- .../config/components/DefaultModelConfig.tsx | 46 +- .../ModelServiceCollapse.presentation.test.ts | 2 +- .../config/components/ModelTagsField.tsx | 46 + .../modelConnectionTestPlan.test.ts | 2 + .../config/services/modelTags.test.ts | 123 +++ .../config/services/modelTags.ts | 66 ++ .../src/infrastructure/config/types/index.ts | 2 + .../locales/en-US/settings/default-model.json | 6 +- .../src/locales/en-US/settings/models.json | 63 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 2 +- .../locales/zh-CN/settings/default-model.json | 6 +- .../src/locales/zh-CN/settings/models.json | 63 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 2 +- .../locales/zh-TW/settings/default-model.json | 6 +- .../src/locales/zh-TW/settings/models.json | 63 +- 55 files changed, 4096 insertions(+), 1817 deletions(-) create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/ModelPoolCardActions.tsx create mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensityControl.scss create mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensityControl.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensityControl.tsx delete mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.scss delete mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.test.tsx delete mode 100644 src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.tsx create mode 100644 src/web-ui/src/flow_chat/components/reasoningPresetPresentation.test.ts create mode 100644 src/web-ui/src/infrastructure/config/components/ModelTagsField.tsx create mode 100644 src/web-ui/src/infrastructure/config/services/modelTags.test.ts create mode 100644 src/web-ui/src/infrastructure/config/services/modelTags.ts diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts index 880918553a..e9bad7fdd0 100644 --- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts +++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts @@ -23,6 +23,7 @@ const descriptionKeys: Readonly> = { ExploreGroup: "component.ExploreGroup.description", ContextLoadGroup: "component.ContextLoadGroup.description", FlowChatRuntimeStatus: "component.FlowChatRuntimeStatus.description", + FlowChatTurnMetrics: "component.FlowChatTurnMetrics.description", OpenBitFunSolidMark: "component.OpenBitFunSolidMark.description", OpenBitFunAppIcon: "component.OpenBitFunAppIcon.description", OpenBitFunMark: "component.OpenBitFunMark.description", diff --git a/design-system/apps/design-lab/src/i18n/flowChatMessages.ts b/design-system/apps/design-lab/src/i18n/flowChatMessages.ts index ee8e30ed70..b2e79955cc 100644 --- a/design-system/apps/design-lab/src/i18n/flowChatMessages.ts +++ b/design-system/apps/design-lab/src/i18n/flowChatMessages.ts @@ -78,6 +78,15 @@ export const flowChatEn = { "component.ThinkingBlock.description": "Reasoning and summaries with controlled disclosure, shared with the conversation.", "component.ExploreGroup.description": "A shared exploration group with controlled disclosure and natural-height content.", "component.FlowChatRuntimeStatus.description": "A resident status slot whose label and visibility are controlled by the application.", + "component.FlowChatTurnMetrics.description": "Turn token usage, cache hits and output speed with accessible details for each metric.", + "flowChat.modelRound.metrics.label": "Turn usage", + "flowChat.modelRound.metrics.total": "Total tokens", + "flowChat.modelRound.metrics.input": "Input tokens", + "flowChat.modelRound.metrics.output": "Output tokens", + "flowChat.modelRound.metrics.cache": "Cache hit rate", + "flowChat.modelRound.metrics.averageRate": "Average output rate", + "flowChat.modelRound.metrics.rateValue": "{value} tokens/s", + "flowChat.modelRound.metrics.rateBasis": "Output tokens divided by the whole turn duration, including tools and waits.", "detail.option.collapsed": "Collapsed", "detail.option.streaming": "Streaming", "detail.option.summary": "Summary", @@ -225,6 +234,15 @@ export const flowChatZhCN = { "component.ThinkingBlock.description": "对话共用的思考与摘要组件,统一展开收起和内容区域。", "component.ExploreGroup.description": "对话共用的探索组,统一折叠与分组摘要,内容自然展开。", "component.FlowChatRuntimeStatus.description": "常驻运行状态区域,由应用提供文案并控制显示。", + "component.FlowChatTurnMetrics.description": "回合 Token 用量、缓存命中与输出速度,每项指标均提供无障碍详情。", + "flowChat.modelRound.metrics.label": "回合用量", + "flowChat.modelRound.metrics.total": "总 Token", + "flowChat.modelRound.metrics.input": "输入 Token", + "flowChat.modelRound.metrics.output": "输出 Token", + "flowChat.modelRound.metrics.cache": "缓存命中率", + "flowChat.modelRound.metrics.averageRate": "平均输出速度", + "flowChat.modelRound.metrics.rateValue": "{value} tokens/s", + "flowChat.modelRound.metrics.rateBasis": "输出 Token 除以整个回合耗时,包含工具执行与等待。", "detail.option.collapsed": "已收起", "detail.option.streaming": "流式更新", "detail.option.summary": "思考摘要", @@ -372,6 +390,15 @@ export const flowChatZhTW = { "component.ThinkingBlock.description": "對話共用的思考與摘要元件,統一展開收起與內容區域。", "component.ExploreGroup.description": "對話共用的探索組,統一折疊與分組摘要,內容自然展開。", "component.FlowChatRuntimeStatus.description": "常駐執行狀態區域,由應用提供文案並控制顯示。", + "component.FlowChatTurnMetrics.description": "回合 Token 用量、快取命中與輸出速度,每項指標均提供無障礙詳情。", + "flowChat.modelRound.metrics.label": "回合用量", + "flowChat.modelRound.metrics.total": "總 Token", + "flowChat.modelRound.metrics.input": "輸入 Token", + "flowChat.modelRound.metrics.output": "輸出 Token", + "flowChat.modelRound.metrics.cache": "快取命中率", + "flowChat.modelRound.metrics.averageRate": "平均輸出速度", + "flowChat.modelRound.metrics.rateValue": "{value} tokens/s", + "flowChat.modelRound.metrics.rateBasis": "輸出 Token 除以整個回合耗時,包含工具執行與等待。", "detail.option.collapsed": "已收合", "detail.option.streaming": "串流更新", "detail.option.summary": "思考摘要", diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index bed278b497..3c3fd7c1f7 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -163,7 +163,7 @@ 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 quiet, filled, and primary presentation variants.", + "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.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.", @@ -556,6 +556,7 @@ export const enUSMessages = { "detail.option.fill": "Filled", "detail.option.primary": "Primary", "detail.option.quiet": "Quiet", + "detail.option.annotation": "Annotation", "detail.option.text": "Text", "detail.option.sm": "Small", "detail.option.start": "Start", @@ -968,7 +969,7 @@ export const zhCNMessages = { "component.GitToolCard.description": "关注型 Git 命令卡片,统一呈现输出、警告、失败与执行元数据。", "component.GlobSearchToolCard.description": "复用结果列表结构的低关注文件模式搜索卡片。", "component.GrepSearchToolCard.description": "带查询元数据和可滚动结果预览的低关注文本搜索卡片。", - "component.IconButton.description": "带无障碍标签的纯图标操作组件,支持安静、填充和主要呈现方式。", + "component.IconButton.description": "带无障碍标签的纯图标组件,支持标注、安静、描边、填充和主要呈现方式。标注以小尺寸、弱化颜色提供补充说明入口。", "component.Input.description": "使用原生输入语义,并提供独立前置与后置内容槽的文本字段。", "component.KeyHint.description": "使用键盘提示语义,并支持可选装饰图标的基础组件。", "component.LoadingState.description": "由活动指示器与可选说明文字组成的居中加载状态。", @@ -1361,6 +1362,7 @@ export const zhCNMessages = { "detail.option.fill": "填充", "detail.option.primary": "主要", "detail.option.quiet": "安静", + "detail.option.annotation": "标注", "detail.option.text": "文字", "detail.option.sm": "小", "detail.option.start": "起始", @@ -1770,6 +1772,7 @@ export const zhTWMessages = { "meta.category.primitive": "基礎元件", "meta.category.other": "元件", "component.ActionCard.description": "可獨立設定前置內容、標題、說明、選取狀態和同級尾端操作的可互動卡片。", + "component.IconButton.description": "帶無障礙標籤的純圖示元件,支援標註、安靜、描邊、填滿和主要呈現方式。標註以小尺寸、弱化顏色提供補充說明入口。", "component.MobileActionSheet.description": "用於統一呈現一般和危險操作清單的可關閉行動端彈層。", "component.MobileBadge.description": "用於狀態、身分與中繼資料的緊湊行動端標籤。", "component.MobileBanner.description": "支援語義色調和可選操作的行動端行內狀態橫幅。", @@ -2109,6 +2112,7 @@ export const zhTWMessages = { "detail.icon": "圖示", "detail.iconPosition": "圖示位置", "detail.option.outline": "描邊", + "detail.option.annotation": "標註", "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 61b8bf0de4..f9aec4b64b 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -163,7 +163,7 @@ type PageHeaderAlign = "center" | "start"; type PageHeaderSize = "display" | "lg" | "md" | "sm"; const buttonVariants = ["outline", "fill", "secondary", "primary", "text"] as const; -const iconButtonVariants = ["quiet", "outline", "fill", "primary"] as const; +const iconButtonVariants = ["annotation", "quiet", "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; @@ -230,6 +230,7 @@ const optionLabelKeys: Readonly> = { on: "detail.option.on", open: "detail.option.open", outline: "detail.option.outline", + annotation: "detail.option.annotation", primary: "detail.option.primary", quiet: "detail.option.quiet", raised: "detail.option.raised", @@ -538,11 +539,12 @@ export function ComponentDetailPage({ if (component.name === "IconButton") { const stateProps = `${inspectorDisabled ? " disabled" : ""}${inspectorLoading ? " loading" : ""}`; - return `import { IconButton } from "@openbitfun/ui";\nimport { List } from "lucide-react";\n\n}\n variant="${iconButtonVariant}"\n size="${iconButtonSize}"\n shape="${iconButtonShape}"${stateProps}\n/>`; + const annotation = iconButtonVariant === "annotation"; + return `import { Icon, IconButton } from "@openbitfun/ui";${annotation ? "" : '\nimport { List } from "lucide-react";'}\n\n}\n variant="${iconButtonVariant}"\n size="${iconButtonSize}"\n shape="${iconButtonShape}"${stateProps}\n/>`; } if (component.name === "Field") { const labelAction = fieldShowLabelAction - ? `\n labelAction={} size="xs" />}` + ? `\n labelAction={\n \n } size="xs" variant="annotation" />\n \n }` : ""; const controlLeading = fieldShowControlLeading ? `\n controlLeading={}` @@ -550,7 +552,7 @@ export function ComponentDetailPage({ const controlTrailing = fieldShowControlTrailing ? `\n controlTrailing={} size="xs" />}` : ""; - return `import { Icon, Field, IconButton, Input, Switch } from "@openbitfun/ui";\n\n\n } />\n`; + return `import { Icon, Field, IconButton, Input, Switch, Tooltip } from "@openbitfun/ui";\n\n\n } />\n`; } if (component.name === "Input") { const stateProps = previewState === "disabled" @@ -867,13 +869,14 @@ export function ComponentDetailPage({ previewVariant = iconButtonVariant, applyInspectorControls = false, ) { + const annotation = previewVariant === "annotation"; return (
)} + updateModelDraft(draft.modelName, { userTags })} + /> + + )} + > + {apiProviderGroups.length > 0 ? ( +
    + {apiProviderGroups.map(group => ( +
  • + + + +
  • + ))} +
+ ) : {t('acquisition.noneConfigured')}} + + +
+ {t('acquisition.subscriptions')} + {subscriptionProviderGroups.length > 0 && ( + + {t('acquisition.importedSummary', { + providers: i18nService.formatNumber(subscriptionProviderGroups.length), + models: i18nService.formatNumber(subscriptionModelCount), + })} + + )} +
+ + + )} + > + {subscriptionProviderGroups.length > 0 ? ( +
    + {subscriptionProviderGroups.map(group => ( +
  • + +
  • + ))} +
+ ) : {t('acquisition.noneImported')}} +
+ + + + + {t('selectionModes.unavailable')} + + + {t('selectionModes.unavailable')} + - - } +
+ + + setShowTagSyncNotice(true)} + icon={} /> + + {modelsDevStatusAvailable && ( + + { setShowModelsDevDetails(true); void handleRefreshModelsDev(); }} + icon={} /> + + )} + {aiModels.length > 0 && ( + + setShowProviderManager(true)} + icon={} + /> + + )} + + )}> + {aiModels.length > 0 && ( +
+ +
+ )} + {hasSuspendedEditorDraft && ( + setIsEditing(true)} onDiscard={closeEditingModal} /> + )} + {aiModels.length > 0 ? ( + <> +
+ setPoolQuery(event.target.value)} placeholder={t('pool.search')} + aria-label={t('pool.search')} /> + setPoolProvider(String(value))} + options={[{ value: '', label: t('pool.allProviders') }, ...poolProviderOptions]} /> +
+ {visiblePoolModels.length > 0 ? ( +
+ {visiblePoolModels.map(config => renderModelCollectionItem(config, 'card'))} +
+ ) : ( +
+
+ + +
+ + + setStreamTtftTimeoutInput(e.target.value)} + placeholder={t('streamTtftTimeout.placeholder')} + size="sm" + /> + + + setStreamIdleTimeoutInput(e.target.value)} + placeholder={t('streamIdleTimeout.placeholder')} + size="sm" + /> + + void handleSaveStreamTimeouts()} + onDiscard={discardStreamTimeoutDraft} /> - + + + + + setProxyConfig(prev => ({ ...prev, enabled: e.target.checked }))} + /> + + + setProxyConfig(prev => ({ ...prev, url: e.target.value }))} + placeholder={t('proxy.urlPlaceholder')} + disabled={!proxyConfig.enabled} + size="sm" + /> + + + setProxyConfig(prev => ({ ...prev, username: e.target.value }))} + placeholder={t('proxy.usernamePlaceholder')} + disabled={!proxyConfig.enabled} + size="sm" + /> + + + setProxyConfig(prev => ({ ...prev, password: e.target.value }))} + placeholder={t('proxy.passwordPlaceholder')} + disabled={!proxyConfig.enabled} + size="sm" + /> + + void handleSaveProxy()} + onDiscard={discardProxyDraft} + /> + +
+
+ + + { if (!open && !loggingInProvider) setShowSubscriptionManager(false); }} size="xl"> + + {t('acquisition.importTitle')} + void refreshSubscriptionAccounts()} disabled={isLoadingSubscriptions} + icon={} /> + + + + {subscriptionLoadError && ( + void refreshSubscriptionAccounts()} /> + )} + {isLoadingSubscriptions &&

{t('messages.loading')}

} + {!isLoadingSubscriptions && !subscriptionLoadError && subscriptionAccounts.length === 0 && ( +
+ + - - } + + + {t('pool.manageConnections')} + + + + + } + /> + + )} + > + {hasSuspendedEditorDraft && ( + setIsEditing(true)} + onDiscard={closeEditingModal} /> - - )} - > - {hasSuspendedEditorDraft && ( - setIsEditing(true)} - onDiscard={closeEditingModal} - /> - )} - {aiModels.length === 0 ? ( - } - - - - setStreamTtftTimeoutInput(e.target.value)} - placeholder={t('streamTtftTimeout.placeholder')} - size="sm" - /> - - - setStreamIdleTimeoutInput(e.target.value)} - placeholder={t('streamIdleTimeout.placeholder')} - size="sm" - /> - - void handleSaveStreamTimeouts()} - onDiscard={discardStreamTimeoutDraft} - /> - + ); + })} + + )} + + + - - - setProxyConfig(prev => ({ ...prev, enabled: e.target.checked }))} - /> - - - setProxyConfig(prev => ({ ...prev, url: e.target.value }))} - placeholder={t('proxy.urlPlaceholder')} - disabled={!proxyConfig.enabled} - size="sm" - /> - - - setProxyConfig(prev => ({ ...prev, username: e.target.value }))} - placeholder={t('proxy.usernamePlaceholder')} - disabled={!proxyConfig.enabled} - size="sm" - /> - - - setProxyConfig(prev => ({ ...prev, password: e.target.value }))} - placeholder={t('proxy.passwordPlaceholder')} - disabled={!proxyConfig.enabled} - size="sm" - /> - - void handleSaveProxy()} - onDiscard={discardProxyDraft} - /> - - + + + {t('pool.syncTags')} + + +

{t('pool.recommendationsUnavailable')}

+ + + +
{ {t('modelsDevCatalog.detailsTitle')} + + void handleRefreshModelsDev()} + icon={} /> + diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index 84b4839352..34b39fd952 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -141,10 +141,16 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ categoryId: 'ai', labelKey: 'navigation.pages.models.label', descriptionKey: 'navigation.pages.models.description', - keywords: ['model', 'provider', 'api key', 'base url', 'proxy', 'network', 'subscription'], + keywords: ['model', 'provider', 'api key', 'base url', 'proxy', 'network', 'subscription', 'pool', 'tag', 'smart', 'battery'], namespaces: ['settings/models', 'settings/default-model', 'components'], searchPhrases: [ phrase('settings/models', 'title'), + phrase('settings/models', 'sections.acquisition'), + phrase('settings/models', 'sections.selectionModes'), + phrase('settings/models', 'sections.pool'), + phrase('settings/models', 'selectionModes.smart'), + phrase('settings/models', 'selectionModes.battery'), + phrase('settings/models', 'pool.tagsLabel'), phrase('settings/default-model', 'sections.defaults'), phrase('settings/default-model', 'sections.providers'), phrase('settings/default-model', 'sections.proxy'), diff --git a/src/web-ui/src/flow_chat/components/ModelModeAnimation.scss b/src/web-ui/src/flow_chat/components/ModelModeAnimation.scss index 8ce2b9678d..06a3e4882b 100644 --- a/src/web-ui/src/flow_chat/components/ModelModeAnimation.scss +++ b/src/web-ui/src/flow_chat/components/ModelModeAnimation.scss @@ -1,10 +1,9 @@ .openbitfun-model-mode { - --_model-mode-cycle: 16s; - --_model-mode-phase: 0s; + --_model-mode-cycle: 8s; position: absolute; inset: 0; - // Active star clouds stay above ambient haze, beneath the centered menu label. + // Starlight stays above ambient haze, beneath the centered menu label. z-index: 1; inline-size: 100%; block-size: 100%; @@ -15,289 +14,324 @@ pointer-events: none; user-select: none; - &[data-mode='pool'] { - --_model-mode-cycle: 20s; + &[data-mode='smart'] { + // Neutral starlight stays distinct from the colored nebula. + color: var(--openbitfun-color-content-on-dark); + fill: currentColor; } - &__region--lower, - &__region--next { - --_model-mode-phase: calc(var(--_model-mode-cycle) * -0.5); + &[data-mode='pool'] { + --_model-mode-cycle: 6.6s; + --_model-battery-tint: color-mix(in oklch, var(--openbitfun-color-status-success-emphasis) 72%, var(--openbitfun-color-link-default)); + color: color-mix(in srgb, var(--_model-battery-tint) 65%, var(--openbitfun-color-content-on-dark)); } - &__source, - &__destination, - &__traveler, - &__energy, - &__transfer { - animation-duration: var(--_model-mode-cycle); - animation-timing-function: ease-in-out; - animation-delay: calc(var(--_model-mode-phase) + var(--_model-star-delay)); - animation-iteration-count: infinite; + &__nebula { + fill: color-mix(in srgb, var(--openbitfun-color-surface-panel) 70%, var(--openbitfun-color-content-on-dark)); + opacity: 0.85; + transform-origin: center; + animation: openbitfun-model-mode-nebula 9s ease-in-out -3s infinite alternate; } - &__source { - opacity: calc(var(--_model-star-opacity) * 0.58); - animation-name: openbitfun-model-stars-listen; + &__nebula-field { + opacity: 0.22; + + :where([data-color-scheme='dark']) & { + opacity: 0.085; + } } - &__destination { - opacity: calc(var(--_model-star-opacity) * 0.16); - transform: translate(var(--_model-star-spread-x), var(--_model-star-spread-y)); - animation-name: openbitfun-model-stars-gather; + :where([data-mode='smart']) &__nebula { + // Move the overscanned texture as a whole, with enough coverage at both ends. + transform-box: view-box; + animation-name: openbitfun-model-smart-nebula-drift; } - &__region--upper &__destination { - // Retain a readable density difference when animation is disabled. - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); + &[data-mode='smart'] &__nebula { + fill: color-mix(in oklch, var(--openbitfun-color-link-default) 58%, var(--openbitfun-color-accent-secondary)); } - &__traveler, - &__transfer { - opacity: 0; + &__sky-layer { + --_model-star-drift-x: 3px; + --_model-star-drift-y: 1.5px; + + // The whole sky shares one slow direction; only depth changes its travel distance. + animation: openbitfun-model-sky-drift 12s ease-in-out -4.5s infinite alternate; } - &__traveler { - animation-name: openbitfun-model-stars-route; + &__sky-layer--far { + opacity: 0.54; + + :where([data-color-scheme='light']) & { + opacity: 0.8; + } } - &__energy { - opacity: var(--_model-star-opacity); - animation-name: openbitfun-model-stars-charge; + &__sky-layer--middle { + --_model-star-drift-x: 5px; + --_model-star-drift-y: 2.5px; + + opacity: 0.78; + + :where([data-color-scheme='light']) & { + opacity: 1; + } } - &__region--next &__energy { - opacity: calc(var(--_model-star-opacity) * 0.2); + &__sky-layer--near { + --_model-star-drift-x: 10px; + --_model-star-drift-y: 5px; } - &__transfer { - animation-name: openbitfun-model-stars-transfer; + &__sky-star { + opacity: var(--_model-star-opacity); + animation: openbitfun-model-sky-twinkle var(--_model-star-duration) ease-in-out var(--_model-star-delay) infinite; } -} -// Uneven pauses and per-star offsets keep long sequences alive without a rigid moving cloud. -@keyframes openbitfun-model-stars-listen { - 0%, 100% { - opacity: calc(var(--_model-star-opacity) * 0.6); - transform: translate(0, 0); + &__sky-star--near { + animation-name: openbitfun-model-sky-flare; } - 9%, 14% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); + + &__sky-glow { + // Keep a tight corona around the crisp core, especially against a dark sky. + opacity: 0.8; + + :where([data-color-scheme='dark']) & { + opacity: 0.6; + } } - 25% { - opacity: calc(var(--_model-star-opacity) * 0.38); - transform: translate(var(--_model-star-sway-x), var(--_model-star-sway-y)); + + &__sky-core { + :where([data-color-scheme='light']) & { + // A white edge reinforces the luminous core without a gray outline. + stroke: currentColor; + stroke-width: 0.3; + } } - 39% { - opacity: calc(var(--_model-star-opacity) * 0.72); - transform: translate(0, 0); + + &[data-mode='pool'] &__nebula { + fill: color-mix(in srgb, currentColor 68%, var(--openbitfun-color-content-on-dark)); } - 54%, 60% { - opacity: calc(var(--_model-star-opacity) * 0.9); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); + + &[data-mode='pool'] &__nebula-field { + opacity: 0.28; + + :where([data-color-scheme='dark']) & { + opacity: 0.14; + } } - 74% { - opacity: calc(var(--_model-star-opacity) * 0.42); - transform: translate(calc(var(--_model-star-sway-x) * -1), calc(var(--_model-star-sway-y) * -1)); + + &__battery-silhouettes { + opacity: 0.9; + + :where([data-color-scheme='dark']) & { + opacity: 0.8; + } } - 88% { - opacity: calc(var(--_model-star-opacity) * 0.76); - transform: translate(var(--_model-star-sway-x), var(--_model-star-sway-y)); + + &__battery-glass { + color: color-mix(in srgb, currentColor 25%, var(--openbitfun-color-content-on-dark)); } -} -// Respond to both a direct choice and the other cloud's relayed stars half a cycle later. -@keyframes openbitfun-model-stars-gather { - 0%, 100% { - opacity: calc(var(--_model-star-opacity) * 0.35); - transform: translate(var(--_model-star-spread-x), var(--_model-star-spread-y)); + &__battery-rim { + // A tinted fine edge reads on light glass; dark glass retains a pale reflection. + color: color-mix(in srgb, var(--_model-battery-tint) 50%, var(--openbitfun-color-content-on-dark)); + + :where([data-color-scheme='dark']) & { + color: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 72%, currentColor); + } } - 12%, 20% { - opacity: calc(var(--_model-star-opacity) * 0.78); - transform: translate(var(--_model-star-sway-x), var(--_model-star-sway-y)); + + &__battery-energy, + &__battery-particles { + animation: openbitfun-model-battery-breathe var(--_model-mode-cycle) ease-in-out calc(var(--_model-mode-cycle) * var(--_model-star-cell-phase)) infinite; } - 31%, 37% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); + + &__battery-shell { + // Glass stays present while only the light inside it breathes. + opacity: 0.9; } - 46% { - opacity: calc(var(--_model-star-opacity) * 0.62); - transform: translate(var(--_model-star-sway-x), var(--_model-star-sway-y)); + + &__battery-light { + // Pale mint light sits above the jade tint instead of adding another dense green fill. + color: color-mix(in srgb, var(--_model-battery-tint) 55%, var(--openbitfun-color-content-on-dark)); + + :where([data-color-scheme='dark']) & { + color: color-mix(in srgb, var(--_model-battery-tint) 64%, var(--openbitfun-color-content-on-dark)); + } } - 58% { - opacity: calc(var(--_model-star-opacity) * 0.26); - transform: translate(var(--_model-star-spread-x), var(--_model-star-spread-y)); + + &__battery-energy { + fill-opacity: 0.72; + opacity: 0.8; + + :where([data-color-scheme='dark']) & { + fill-opacity: 0.42; + } } - 72% { - opacity: calc(var(--_model-star-opacity) * 0.5); - transform: translate(calc(var(--_model-star-sway-x) * -1), calc(var(--_model-star-sway-y) * -1)); + + &__battery-outline { + // A steady reflection keeps the shape legible while its interior breathes. + opacity: 0.82; + stroke-width: 0.85; } - 86% { - opacity: calc(var(--_model-star-opacity) * 0.12); - transform: translate(var(--_model-star-spread-x), var(--_model-star-spread-y)); + + &__battery-outline--right { + // Compensate for the deeper sky at the right edge without darkening the glass. + opacity: 1; } -} -// Probe, choose, settle, then reconsider via the card's outer edge; resets happen while hidden. -@keyframes openbitfun-model-stars-route { - 0%, 100% { - opacity: calc(var(--_model-star-opacity) * 0.25); - transform: translate(0, 0); + &__battery-particles { + fill: color-mix(in srgb, var(--_model-battery-tint) 55%, var(--openbitfun-color-content-on-dark)); + opacity: 0.9; + + :where([data-color-scheme='dark']) & { + fill: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 88%, currentColor); + } } - 4% { - opacity: var(--_model-star-opacity); - transform: translate(0, 0); + + &__battery-spark { + fill: var(--_model-battery-tint); + + :where([data-color-scheme='dark']) & { + fill: color-mix(in srgb, currentColor 70%, var(--openbitfun-color-content-on-dark)); + } } - 12%, 16% { - opacity: calc(var(--_model-star-opacity) * 0.82); - transform: translate(var(--_model-star-probe-x), var(--_model-star-probe-y)); + + &__battery-particle { + opacity: calc(var(--_model-star-opacity) * 0.75); + animation: openbitfun-model-battery-rise var(--_model-star-duration) linear var(--_model-star-delay) infinite; } - 23% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-mid-x), var(--_model-star-mid-y)); +} + +@keyframes openbitfun-model-sky-drift { + from { + transform: translate(calc(var(--_model-star-drift-x) * -1), var(--_model-star-drift-y)); } - 31%, 35% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-end-x), var(--_model-star-end-y)); + + to { + transform: translate(var(--_model-star-drift-x), calc(var(--_model-star-drift-y) * -1)); } - 41% { - opacity: calc(var(--_model-star-opacity) * 0.18); - transform: translate(var(--_model-star-end-x), var(--_model-star-end-y)); +} + +// Hold the bright phase long enough to read before each star fades independently. +@keyframes openbitfun-model-sky-twinkle { + 0%, 100% { + opacity: calc(var(--_model-star-opacity) * 0.3); } - 47% { - opacity: calc(var(--_model-star-opacity) * 0.78); - transform: translate(var(--_model-star-end-x), var(--_model-star-end-y)); + + 34% { + opacity: calc(var(--_model-star-opacity) * 0.62); } - 54% { + + 44%, 60% { opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-relay-x), var(--_model-star-relay-y)); } - 64%, 70% { - opacity: calc(var(--_model-star-opacity) * 0.9); - transform: translate(var(--_model-star-alternate-x), var(--_model-star-alternate-y)); - } - 80%, 84% { - opacity: 0; - transform: translate(var(--_model-star-release-x), var(--_model-star-release-y)); + + 76% { + opacity: calc(var(--_model-star-opacity) * 0.52); } - 85%, 91% { - opacity: 0; - transform: translate(0, 0); + + 88% { + opacity: calc(var(--_model-star-opacity) * 0.7); } } -// Build from a small reservoir, sustain with a local swirl, release, then slowly replenish. -@keyframes openbitfun-model-stars-charge { +@keyframes openbitfun-model-sky-flare { 0%, 100% { - opacity: calc(var(--_model-star-opacity) * 0.48); - transform: translate(var(--_model-star-seed-x), var(--_model-star-seed-y)); - } - 8% { - opacity: calc(var(--_model-star-opacity) * 0.8); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); - } - 16% { - opacity: var(--_model-star-opacity); - transform: translate(0, 0); + opacity: calc(var(--_model-star-opacity) * 0.16); } - 23% { - opacity: calc(var(--_model-star-opacity) * 0.82); - transform: translate(var(--_model-star-sway-x), var(--_model-star-sway-y)); + + 28% { + opacity: calc(var(--_model-star-opacity) * 0.56); } - 29% { + + 44%, 62% { opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); } - 38% { - opacity: calc(var(--_model-star-opacity) * 0.68); - transform: translate(var(--_model-star-spread-x), var(--_model-star-spread-y)); - } - 49% { - opacity: calc(var(--_model-star-opacity) * 0.16); - transform: translate(var(--_model-star-sway-x), var(--_model-star-sway-y)); + + 82% { + opacity: calc(var(--_model-star-opacity) * 0.38); } - 61% { - opacity: calc(var(--_model-star-opacity) * 0.24); - transform: translate(var(--_model-star-spread-x), var(--_model-star-spread-y)); +} + +@keyframes openbitfun-model-smart-nebula-drift { + from { + transform: translate(-8%, 5%) scale(1.3); + opacity: 0.75; } - 73% { - opacity: calc(var(--_model-star-opacity) * 0.12); - transform: translate(calc(var(--_model-star-sway-x) * -1), calc(var(--_model-star-sway-y) * -1)); + + to { + transform: translate(8%, -5%) scale(1.3); + opacity: 1; } - 84% { - opacity: calc(var(--_model-star-opacity) * 0.3); - transform: translate(var(--_model-star-gather-x), var(--_model-star-gather-y)); +} + +@keyframes openbitfun-model-mode-nebula { + from { + transform: translate(-1%, 1%) scale(1.04); + opacity: 0.7; } - 94% { - opacity: calc(var(--_model-star-opacity) * 0.4); - transform: translate(var(--_model-star-seed-x), var(--_model-star-seed-y)); + + to { + transform: translate(1%, -1%) scale(1.08); + opacity: 1; } } -// Small staggered groups pass on their charge, then return along a different side to replenish. -@keyframes openbitfun-model-stars-transfer { - 0%, 22%, 96%, 100% { - opacity: 0; - transform: translate(0, 0); +// Overlapping phases pass a soft emphasis left to right without moving the glass. +@keyframes openbitfun-model-battery-breathe { + 0%, 100% { + opacity: 0.7; } + 26% { - opacity: calc(var(--_model-star-opacity) * 0.7); - transform: translate(0, 0); - } - 30% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-outlet-x), var(--_model-star-outlet-y)); + opacity: 0.98; } - 35% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-mid-x), var(--_model-star-mid-y)); - } - 40% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-inlet-x), var(--_model-star-inlet-y)); + + 52% { + opacity: 0.82; } - 43% { - opacity: calc(var(--_model-star-opacity) * 0.82); - transform: translate(var(--_model-star-end-x), var(--_model-star-end-y)); + + 76% { + opacity: 0.7; } - 47%, 65% { +} + +// Short rising currents fade before wrapping; individual phases prevent a wave of resets. +@keyframes openbitfun-model-battery-rise { + 0% { opacity: 0; - transform: translate(var(--_model-star-end-x), var(--_model-star-end-y)); - } - 68% { - opacity: calc(var(--_model-star-opacity) * 0.65); - transform: translate(var(--_model-star-end-x), var(--_model-star-end-y)); - } - 73% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-inlet-x), calc(var(--_model-star-inlet-y) * -1)); + transform: translate(0, var(--_model-star-rise-y)); } - 79% { - opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-mid-x), calc(var(--_model-star-mid-y) * -1)); + + 22% { + opacity: calc(var(--_model-star-opacity) * 0.72); } - 84% { + + 48% { opacity: var(--_model-star-opacity); - transform: translate(var(--_model-star-outlet-x), calc(var(--_model-star-outlet-y) * -1)); + transform: translate(var(--_model-star-sway-x), 0); } - 89% { - opacity: calc(var(--_model-star-opacity) * 0.82); - transform: translate(0, var(--_model-star-end-y)); + + 78% { + opacity: calc(var(--_model-star-opacity) * 0.62); } - 94% { + + 100% { opacity: 0; - transform: translate(0, var(--_model-star-end-y)); + transform: translate(0, calc(var(--_model-star-rise-y) * -1)); } } @media (prefers-reduced-motion: reduce) { .openbitfun-model-mode { - &__source, - &__destination, - &__traveler, - &__energy, - &__transfer { + &__nebula, + &__sky-layer, + &__sky-star, + &__battery-energy, + &__battery-particles, + &__battery-particle { animation: none; } } diff --git a/src/web-ui/src/flow_chat/components/ModelModeAnimation.tsx b/src/web-ui/src/flow_chat/components/ModelModeAnimation.tsx index 21e6a5d47f..008cd30290 100644 --- a/src/web-ui/src/flow_chat/components/ModelModeAnimation.tsx +++ b/src/web-ui/src/flow_chat/components/ModelModeAnimation.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useId } from 'react'; import './ModelModeAnimation.scss'; interface ModelModeAnimationProps { @@ -15,29 +15,207 @@ function starCloud(count: number, width: number, height: number, phase: number) return { x: Math.cos(angle) * spread * width / 2, y: Math.sin(angle) * spread * height / 2, - radius: 0.7 + (index % 4) * 0.2, - opacity: 0.5 + (index % 5) * 0.08, + radius: 0.65 + (index % 4) * 0.18, + opacity: 0.7 + (index % 5) * 0.06, }; }); } -const sourceStars = starCloud(9, 30, 36, 0.7); -const destinationStars = starCloud(18, 45, 29, 1.1); -const travelingStars = starCloud(20, 46, 30, 2.3); -const energyStars = starCloud(52, 69, 83, 0.4); -const transferStars = starCloud(12, 38, 23, 1.8); -const routingRegions = [ - { side: 'upper', y: 29, edgeY: 17, alternateY: 101 }, - { side: 'lower', y: 101, edgeY: 111, alternateY: 29 }, -] as const; -const energyRegions = [ - { side: 'first', x: 53 }, - { side: 'next', x: 227 }, -] as const; +const batteryStars = [0.6, 2.1, 3.7].map((phase) => starCloud(24, 50, 78, phase)); +const batteryCenters = [52, 140, 228] as const; + +// Different depths share a stable sky, but never a shared twinkle phase. +const smartStarLayers = [ + { depth: 'far', count: 48, radius: 0.4 }, + { depth: 'middle', count: 28, radius: 0.64 }, + { depth: 'near', count: 12, radius: 1.08 }, +].map(({ depth, count, radius }, layerIndex) => ({ + depth, + stars: Array.from({ length: count }, (_, index) => ({ + x: 6 + (index * 61 + index * index * 13 + layerIndex * 31) % 269, + y: 7 + (index * 43 + index * index * 7 + layerIndex * 23) % 113, + radius: radius + (index % 3) * 0.12, + opacity: 0.66 + layerIndex * 0.1 + (index % 3) * 0.05, + duration: `${3.4 - layerIndex * 0.6 + (index % 7) * 0.25}s`, + delay: `${-(index * 1.37 + layerIndex * 2.71)}s`, + })), +})); const px = (value: number) => `${value.toFixed(2)}px`; -/** Decorative density changes only; no routing lines, cell outlines or runtime state. */ +function SmartStarfield() { + const skyId = useId(); + + return ( + + + + + + + + + + + + + + + + + + + {smartStarLayers.map(({ depth, stars }) => ( + + {stars.map((star, index) => ( + + + {depth === 'near' && ( + + )} + + + + ))} + + ))} + + ); +} + +function ModeNebula() { + const nebulaId = useId(); + + return ( + + + + + + + + + + + ); +} + +function BatteryMaterial() { + const batteryId = useId(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {batteryCenters.map((x, cellIndex) => ( + + + + + + {batteryStars[cellIndex].map((star, index) => ( + + {index % 6 === 0 && ( + + )} + + + ))} + + + + + ))} + + ); +} + +/** Ambient material only; battery silhouettes do not represent live capacity. */ export const ModelModeAnimation: React.FC = ({ mode }) => ( = ({ mode }) aria-hidden="true" focusable="false" > - {mode === 'smart' ? ( - <> - {sourceStars.map((star, index) => ( - - ))} - {routingRegions.map(({ side, y, edgeY, alternateY }) => ( - - {destinationStars.map((star, index) => ( - - ))} - {travelingStars.map((star, index) => ( - - ))} - - ))} - - ) : energyRegions.map(({ side, x }) => ( - - {energyStars.map((star, index) => ( - - ))} - {transferStars.map((star, index) => ( - - ))} - - ))} + + {mode === 'smart' ? : } ); diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts b/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts index 5fafcc8dd6..478e6d921a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ModelSelector.appearance.ts @@ -8,8 +8,12 @@ export const modelSelectorAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'name' }, { id: 'selectionSummary' }, { id: 'modeMaterial' }, + { id: 'modeChoices' }, { id: 'reasoningSummary' }, { id: 'reasoningSlider' }, + { id: 'reasoningSliderSky' }, + { id: 'reasoningSliderNebula' }, + { id: 'reasoningSliderAurora' }, { id: 'reasoningSliderInput' }, { id: 'reasoningSliderValue' }, { id: 'dropdown' }, diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index 097dbad714..2540b48e0f 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -9,36 +9,15 @@ @use 'sass:list'; @use 'sass:math'; -// A small, deterministic grain tile gives the glass texture without image assets. -// Keep it on the surface background so the animated stars never move the grain. -$model-mode-grain: (); -$model-mode-grain-sizes: (); -@for $grain-index from 1 through 32 { - $grain-x: math.div(($grain-index * 37 + $grain-index * $grain-index * 11) % 101, 101) * 100%; - $grain-y: math.div(($grain-index * 53 + $grain-index * $grain-index * 7) % 103, 103) * 100%; - $grain-radius: 0.25px + ($grain-index % 3) * 0.08px; - $grain-color: var(--_model-mode-grain-light); - @if $grain-index % 3 == 0 { - $grain-color: var(--_model-mode-grain-shadow); - } - $model-mode-grain: list.append( - $model-mode-grain, - radial-gradient(circle at $grain-x $grain-y, $grain-color 0 $grain-radius, transparent 0.8px), - comma - ); - $model-mode-grain-sizes: list.append($model-mode-grain-sizes, 31px 29px, comma); -} - -// Distant dust stays visible beneath the moving stars and mode-specific light paths. +// Match the reasoning sky's fine dust while preserving each mode's own palette. $model-mode-dust: (); $model-mode-dust-sizes: (); -@for $dust-index from 1 through 28 { - $dust-x: math.div(($dust-index * 43 + $dust-index * $dust-index * 13) % 127, 127) * 100%; - $dust-y: math.div(($dust-index * 59 + $dust-index * $dust-index * 17) % 131, 131) * 100%; - $dust-radius: 0.3px + ($dust-index % 3) * 0.12px; +@for $dust-index from 1 through 72 { + $dust-x: math.div(($dust-index * 31 + $dust-index * $dust-index * 7) % 137, 137) * 100%; + $dust-y: math.div(($dust-index * 47 + $dust-index * $dust-index * 13) % 139, 139) * 100%; $model-mode-dust: list.append( $model-mode-dust, - radial-gradient(circle at $dust-x $dust-y, var(--_model-mode-star-soft) 0 $dust-radius, transparent 1px), + radial-gradient(circle at $dust-x $dust-y, var(--_model-mode-star-soft) 0 0.45px, transparent 1.05px), comma ); $model-mode-dust-sizes: list.append($model-mode-dust-sizes, auto, comma); @@ -184,6 +163,8 @@ $model-mode-dust-sizes: (); &__dropdown { --openbitfun-model-dropdown-offset: -4px; --openbitfun-overlay-menu-inline-size: min(280px, calc(100vw - 16px)); + // One geometry owner for manual, smart, pool and their loading state. + --_model-selector-card-height: calc(var(--openbitfun-space-16) * 2 + var(--openbitfun-space-2)); // Non-row content uses the same typography as MenuItem's shared label slot. font-family: var(--openbitfun-type-label-md-font-family); @@ -227,64 +208,87 @@ $model-mode-dust-sizes: (); transition: none; } - &[data-menu-level='settings'][data-model-mode='manual'] { + &[data-menu-level='settings'] { + // All mode summaries share a borderless surface. Detail menus retain their insets. + --openbitfun-overlay-menu-surface-padding: 0px; + --openbitfun-overlay-menu-scrollbar-gap: 0px; + + overflow: hidden; + border: 0; border-radius: var(--openbitfun-radius-lg); - // An inset edge retains the existing card dimensions. - box-shadow: - inset 0 0 0 var(--openbitfun-border-width-default) var(--openbitfun-color-border-subtle), - var(--openbitfun-shadow-menu); + background: var(--openbitfun-color-surface-raised); + box-shadow: var(--openbitfun-shadow-menu); + + @supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { + // Leave room for the nebula layers without hiding the blurred backdrop. + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 40%, transparent); + -webkit-backdrop-filter: var(--openbitfun-effect-blur-base); + backdrop-filter: var(--openbitfun-effect-blur-base); + } + + @media (prefers-reduced-transparency: reduce) { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + > [data-openbitfun-component='scroll-area'] { + // Summary cards are fixed compositions, not vertically scrollable lists. + overflow: clip; + } } &[data-model-mode='smart'][data-menu-level='settings'] { - --_model-mode-tint: var(--openbitfun-color-accent-secondary); + --_model-mode-tint: color-mix(in oklch, var(--openbitfun-color-accent-secondary) 80%, var(--openbitfun-color-link-default)); --_model-mode-sky: color-mix( in srgb, - var(--_model-mode-tint) 50%, - var(--openbitfun-color-surface-panel) + var(--openbitfun-color-link-default) calc(12% * var(--_model-mode-sky-strength)), + var(--openbitfun-color-surface-raised) ); @supports (color: oklch(from currentColor l c h)) { - // Retain the theme's purple hue while lifting muted accents for the glass. - --_model-mode-tint: oklch(from var(--openbitfun-color-accent-secondary) l max(c, 0.2) h); + // Preserve the former peak reasoning sky's luminous blue-violet mixture. + --_model-mode-tint: oklch( + from color-mix(in oklch, var(--openbitfun-color-accent-secondary) 80%, var(--openbitfun-color-link-default)) + max(l, 0.65) max(c, 0.18) h + ); } } &[data-model-mode='pool'][data-menu-level='settings'] { - --_model-mode-tint: var(--openbitfun-color-link-default); + --_model-mode-tint: color-mix(in oklch, var(--openbitfun-color-status-success-emphasis) 72%, var(--openbitfun-color-link-default)); --_model-mode-sky: color-mix( in srgb, - var(--_model-mode-tint) 40%, - var(--openbitfun-color-surface-panel) + var(--_model-mode-tint) calc(14% * var(--_model-mode-sky-strength)), + var(--openbitfun-color-surface-raised) ); } &[data-menu-level='settings']:is([data-model-mode='smart'], [data-model-mode='pool']) { - --_model-mode-grain-light: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 28%, transparent); - --_model-mode-grain-shadow: color-mix(in srgb, var(--openbitfun-color-content-on-light) 8%, transparent); - --_model-mode-star: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 82%, var(--_model-mode-sky)); - --_model-mode-star-soft: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 48%, transparent); + --_model-mode-sky-strength: 1; + --_model-mode-material-opacity: 40%; + --_model-mode-star: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 94%, var(--_model-mode-sky)); + --_model-mode-star-soft: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 72%, transparent); + + :where([data-color-scheme='dark']) & { + --_model-mode-sky-strength: 0.38; + } isolation: isolate; overflow: hidden; border-radius: var(--openbitfun-radius-lg); background-color: var(--_model-mode-sky); background-image: - #{$model-mode-grain}, #{$model-mode-dust}, - radial-gradient(ellipse at 18% 12%, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 36%, transparent), transparent 72%), - radial-gradient(ellipse at 86% 88%, color-mix(in srgb, var(--_model-mode-tint) 14%, transparent), transparent 76%), - linear-gradient(145deg, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 26%, transparent), transparent 48%, color-mix(in srgb, var(--_model-mode-tint) 10%, transparent)); - background-size: #{$model-mode-grain-sizes}, #{$model-mode-dust-sizes}, auto, auto, auto; - box-shadow: - inset 0 0 0 var(--openbitfun-border-width-default) color-mix(in srgb, var(--openbitfun-color-content-on-dark) 46%, transparent), - inset 0 var(--openbitfun-border-width-default) 0 color-mix(in srgb, var(--openbitfun-color-content-on-dark) 72%, transparent), - var(--openbitfun-shadow-menu); + radial-gradient(ellipse at 18% 12%, color-mix(in srgb, var(--openbitfun-color-surface-panel) 18%, transparent), transparent 72%), + radial-gradient(ellipse at 86% 88%, color-mix(in srgb, var(--_model-mode-tint) calc(14% * var(--_model-mode-sky-strength)), transparent), transparent 76%), + linear-gradient(145deg, color-mix(in srgb, var(--openbitfun-color-surface-panel) 14%, transparent), transparent 48%, color-mix(in srgb, var(--_model-mode-tint) calc(10% * var(--_model-mode-sky-strength)), transparent)); + background-size: #{$model-mode-dust-sizes}, auto, auto, auto; + // The shared settings surface owns the soft menu shadow. @supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { - // Every gradient above this translucent base also lets the backdrop through. - background-color: color-mix(in srgb, var(--_model-mode-sky) 72%, transparent); - -webkit-backdrop-filter: var(--openbitfun-effect-blur-medium); - backdrop-filter: var(--openbitfun-effect-blur-medium); + // Surface opacity and cloud light are independent material layers. + background-color: color-mix(in srgb, var(--_model-mode-sky) var(--_model-mode-material-opacity), transparent); } @media (prefers-reduced-transparency: reduce) { @@ -303,7 +307,7 @@ $model-mode-dust-sizes: (); z-index: 0; pointer-events: none; background-repeat: no-repeat; - opacity: 0.5; + opacity: 0.7; } &::before { @@ -320,10 +324,10 @@ $model-mode-dust-sizes: (); radial-gradient(circle at 16% 87%, var(--_model-mode-star-soft) 0 0.45px, transparent 1px), radial-gradient(circle at 61% 91%, var(--_model-mode-star-soft) 0 0.45px, transparent 1px), radial-gradient(circle at 81% 12%, var(--_model-mode-star) 0 0.55px, transparent 1.2px), - radial-gradient(ellipse at 24% 36%, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 30%, transparent), transparent 72%); + radial-gradient(ellipse at 24% 36%, color-mix(in srgb, var(--openbitfun-color-surface-panel) 16%, transparent), transparent 72%); animation: - openbitfun-model-mode-drift 4s ease-in-out -1.2s infinite alternate, - openbitfun-model-mode-twinkle 1.6s ease-in-out -0.5s infinite; + openbitfun-model-mode-drift 6s ease-in-out -0.6s infinite alternate, + openbitfun-model-mode-twinkle 2.4s ease-in-out -0.25s infinite; } &::after { @@ -340,20 +344,105 @@ $model-mode-dust-sizes: (); radial-gradient(circle at 48% 89%, var(--_model-mode-star-soft) 0 0.45px, transparent 1px), radial-gradient(circle at 93% 19%, var(--_model-mode-star) 0 0.6px, transparent 1.3px), radial-gradient(circle at 33% 52%, var(--_model-mode-star-soft) 0 0.4px, transparent 0.9px), - radial-gradient(ellipse at 78% 68%, color-mix(in srgb, var(--_model-mode-tint) 14%, transparent), transparent 72%); + radial-gradient(ellipse at 78% 68%, color-mix(in srgb, var(--_model-mode-tint) calc(14% * var(--_model-mode-sky-strength)), transparent), transparent 72%); animation: - openbitfun-model-mode-drift 6s ease-in-out -2.4s infinite alternate-reverse, - openbitfun-model-mode-twinkle 2.2s ease-in-out -1s infinite reverse; + openbitfun-model-mode-drift 8s ease-in-out -1.2s infinite alternate-reverse, + openbitfun-model-mode-twinkle 3.2s ease-in-out -0.5s infinite reverse; } } - &[data-model-mode='pool'][data-menu-level='settings'] { + &[data-model-mode='smart'][data-menu-level='settings'] { + --_model-mode-sky-strength: 0.76; + --_model-mode-cloud-min-opacity: 0.14; + --_model-mode-cloud-max-opacity: 0.9; + + :where([data-color-scheme='light']) & { + // A luminous tinted base keeps the gray backdrop from dulling the moving clouds. + --_model-mode-material-opacity: 84%; + --_model-mode-sky: color-mix(in oklch, var(--_model-mode-tint) 12%, var(--openbitfun-color-content-on-dark)); + } + + :where([data-color-scheme='dark']) & { + // Give the moving color its own luminous base instead of a charcoal wash. + --_model-mode-material-opacity: 78%; + --_model-mode-sky: color-mix(in oklch, var(--_model-mode-tint) 22%, var(--openbitfun-color-surface-raised)); + --_model-mode-sky-strength: 0.52; + --_model-mode-cloud-min-opacity: 0.2; + --_model-mode-cloud-max-opacity: 0.62; + } + + // Multicolor clouds now identify Smart; reasoning uses one blue sky family. + background-image: + #{$model-mode-dust}, + radial-gradient(ellipse 46% 70% at 50% 42%, color-mix(in srgb, var(--openbitfun-color-content-on-dark) calc(6% * var(--_model-mode-sky-strength)), transparent), transparent 80%), + radial-gradient(ellipse 58% 78% at 15% 100%, color-mix(in srgb, var(--openbitfun-color-link-default) calc(24% * var(--_model-mode-sky-strength)), transparent), transparent 76%), + linear-gradient(120deg, + color-mix(in srgb, var(--_model-mode-tint) calc(56% * var(--_model-mode-sky-strength)), transparent), + color-mix(in srgb, var(--openbitfun-color-link-default) calc(44% * var(--_model-mode-sky-strength)), transparent) 48%, + color-mix(in srgb, var(--_model-mode-tint) calc(60% * var(--_model-mode-sky-strength)), transparent)); + + &::before, + &::after { + inset: -26% -18%; + filter: var(--openbitfun-effect-blur-base); + mask-image: radial-gradient(ellipse 42% 58% at 50% 44%, + transparent, var(--openbitfun-color-content-on-dark) 86%); + opacity: calc(0.65 * var(--_model-mode-sky-strength)); + transform-origin: center; + animation: openbitfun-model-smart-cloud 9s ease-in-out -3s infinite alternate; + } + &::before { - animation-delay: -2s, -0.8s; + background-image: + radial-gradient(ellipse 44% 58% at 24% 62%, color-mix(in srgb, var(--openbitfun-color-link-default) 60%, transparent), transparent 76%), + radial-gradient(ellipse 36% 48% at 82% 22%, color-mix(in srgb, var(--openbitfun-color-link-default) 42%, transparent), transparent 78%); + } + + &::after { + background-image: + radial-gradient(ellipse 42% 58% at 76% 70%, color-mix(in srgb, var(--_model-mode-tint) 58%, transparent), transparent 76%), + radial-gradient(ellipse 34% 50% at 14% 18%, color-mix(in srgb, var(--_model-mode-tint) 40%, transparent), transparent 78%); + animation-duration: 12s; + animation-delay: -7s; + animation-direction: alternate-reverse; + } + } + + &[data-model-mode='pool'][data-menu-level='settings'] { + // A light jade surface leaves translucency between the local clouds and cell reflections. + --_model-mode-material-opacity: 64%; + --_model-mode-sky-strength: 1; + --_model-mode-star: color-mix(in srgb, var(--_model-mode-tint) 72%, var(--openbitfun-color-content-on-dark)); + --_model-mode-star-soft: color-mix(in srgb, var(--_model-mode-tint) 42%, transparent); + + :where([data-color-scheme='dark']) & { + --_model-mode-sky-strength: 0.5; + --_model-mode-star: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 88%, var(--_model-mode-tint)); + --_model-mode-star-soft: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 48%, transparent); + } + + // Broad color stays present between the cells; offset clouds keep the center quiet. + background-image: + #{$model-mode-dust}, + radial-gradient(ellipse 66% 92% at 86% 14%, color-mix(in srgb, var(--_model-mode-tint) calc(26% * var(--_model-mode-sky-strength)), transparent), transparent 78%), + radial-gradient(ellipse 62% 90% at 10% 94%, color-mix(in srgb, var(--_model-mode-tint) calc(20% * var(--_model-mode-sky-strength)), transparent), transparent 80%), + linear-gradient(120deg, + color-mix(in srgb, var(--_model-mode-tint) calc(14% * var(--_model-mode-sky-strength)), transparent), + color-mix(in srgb, var(--_model-mode-tint) calc(6% * var(--_model-mode-sky-strength)), transparent) 50%, + color-mix(in srgb, var(--_model-mode-tint) calc(16% * var(--_model-mode-sky-strength)), transparent)); + + &::before, + &::after { + // Distant stars shimmer in place; only the cell particles travel. + animation: openbitfun-model-mode-twinkle 4.8s ease-in-out -1.1s infinite; + opacity: 0.68; } &::after { - animation-delay: -3.8s, -1.2s; + animation-duration: 6.4s; + animation-delay: -3.1s; + animation-direction: reverse; + opacity: 0.56; } } } @@ -381,8 +470,7 @@ $model-mode-dust-sizes: (); } } - &__selection-back [data-openbitfun-part='metadata'], - &__mode-option [data-openbitfun-part='metadata'] { + &__selection-back [data-openbitfun-part='metadata'] { min-inline-size: 0; max-inline-size: 60%; } @@ -391,11 +479,56 @@ $model-mode-dust-sizes: (); position: relative; z-index: 2; box-sizing: border-box; - block-size: calc(var(--openbitfun-space-16) + var(--openbitfun-space-8)); - min-block-size: calc(var(--openbitfun-space-16) + var(--openbitfun-space-8)); - // The Menu owns equal surface insets; keep spare height between its rows. + block-size: auto; + min-block-size: var(--_model-selector-card-height); justify-content: space-between; padding: 0; + + &:not([data-model-mode]) { + padding-block-start: var(--openbitfun-space-1); + } + + // Keep one layout while the reasoning control animates its content height in both directions. + &:not([data-model-mode])[data-has-model='true'] { + display: grid; + grid-template-columns: minmax(0, 1fr); + padding-block-start: 0; + + .openbitfun-model-selector__model-summary { + grid-area: 1 / 1; + padding-block: 0; + } + + .openbitfun-model-selector__mode-footer { + grid-area: 1 / 1; + align-self: end; + } + } + + &[data-selecting-mode='true'] { + .openbitfun-model-selector__model-field, + .openbitfun-model-selector__mode-footer, + .openbitfun-model-selector__mode-summary, + .openbitfun-reasoning-control__value { + visibility: hidden; + } + } + + &[data-has-reasoning='true'] { + .openbitfun-model-selector__mode-footer, + .openbitfun-model-selector__model-field { + position: relative; + z-index: 1; + pointer-events: none; + } + + .openbitfun-model-selector__model-control, + .openbitfun-model-selector__mode-control, + .openbitfun-model-selector__provider-control { + pointer-events: auto; + } + + } } &__loading { @@ -403,7 +536,7 @@ $model-mode-dust-sizes: (); z-index: 1; display: grid; place-items: center; - min-block-size: calc(var(--openbitfun-space-16) + var(--openbitfun-space-8)); + min-block-size: var(--_model-selector-card-height); [data-openbitfun-component='loading-state'] { color: inherit; @@ -416,35 +549,87 @@ $model-mode-dust-sizes: (); } } - &__model-control { - // The model and provider are separate text actions. + &__mode-choices { + position: absolute; + inset: 0; + z-index: 2; + display: grid; + align-items: center; + padding-inline: var(--openbitfun-space-3); + transition: opacity 160ms var(--openbitfun-motion-easing-standard), transform 160ms var(--openbitfun-motion-easing-standard); + + @starting-style { + opacity: 0; + transform: scaleX(0.86); + } + + [data-openbitfun-component='segmented-control'] { + gap: var(--openbitfun-space-1); + padding: 0; + background: transparent; + } + + [data-openbitfun-part='segment'] { + block-size: var(--openbitfun-space-8); + padding-inline: var(--openbitfun-space-1); + color: var(--openbitfun-color-content-secondary); + font-weight: var(--openbitfun-type-label-md-font-weight); + + &:is(:hover, :focus-visible):not(:disabled), + &[aria-pressed='true'] { + color: var(--openbitfun-color-content-primary); + background: var(--openbitfun-color-action-neutral-surface-hover); + } + + &:disabled { + color: var(--openbitfun-color-action-neutral-content-disabled); + } + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + &__model-control, + &__mode-summary .openbitfun-model-selector__mode-control { flex: 0 1 auto; inline-size: fit-content; min-inline-size: 0; max-inline-size: 100%; - min-block-size: var(--openbitfun-overlay-menu-item-height); - margin-inline-start: calc(-1 * var(--openbitfun-space-2)); + min-block-size: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); padding-inline: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-primary); + + [data-openbitfun-part='label'] { + font-family: var(--openbitfun-type-heading-section-font-family); + font-size: var(--openbitfun-type-heading-section-font-size); + font-weight: var(--openbitfun-type-heading-section-font-weight); + line-height: var(--openbitfun-type-heading-section-line-height); + letter-spacing: var(--openbitfun-type-heading-section-letter-spacing); + } + } + + &__model-summary { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + // Identity and its secondary control form one compact information group. + gap: calc(var(--openbitfun-space-1) / 2); + min-inline-size: 0; + min-block-size: 0; + padding: var(--openbitfun-space-2) var(--openbitfun-space-3) 0; } &__model-field { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: center; + justify-content: center; flex: 0 0 auto; - column-gap: var(--openbitfun-space-3); min-inline-size: 0; - text-align: start; - - .openbitfun-model-selector__model-control { - justify-self: start; - margin-inline-start: 0; - padding-inline: var(--openbitfun-space-1); - - [data-openbitfun-part='trigger'] { - gap: var(--openbitfun-space-1); - } - } + min-block-size: 0; + text-align: center; } &__provider-control { @@ -452,10 +637,25 @@ $model-mode-dust-sizes: (); inline-size: fit-content; min-inline-size: 0; max-inline-size: 100%; - min-block-size: var(--openbitfun-overlay-menu-item-height); - justify-self: end; + min-block-size: var(--openbitfun-space-5); padding-inline: var(--openbitfun-space-1); - text-align: end; + color: var(--openbitfun-color-content-secondary); + + [data-openbitfun-part='trigger'], + [data-openbitfun-part='label'] { + text-align: start; + } + } + + &__model-control { + // The centered name uses its text height instead of reserving a full menu row. + min-block-size: 0; + + [data-openbitfun-part='trigger'], + [data-openbitfun-part='label'] { + justify-content: center; + text-align: center; + } } &__model-provider, @@ -465,37 +665,69 @@ $model-mode-dust-sizes: (); &__reasoning-control { display: flex; - flex: 1 1 auto; + flex: 0 0 auto; flex-direction: column; justify-content: flex-start; + inline-size: 100%; min-inline-size: 0; min-block-size: 0; - padding-inline: var(--openbitfun-space-1); - } - &__reasoning-choice { - inline-size: max-content; - max-inline-size: 100%; - min-inline-size: 0; - min-block-size: var(--openbitfun-overlay-menu-item-height); + .openbitfun-reasoning-control { + // Anchor the sky to the card while the inline choices follow the model. + position: static; + isolation: auto; + overflow: visible; + } - [data-openbitfun-part='trigger'] { - gap: var(--openbitfun-space-1); - text-align: center; + .openbitfun-reasoning-control__value { + color: var(--openbitfun-color-content-secondary); } - } - &__reasoning-choice [data-openbitfun-part='label'] { - // Size the shared label slot itself so the four-character automatic label - // cannot collapse inside a shrink-to-fit menu trigger. - flex: 0 1 auto; - inline-size: max-content; - min-inline-size: 4em; - text-align: center; + .openbitfun-reasoning-control { + // Let the retained content own height throughout expansion and collapse. + &, + .openbitfun-reasoning-control__value, + .openbitfun-reasoning-control__trigger { + min-block-size: 0; + } + } + + .openbitfun-reasoning-control__backdrop { + z-index: 0; + } } - &__mode-header { - padding-inline: var(--openbitfun-space-1); + &__mode-footer { + flex-direction: row; + align-items: center; + justify-content: space-between; + flex: 0 0 auto; + padding-block-end: var(--openbitfun-space-1); + padding-inline: var(--openbitfun-space-2); + + .openbitfun-model-selector__mode-control, + .openbitfun-model-selector__provider-control { + min-block-size: var(--openbitfun-space-5); + margin-inline-start: 0; + color: var(--openbitfun-color-content-secondary); + + [data-openbitfun-part='label'] { + font-family: var(--openbitfun-type-label-sm-font-family); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); + line-height: var(--openbitfun-type-label-sm-line-height); + letter-spacing: var(--openbitfun-type-label-sm-letter-spacing); + } + } + + .openbitfun-model-selector__mode-control { + flex-shrink: 0; + max-inline-size: 60%; + } + + .openbitfun-model-selector__provider-control { + margin-inline-start: auto; + } } &__mode-summary { @@ -569,6 +801,18 @@ $model-mode-dust-sizes: (); } +@keyframes openbitfun-model-smart-cloud { + from { + transform: translate3d(-10%, 6%, 0) rotate(-5deg) scale(1.04); + opacity: var(--_model-mode-cloud-min-opacity); + } + + to { + transform: translate3d(10%, -6%, 0) rotate(5deg) scale(1.12); + opacity: var(--_model-mode-cloud-max-opacity); + } +} + @keyframes openbitfun-model-mode-drift { from { transform: translate3d(calc(-1 * var(--openbitfun-space-2)), var(--openbitfun-space-1), 0); @@ -582,19 +826,20 @@ $model-mode-dust-sizes: (); @keyframes openbitfun-model-mode-twinkle { 0%, 100% { - opacity: 0.3; + opacity: 0.55; } 42% { - opacity: 0.64; + opacity: 0.9; } 72% { - opacity: 0.42; + opacity: 0.68; } } @media (forced-colors: active) { + .openbitfun-model-selector__dropdown[data-menu-level='settings'], .openbitfun-model-selector__dropdown[data-menu-level='settings']:is([data-model-mode='smart'], [data-model-mode='pool']) { background-image: none; background-color: Canvas; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index acce15d220..e6e15a706b 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -8,7 +8,7 @@ * - Supports 'primary' | 'fast' | specific model IDs */ -import { subscribeOverlayInteraction, createOverlayPortal, LoadingState, Menu, MenuItem, MenuList, MenuSection, MenuSeparator, OverflowText, Spinner } from '@openbitfun/ui'; +import { subscribeOverlayInteraction, createOverlayPortal, LoadingState, Menu, MenuItem, MenuList, MenuSection, MenuSeparator, OverflowText, SegmentedControl, Spinner } from '@openbitfun/ui'; import React, { useState, useEffect, useId, useRef, useCallback, useLayoutEffect, useMemo, useSyncExternalStore } from 'react'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; @@ -50,8 +50,8 @@ import { createLogger } from '@/shared/utils/logger'; import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition'; import { AcpModeSelector } from './AcpModeSelector'; import { ModelModeAnimation } from './ModelModeAnimation'; -import { ReasoningIntensitySlider } from './ReasoningIntensitySlider'; -import { presetDisplayLabel, reasoningPresetChoices, resolveReasoningPresetChoice } from './reasoningPresetPresentation'; +import { ReasoningIntensityControl } from './ReasoningIntensityControl'; +import { isAutomaticReasoningPreset, reasoningPresetChoices, reasoningSelectionLabel, resolveReasoningPresetChoice } from './reasoningPresetPresentation'; import { getRecentReasoningPreset, setRecentReasoningPreset, @@ -161,7 +161,7 @@ interface ProviderGroupInfo { models: ModelInfo[]; } -type ModelSelectorPanel = 'modes' | 'models' | 'reasoning'; +type ModelSelectorPanel = 'modes' | 'models'; type UpcomingModelMode = 'smart' | 'pool'; type ModelSelectorLevelDirection = 'none' | 'forward' | 'back'; @@ -330,7 +330,6 @@ export const ModelSelector: React.FC = ({ persistSharedModeDefault = true, disabled = false, disabledReason, - reasoningTriggerPresentation = 'meter', }) => { const { t } = useI18n('flow-chat'); const deviceSurfaceScope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); @@ -350,8 +349,10 @@ export const ModelSelector: React.FC = ({ const [keyboardNavigationOpen, setKeyboardNavigationOpen] = useState(false); /** Provider whose models the menu is currently showing; null is the provider level. */ const [activeProviderKey, setActiveProviderKey] = useState(null); - /** The detail view replaces the summary inside the same menu surface. */ + /** Models use a detail view; modes expand horizontally inside the summary card. */ const [activePanel, setActivePanel] = useState(null); + const [modeSelectionPending, setModeSelectionPending] = useState(false); + const [modeFocusRevision, setModeFocusRevision] = useState(0); const [providerFromSummary, setProviderFromSummary] = useState(false); // Upcoming modes execute through primary until the runtime supports routing. const [upcomingMode, setUpcomingMode] = useState<{ @@ -376,8 +377,10 @@ export const ModelSelector: React.FC = ({ const portalDropdownRef = useRef(null); const modelFieldRef = useRef(null); const modeFieldRef = useRef(null); + const modeChoicesRef = useRef(null); + const modeSelectionPendingRef = useRef(false); + const modeRetryFocusRef = useRef(undefined); const providerFieldRef = useRef(null); - const reasoningFieldRef = useRef(null); const focusPanelOnOpenRef = useRef(false); const focusSummaryOnReturnRef = useRef(null); const triggerRef = useRef(null); @@ -983,6 +986,15 @@ export const ModelSelector: React.FC = ({ ), [handleOpenModelSettings, t]); const focusPreferredPanelItem = useCallback(() => { + if (modeChoicesRef.current) { + const buttons = Array.from(modeChoicesRef.current.querySelectorAll('button:not(:disabled)')); + const preferred = buttons.find(button => button.dataset.openbitfunValue === modeRetryFocusRef.current) + ?? buttons.find(button => button.getAttribute('aria-pressed') === 'true') + ?? buttons[0]; + preferred?.focus({ preventScroll: true }); + modeRetryFocusRef.current = undefined; + return; + } const menu = portalDropdownRef.current; const preferredItem = menu?.querySelector( 'button[role="menuitemradio"][aria-checked="true"], button[data-provider-key][data-selected="true"]', @@ -1003,6 +1015,7 @@ export const ModelSelector: React.FC = ({ }, []); const openPanel = useCallback((kind: ModelSelectorPanel, moveFocus: boolean) => { + modeRetryFocusRef.current = undefined; focusPanelOnOpenRef.current = moveFocus; focusSummaryOnReturnRef.current = null; setActiveProviderKey(null); @@ -1078,14 +1091,14 @@ export const ModelSelector: React.FC = ({ const reasoningPresetCandidate = sessionId ? sessionReasoningPreset : preSessionReasoningPreset; - // Resolve target-owned presets once so every transport uses the same menu. + // Resolve target-owned presets once so every transport uses the same control. const reasoningProjection = externalSelection ? externalSelection.onSelectReasoningPreset ? externalReasoningProjection : null : isAcpSession ? acpReasoning?.projection : currentReasoningProjection; const presentedReasoningPreset = externalSelection ? externalSelection.selectedReasoningPreset === 'auto' - ? undefined - : externalSelection.selectedReasoningPreset + && !externalReasoningProjection?.presets?.some(preset => preset.id === 'auto') + ? undefined : externalSelection.selectedReasoningPreset : isAcpSession ? acpReasoning?.selectedPreset : reasoningPresetCandidate; const selectedReasoningPreset = currentReasoningProjection?.status === 'known' && currentReasoningProjection.presets?.some(preset => preset.id === reasoningPresetCandidate) @@ -1107,13 +1120,7 @@ export const ModelSelector: React.FC = ({ catalogReasoningPresets, ); const effectiveReasoningDescriptor = selectedReasoningDescriptor ?? defaultReasoningDescriptor; - const currentReasoningLabel = effectiveReasoningDescriptor - ? presetDisplayLabel(effectiveReasoningDescriptor, t) - : t('reasoningSelector.auto'); - const reasoningPresetLabels = orderedReasoningPresets.map(preset => ( - presetDisplayLabel(preset, t) - )); - const manualReasoningPresets = orderedReasoningPresets.filter(preset => preset.id !== 'auto'); + const currentReasoningLabel = reasoningSelectionLabel(selectedReasoningDescriptor, t); const isNativeModelSelection = !externalSelection && !isAcpSession; const modelInformationLoading = availability.status === 'loading' || (isNativeModelSelection && configLoadState === 'ready' && catalogLoadState === 'loading') @@ -1127,7 +1134,7 @@ export const ModelSelector: React.FC = ({ model.id === defaultModels.primary && isSelectableTextChatModel(model) )); const hasSettingsPanel = hasReasoningSettings || isNativeModelSelection; - const automaticReasoning = selectedReasoningDescriptor?.id === 'auto' + const automaticReasoning = (selectedReasoningDescriptor && isAutomaticReasoningPreset(selectedReasoningDescriptor)) || (!selectedReasoningDescriptor && (!isAcpSession || Boolean(externalSelection))); useEffect(() => { if (!dropdownOpen) return; @@ -1135,16 +1142,10 @@ export const ModelSelector: React.FC = ({ openPanel('models', true); return; } - if (!hasReasoningSettings && activePanel === 'reasoning') { - if (hasSettingsPanel) { - closePanel(true); - } else { - openPanel('models', true); - } - } else if (!hasSettingsPanel && !activePanel) { + if (!hasSettingsPanel && !activePanel) { openPanel('models', keyboardNavigationOpen); } - }, [closePanel, dropdownOpen, hasReasoningSettings, hasSettingsPanel, isNativeModelSelection, + }, [dropdownOpen, hasSettingsPanel, isNativeModelSelection, keyboardNavigationOpen, activePanel, openPanel]); // Calculate the portalled dropdown position relative to the trigger button. @@ -1539,13 +1540,14 @@ export const ModelSelector: React.FC = ({ activeSession?.workspaceId, ]); - const handleSelectReasoningPresetFromMenu = useCallback(async (presetId: string | null, fromSlider = false) => { - if (disabled || loading || reasoningLoading || externalSelection?.disabled) return; + const handleSelectReasoningPresetFromControl = useCallback(async (presetId: string | null) => { + if (disabled || loading || reasoningLoading || externalSelection?.disabled) return false; let selected = false; if (externalSelection) { + if (!externalSelection.onSelectReasoningPreset) return false; setReasoningLoading(true); try { - await externalSelection.onSelectReasoningPreset?.(presetId); + await externalSelection.onSelectReasoningPreset(presetId); selected = true; } catch (error) { log.error('Failed to update target reasoning preset', error); @@ -1558,8 +1560,8 @@ export const ModelSelector: React.FC = ({ } else { selected = await handleSelectReasoningPreset(presetId); } - if (selected && !fromSlider) closePanel(true); - }, [closePanel, disabled, externalSelection, handleSelectAcpReasoning, handleSelectReasoningPreset, + return selected; + }, [disabled, externalSelection, handleSelectAcpReasoning, handleSelectReasoningPreset, isAcpSession, loading, reasoningLoading, t]); const handleSelectUpcomingMode = useCallback(async (mode: UpcomingModelMode) => { @@ -1588,6 +1590,49 @@ export const ModelSelector: React.FC = ({ }, [allModels, closePanel, currentModelMode, currentNativeModelId, defaultModels, disabled, handleSelectModelFromMenu, loading, modelSelectionScopeKey, openPanel, reasoningLoading, t]); + const handleSelectMode = async (mode: string) => { + if (mode !== 'manual' && mode !== 'smart' && mode !== 'pool') return; + if (disabled || loading || reasoningLoading || modeSelectionPendingRef.current || !isNativeModelSelection) return; + if (mode !== 'manual' && !primaryModelAvailable) return; + if (mode === currentModelMode) { + closePanel(true); + return; + } + modeSelectionPendingRef.current = true; + modeRetryFocusRef.current = mode; + setModeSelectionPending(true); + try { + if (mode === 'manual') await handleSelectManualMode(); + else await handleSelectUpcomingMode(mode); + } finally { + modeSelectionPendingRef.current = false; + setModeSelectionPending(false); + // Keep failed choices focused even when a fast save batches both loading updates. + focusPanelOnOpenRef.current = true; + setModeFocusRevision(revision => revision + 1); + } + }; + + const handleModeChoicesKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closePanel(true); + return; + } + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + const buttons = Array.from(event.currentTarget.querySelectorAll('button:not(:disabled)')); + if (!buttons.length) return; + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'; + const forward = event.key === 'ArrowRight' ? !rtl : rtl; + const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 + : (current + (forward ? 1 : -1) + buttons.length) % buttons.length; + buttons[next]?.focus({ preventScroll: true }); + }; + const handleSelectAcpMode = useCallback(async (value: string) => { if (loading || !acpMode || !acpClientId || !sessionId) return; // A locked picker is disabled in the UI; refusing here too keeps a stray @@ -1682,7 +1727,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' || focusedTarget === 'reasoning') { + if (focusedTarget === 'modes' || focusedTarget === 'models') { event.preventDefault(); openPanel(focusedTarget, true); } @@ -1757,12 +1802,12 @@ export const ModelSelector: React.FC = ({ focusSummaryOnReturnRef.current = null; if (returnTarget) { const field = returnTarget === 'provider' ? providerFieldRef.current - : returnTarget === 'modes' ? modeFieldRef.current - : returnTarget === 'models' ? modelFieldRef.current : reasoningFieldRef.current; - (field ?? modeFieldRef.current ?? modelFieldRef.current ?? reasoningFieldRef.current)?.focus({ preventScroll: true }); + : 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' && previousProviderKey !== activeProviderKey; if (!focusPanelOnOpenRef.current && !providerLevelChanged) return; @@ -1799,6 +1844,10 @@ export const ModelSelector: React.FC = ({ dropdownOpen, focusPreferredPanelItem, activePanel, + loading, + reasoningLoading, + modeSelectionPending, + modeFocusRevision, ]); useEffect(() => { @@ -1892,27 +1941,14 @@ export const ModelSelector: React.FC = ({ const tooltipContent = externalSelection || isAcpSession || currentModelMode ? targetTooltip : ; - const renderReasoningValue = (label: string) => ( - openPanelFromClick('reasoning')} - onKeyDown={(event) => handlePanelTriggerKeyDown('reasoning', event)} - disabled={disabled || reasoningLoading || loading || externalSelection?.disabled} - > - {t('reasoningSelector.thinking')}{' · '}{label} - - ); - const modeControl = isNativeModelSelection ? ( = ({ data-openbitfun-part="reasoningSummary" > {t('reasoningSelector.title')}: {' '} - {reasoningTriggerPresentation === 'label' || !automaticReasoning ? ( - currentReasoningLabel - ) : ( - t('reasoningSelector.automatic') - )} + {currentReasoningLabel} )} @@ -2023,17 +2055,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 ?? 'settings'} + data-menu-level={activeProviderGroup ? 'provider' : activePanel === 'models' ? 'models' : 'settings'} data-model-mode={currentModelMode ?? 'manual'} aria-busy={modelInformationLoading || undefined} aria-hidden={!dropdownOpen} {...(!dropdownOpen ? { inert: '' } : {})} - aria-label={activeProviderGroup?.providerName ?? (activePanel === 'reasoning' - ? t('reasoningSelector.title') : activePanel === 'modes' + aria-label={activeProviderGroup?.providerName ?? (activePanel === 'modes' ? t('modelSelector.modes.selection') : t('modelSelector.modelSettings'))} - onKeyDown={activePanel ? handlePanelKeyDown : handleDropdownKeyDown} + onKeyDown={activePanel === 'models' ? handlePanelKeyDown : handleDropdownKeyDown} > - {!showModelLoadingState && !activePanel && !activeProviderGroup + {!showModelLoadingState && activePanel !== 'models' && !activeProviderGroup && (currentModelMode === 'smart' || currentModelMode === 'pool') && ( )} @@ -2047,7 +2078,7 @@ export const ModelSelector: React.FC = ({ > {t('modelSelector.status.loading')} - ) : activePanel ? ( + ) : activePanel === 'models' ? (
= ({ ) : undefined} onClick={() => closePanel(true)} > - {activePanel === 'reasoning' ? t('reasoningSelector.title') - : activePanel === 'modes' ? t('modelSelector.modes.selection') : t('modelSelector.model')} + {t('modelSelector.model')} )} - {activePanel === 'modes' ? ( - - {([ - { id: 'manual', label: t('modelSelector.modes.manual') }, - { id: 'smart', label: t('modelSelector.modes.smart') }, - { id: 'pool', label: t('modelSelector.modes.pool') }, - ] as const).map(mode => ( - - {manualModelLabel} : t('modelSelector.modes.comingSoon')} - disabled={disabled || loading || reasoningLoading || !isNativeModelSelection - || (mode.id !== 'manual' && !primaryModelAvailable)} - onClick={() => mode.id === 'manual' - ? handleSelectManualMode() : handleSelectUpcomingMode(mode.id)} - > - {mode.label} - - - ))} - - ) : activePanel === 'reasoning' ? ( - <> - {!isAcpSelection && ( - handleSelectReasoningPresetFromMenu(null)} - > - {t('reasoningSelector.auto')} - - )} - - {orderedReasoningPresets.map((preset, index) => { - const isSelected = selectedReasoningDescriptor?.id === preset.id; - const label = reasoningPresetLabels[index] - ?? presetDisplayLabel(preset, t); - - return ( - handleSelectReasoningPresetFromMenu(preset.id)} - > - {label} - - ); - })} - - ) : (externalSelection || isAcpSession) && !activeProviderGroup ? ( + {(externalSelection || isAcpSession) && !activeProviderGroup ? ( <> {showModelChoices && ( = ({ data-model-mode={currentModelMode ?? undefined} data-has-model={showModelChoices} data-has-reasoning={hasReasoningSettings} + data-selecting-mode={activePanel === 'modes' ? 'true' : undefined} > - {hasReasoningSettings && ( + {activePanel === 'modes' && (
- handleSelectReasoningPresetFromMenu(presetId, true)} - renderValue={label => renderReasoningValue(automaticReasoning ? t('reasoningSelector.auto') : label)} + ({ + value: mode.value, + disabled: mode.value !== 'manual' && !primaryModelAvailable, + label: ( + + {mode.label} + + ), + }))} + onValueChange={value => { void handleSelectMode(value); }} />
)} - {!hasReasoningSettings && modeControl && ( - - {modeControl} - + {((showModelChoices && !currentModelMode) || hasReasoningSettings) && ( +
+ {showModelChoices && !currentModelMode && ( + + openPanelFromClick('models')} + onKeyDown={(event) => handlePanelTriggerKeyDown('models', event)} + disabled={disabled || loading || externalSelection?.disabled} + > + + {settingsModelLabel} + + + + )} + {hasReasoningSettings && ( +
+ +
+ )} +
)} - {showModelChoices && !currentModelMode && ( + {(modeControl || (showModelChoices && !currentModelMode && displayedModel?.providerName)) && ( - openPanelFromClick('models')} - onKeyDown={(event) => handlePanelTriggerKeyDown('models', event)} - disabled={disabled || loading || externalSelection?.disabled} - > - - {settingsModelLabel} - - - {displayedModel?.providerName && ( + {modeControl} + {showModelChoices && !currentModelMode && displayedModel?.providerName && ( { expect(document.body.querySelectorAll('[data-testid="chat-model-selector-option"]')).toHaveLength(1); await click('chat-model-selector-summary-back'); await click('chat-model-selector-settings-reasoning'); - expect(document.body.querySelector('[data-preset-id="auto"]')).toBeNull(); + expect(document.body.querySelector('[data-openbitfun-value="preset:auto"]')).toBeNull(); vi.mocked(ACPClientAPI.setSessionConfigOption).mockResolvedValue({ ...modelOptions, configOptions: [MODE_OPTION, { ...reasoningOption, currentValue: 'high' }], } as never); await act(async () => { - document.body.querySelector('[data-preset-id="high"]')?.click(); + document.body.querySelector('[data-openbitfun-value="preset:high"]')?.click(); }); expect(ACPClientAPI.setSessionConfigOption).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'acp-session', clientId: 'dsh', configId: 'reasoning-effort', @@ -445,7 +445,8 @@ describe('ModelSelector ACP mode picker', () => { await click('chat-model-selector-btn'); expect(document.body.querySelector('[data-testid="chat-model-selector-settings-model"]')).toBeNull(); await click('chat-model-selector-settings-reasoning'); - expect(document.body.querySelectorAll('[data-testid="chat-model-selector-reasoning-option"]')).toHaveLength(2); + expect(document.body.querySelectorAll('[data-testid="chat-model-selector-reasoning-options"] button:not(:disabled)')).toHaveLength(2); + expect(document.body.querySelector('[data-openbitfun-value="unavailable:xhigh"]')).toBeNull(); expect(container.querySelector('[data-testid="chat-acp-mode-selector-btn"]')).not.toBeNull(); }); @@ -458,13 +459,13 @@ describe('ModelSelector ACP mode picker', () => { row.focus(); row.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); }); - expect(document.activeElement?.getAttribute('data-preset-id')).toBe('medium'); + expect(document.activeElement?.getAttribute('data-openbitfun-value')).toBe('preset:medium'); if (frameTarget === 'choices') { await act(async () => { vi.advanceTimersToNextFrame(); }); - expect(document.activeElement?.getAttribute('data-preset-id')).toBe('medium'); + expect(document.activeElement?.getAttribute('data-openbitfun-value')).toBe('preset:medium'); } await act(async () => { - document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); }); const returnedRow = document.body.querySelector('[data-testid="chat-model-selector-settings-reasoning"]')!; expect(document.activeElement).toBe(returnedRow); @@ -484,7 +485,7 @@ describe('ModelSelector ACP mode picker', () => { await click('chat-model-selector-btn'); await click('chat-model-selector-settings-reasoning'); await act(async () => { - document.body.querySelector('[data-preset-id="high"]')?.click(); + document.body.querySelector('[data-openbitfun-value="preset:high"]')?.click(); }); expect(container.querySelector('[data-testid="chat-model-selector-trigger-reasoning"]')?.textContent) .toContain('reasoningSelector.levels.medium'); @@ -512,13 +513,13 @@ describe('ModelSelector ACP mode picker', () => { await renderWithOptions([withAuto], modelOptions); await click('chat-model-selector-btn'); await click('chat-model-selector-settings-reasoning'); - expect(document.body.querySelectorAll('[data-preset-id="auto"]')).toHaveLength(1); + expect(document.body.querySelectorAll('[data-openbitfun-value="auto"]')).toHaveLength(1); const autoOptions = { ...modelOptions, configOptions: [{ ...withAuto, currentValue: 'auto' }], }; vi.mocked(ACPClientAPI.setSessionConfigOption).mockResolvedValue(autoOptions as never); vi.mocked(ACPClientAPI.getSessionOptions).mockResolvedValue(autoOptions as never); - await act(async () => { document.body.querySelector('[data-preset-id="auto"]')?.click(); }); + await act(async () => { document.body.querySelector('[data-openbitfun-value="auto"]')?.click(); }); expect(ACPClientAPI.setSessionConfigOption).toHaveBeenCalledWith(expect.objectContaining({ configId: 'reasoning-effort', value: { type: 'select', value: 'auto' }, })); 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 848130a347..056259ddec 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -338,7 +338,7 @@ describe('ModelSelector external transport reuse', () => { )?.click(); }); await act(async () => { - document.body.querySelector('[data-preset-id="high"]')?.click(); + document.body.querySelector('[data-openbitfun-value="preset:high"]')?.click(); await Promise.resolve(); }); @@ -672,7 +672,7 @@ describe('ModelSelector external transport reuse', () => { expect(document.body.querySelector('[data-testid="chat-model-selector-settings-model"]')).not.toBeNull(); await act(async () => { reasoningRow?.click(); }); await act(async () => { - document.body.querySelector('[data-preset-id="high"]')?.click(); + document.body.querySelector('[data-openbitfun-value="preset:high"]')?.click(); await Promise.resolve(); }); @@ -757,24 +757,24 @@ describe('ModelSelector external transport reuse', () => { expect(configManager.setConfig).not.toHaveBeenCalled(); }); - it('saves a slider choice through the target callback while keeping the card open', async () => { + it('saves an inline choice through the target callback while keeping the card open', async () => { const selection = targetSelection(); await renderTarget(selection); await clickControl('chat-model-selector-btn'); - const slider = document.body.querySelector('input[type="range"]')!; - expect(slider.value).toBe('3'); + await clickControl('chat-model-selector-settings-reasoning'); + expect(document.activeElement?.getAttribute('data-openbitfun-value')).toBe('preset:high'); await act(async () => { - slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true })); }); expect(selection.onSelectReasoningPreset).not.toHaveBeenCalled(); await act(async () => { - slider.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true })); + (document.activeElement as HTMLButtonElement).click(); }); expect(selection.onSelectReasoningPreset).toHaveBeenCalledExactlyOnceWith('medium'); expect(agentAPI.updateSessionModel).not.toHaveBeenCalled(); expect(configManager.setConfig).not.toHaveBeenCalled(); await renderTarget({ ...selection, selectedReasoningPreset: 'medium' }); - expect(slider.value).toBe('2'); + expect(document.body.querySelector('[data-testid="chat-model-selector-intensity-control"]')?.getAttribute('data-intensity')).toBe('1'); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); }); @@ -783,17 +783,17 @@ describe('ModelSelector external transport reuse', () => { await renderTarget(selection); await clickControl('chat-model-selector-btn'); await clickControl('chat-model-selector-settings-reasoning'); - expect(document.body.querySelector('[data-preset-id="high"]')?.getAttribute('aria-checked')).toBe('true'); - await act(async () => { document.body.querySelector('[data-preset-id="auto"]')?.click(); }); + expect(document.body.querySelector('[data-openbitfun-value="preset:high"]')?.getAttribute('aria-pressed')).toBe('true'); + await act(async () => { document.body.querySelector('[data-openbitfun-value="auto"]')?.click(); }); expect(selection.onSelectReasoningPreset).toHaveBeenCalledWith(null); expect(agentAPI.updateSessionModel).not.toHaveBeenCalled(); expect(configManager.setConfig).not.toHaveBeenCalled(); await renderTarget({ ...selection, selectedReasoningPreset: 'auto' }); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); await clickControl('chat-model-selector-settings-reasoning'); - expect(document.body.querySelector('[data-preset-id="auto"]')?.getAttribute('aria-checked')).toBe('true'); + expect(document.body.querySelector('[data-openbitfun-value="auto"]')?.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('[data-testid="chat-model-selector-trigger-reasoning"]')?.textContent) - .toContain('reasoningSelector.levels.medium'); + .toContain('reasoningSelector.auto'); }); it('switches dispatch models through the shared settings without retaining unsupported reasoning', async () => { @@ -822,7 +822,7 @@ describe('ModelSelector external transport reuse', () => { await clickControl('chat-model-selector-btn'); await clickControl('chat-model-selector-settings-reasoning'); await renderTarget({ ...selection, reasoningCatalog: undefined }); - expect(document.body.querySelector('[data-testid="chat-model-selector-reasoning-option"]')).toBeNull(); + expect(document.body.querySelector('[data-testid="chat-model-selector-reasoning-options"] button')).toBeNull(); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); expect(document.body.querySelector('[data-testid="chat-model-selector-options"]')?.getAttribute('data-panel-kind')).toBe('models'); }); @@ -833,25 +833,23 @@ describe('ModelSelector external transport reuse', () => { await clickControl('chat-model-selector-btn'); await clickControl('chat-model-selector-settings-reasoning'); await renderTarget({ ...selection, disabled: true }); - await act(async () => { document.body.querySelector('[data-preset-id="medium"]')?.click(); }); + await act(async () => { document.body.querySelector('[data-openbitfun-value="preset:medium"]')?.click(); }); expect(selection.onSelectReasoningPreset).not.toHaveBeenCalled(); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.disabled).toBe(true); }); - it('restores the dispatch slider and unlocks the card after a failed update', async () => { + it('retains dispatch choices and unlocks the card after a failed update', async () => { const selection = targetSelection({ onSelectReasoningPreset: vi.fn().mockRejectedValueOnce(new Error('Offline')) }); await renderTarget(selection); await clickControl('chat-model-selector-btn'); - const slider = document.body.querySelector('input[type="range"]')!; - await act(async () => { - slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); - }); + await clickControl('chat-model-selector-settings-reasoning'); await act(async () => { - slider.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true })); + document.body.querySelector('[data-openbitfun-value="preset:medium"]')!.click(); }); expect(selection.onSelectReasoningPreset).toHaveBeenCalledWith('medium'); - expect(slider.value).toBe('3'); - expect(slider.disabled).toBe(false); + expect(document.body.querySelector('[data-openbitfun-value="preset:high"]')?.getAttribute('aria-pressed')).toBe('true'); + expect(document.body.querySelector('[data-openbitfun-value="preset:medium"]')?.disabled).toBe(false); + expect(document.activeElement?.getAttribute('data-openbitfun-value')).toBe('preset:medium'); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); expect(container.querySelector('[data-testid="chat-model-selector-trigger-reasoning"]')?.textContent) .toContain('reasoningSelector.levels.high'); 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 e3323cd3c8..68f72bdad1 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx @@ -159,9 +159,11 @@ describe('ModelSelector provider levels', () => { '[data-testid="chat-model-selector-settings-mode"]', )!; - const modeOption = (mode: 'manual' | 'smart' | 'pool') => document.body.querySelector( - `[data-testid="chat-model-selector-mode-${mode}"]`, - )!; + const modeChoices = () => document.body.querySelector('[data-testid="chat-model-selector-mode-choices"]'); + const modeOption = (mode: 'manual' | 'smart' | 'pool') => modeChoices()?.querySelector( + `[data-openbitfun-value="${mode}"]`, + ) ?? null; + const modeTooltip = (mode: 'manual' | 'smart' | 'pool') => modeOption(mode)?.querySelector('[data-tooltip]')?.getAttribute('data-tooltip'); const openModeMenu = async () => { await openSettingsMenu(); @@ -303,7 +305,7 @@ describe('ModelSelector provider levels', () => { expect(modelField?.textContent).toContain('acme-fast-native'); expect(trigger.hasAttribute('aria-busy')).toBe(false); expect(menu.hasAttribute('aria-busy')).toBe(false); - expect(document.activeElement).toBe(modeTrigger()); + expect(document.activeElement).toBe(modelField); }); it.each(['configuration', 'catalog'])('leaves loading when the %s request fails', async source => { @@ -391,9 +393,10 @@ describe('ModelSelector provider levels', () => { '[data-testid="chat-model-selector-settings-reasoning"]', )?.textContent).toContain('reasoningSelector.levels.high'); expect(settings?.querySelectorAll('button[role="menuitem"]')).toHaveLength(4); - expect(settings?.querySelector('.openbitfun-reasoning-slider__header')?.contains(modeTrigger())).toBe(true); + expect(settings?.querySelector('.openbitfun-model-selector__mode-footer')?.contains(modeTrigger())).toBe(true); + expect(settings?.querySelector('.openbitfun-reasoning-control')?.contains(modeTrigger())).toBe(false); expect(modeTrigger().textContent).toBe('modelSelector.modes.manual'); - expect(settings?.querySelector('.openbitfun-reasoning-slider__title')).toBeNull(); + expect(settings?.querySelector('.openbitfun-reasoning-control__title')).toBeNull(); expect(settings?.textContent).not.toContain('modelSelector.fastMode'); expect(settings?.querySelector( '[data-testid="chat-model-selector-settings-reset"]', @@ -452,7 +455,7 @@ describe('ModelSelector provider levels', () => { expect(configManager.setConfig).not.toHaveBeenCalled(); }); - it('presents an existing enabled override as the single Low choice without rewriting it', async () => { + it('presents an existing enabled override as Auto without rewriting it', async () => { flowChatStoreMocks.sessions.set('session-a', { config: { agentType: 'Standard', modelName: 'umbra-main', reasoningPreset: 'on' }, }); @@ -485,20 +488,20 @@ describe('ModelSelector provider levels', () => { expect(document.body.querySelector( '[data-testid="chat-model-selector-settings"]', - )).toBeNull(); - expect(optionsPanel()?.dataset.panelKind).toBe('reasoning'); + )).not.toBeNull(); + expect(optionsPanel()).toBeNull(); const options = Array.from(document.body.querySelectorAll( - '[data-testid="chat-model-selector-reasoning-option"]', + '[data-testid="chat-model-selector-reasoning-options"] button', )); - expect(sharedPanelItems()).not.toBeNull(); - expect(options.every(option => sharedPanelItems()?.contains(option))).toBe(true); - expect(options.map(option => option.dataset.presetId)) - .toEqual(['auto', 'off', 'low', 'medium', 'high']); + expect(options.every(option => option.closest('[data-testid="chat-model-selector-settings"]'))).toBe(true); + expect(options.map(option => option.dataset.openbitfunValue)) + .toEqual(['auto', 'preset:off', 'preset:low', 'preset:medium', 'preset:high']); + expect(options.find(option => option.dataset.openbitfunValue === 'unavailable:xhigh')).toBeUndefined(); expect(options.every(option => ( option.querySelector('.openbitfun-model-selector__option-desc') === null ))).toBe(true); expect(options.every(option => option.querySelector('svg') === null)).toBe(true); - expect(options.find(option => option.dataset.presetId === 'low')?.getAttribute('aria-checked')) + expect(options.find(option => option.dataset.openbitfunValue === 'auto')?.getAttribute('aria-pressed')) .toBe('true'); expect(flowChatStoreMocks.store.updateSessionReasoningPreset).not.toHaveBeenCalled(); expect(agentAPI.updateSessionModel).not.toHaveBeenCalled(); @@ -532,7 +535,7 @@ describe('ModelSelector provider levels', () => { await act(async () => reasoningRow?.click()); await act(async () => { document.body.querySelector( - '[data-testid="chat-model-selector-reasoning-option"][data-preset-id="high"]', + '[data-openbitfun-value="preset:high"]', )?.click(); }); @@ -546,23 +549,30 @@ describe('ModelSelector provider levels', () => { it.each(['smart', 'pool'] as const)('selects %s mode with a notification-center notice and no blocking dialog', async (mode) => { aiApiMocks.getModelCatalog.mockResolvedValue({ version: 1, default_models: { primary: 'acme-fast' }, - models: [{ id: 'acme-fast', reasoning: { + models: [{ id: 'umbra-main', reasoning: { status: 'unknown', presets: [] } }, { id: 'acme-fast', reasoning: { status: 'known', default_preset: 'high', presets: [{ id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [{ type: 'effort', value: 'high' }] }], } }], }); await renderSelector(CATALOG_MODELS, 'umbra-main'); await openSettingsMenu(); - expect(document.body.querySelector(`[data-testid="chat-model-selector-mode-${mode}"]`)).toBeNull(); + expect(document.body.querySelector('[data-testid="chat-model-selector-reasoning-status"]')).toBeNull(); + expect(modeChoices()).toBeNull(); await act(async () => modeTrigger().click()); - expect(optionsPanel()?.dataset.panelKind).toBe('modes'); - expect(optionsPanel()?.querySelectorAll('[role="menuitemradio"]')).toHaveLength(3); - expect(modeOption('manual').getAttribute('aria-checked')).toBe('true'); - expect(modeOption('manual').textContent).toContain('umbra-main-native'); + expect(optionsPanel()).toBeNull(); + expect(modeChoices()?.querySelectorAll('button')).toHaveLength(3); + expect(modeChoices()?.querySelector('[role="menuitemradio"]')).toBeNull(); + expect(modeOption('manual')?.getAttribute('aria-pressed')).toBe('true'); + expect(modeOption('manual')?.textContent).toBe('modelSelector.modes.manual'); + expect(modeTooltip('manual')).toBe('umbra-main-native'); + expect(document.body.querySelector('.openbitfun-model-selector__model-summary[aria-hidden="true"][inert]')).not.toBeNull(); + expect(document.body.querySelector('[data-testid="chat-model-selector-reasoning-status"]')).toBeNull(); + expect(document.body.querySelector('.openbitfun-model-selector__mode-footer[aria-hidden="true"][inert]')).not.toBeNull(); + expect(document.body.querySelector('[data-testid="chat-model-selector-menu"]')?.getAttribute('data-menu-level')).toBe('settings'); expect(modelOption('primary')).toBeNull(); const trigger = container.querySelector('[data-testid="chat-model-selector-btn"]')!; - const entry = document.body.querySelector(`[data-testid="chat-model-selector-mode-${mode}"]`)!; - expect(entry.textContent).toContain('modelSelector.modes.comingSoon'); + const entry = modeOption(mode)!; + expect(modeTooltip(mode)).toBe('modelSelector.modes.comingSoon'); await act(async () => entry.click()); expect(useConfirmDialogStore.getState().isOpen).toBe(false); @@ -587,10 +597,10 @@ describe('ModelSelector provider levels', () => { await act(async () => settings.click()); expect(optionsPanel()).toBeNull(); await act(async () => modeTrigger().click()); - expect(optionsPanel()?.dataset.panelKind).toBe('modes'); - expect(document.body.querySelector(`[data-testid="chat-model-selector-mode-${mode}"]`)?.getAttribute('aria-checked')).toBe('true'); - expect(modeOption('manual').textContent).toContain('umbra-main-native'); - await act(async () => modeOption('manual').click()); + expect(modeChoices()).not.toBeNull(); + expect(modeOption(mode)?.getAttribute('aria-pressed')).toBe('true'); + expect(modeTooltip('manual')).toBe('umbra-main-native'); + await act(async () => modeOption('manual')!.click()); expect(trigger.textContent).not.toContain(`modelSelector.modes.${mode}`); expect(trigger.textContent).toContain('umbra-main-native'); expect(trigger.getAttribute('aria-expanded')).toBe('true'); @@ -604,9 +614,7 @@ describe('ModelSelector provider levels', () => { }); await renderSelector(CATALOG_MODELS, 'primary', 'session-a'); await openModeMenu(); - await act(async () => document.body.querySelector( - '[data-testid="chat-model-selector-mode-smart"]', - )!.click()); + await act(async () => modeOption('smart')!.click()); expect(agentAPI.updateSessionModel).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-a', modelName: 'primary', reasoningPreset: null, })); @@ -617,20 +625,19 @@ describe('ModelSelector provider levels', () => { await renderSelector(CATALOG_MODELS, 'umbra-main'); vi.mocked(configManager.setConfig).mockRejectedValueOnce(new Error('Settings unavailable')); await openModeMenu(); - await act(async () => document.body.querySelector( - '[data-testid="chat-model-selector-mode-pool"]', - )!.click()); + await act(async () => modeOption('pool')!.click()); expect(useConfirmDialogStore.getState().isOpen).toBe(false); expect(notificationStore.getState().notificationHistory.filter(notice => notice.type === 'info')).toEqual([]); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.textContent).toContain('umbra-main-native'); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); - expect(optionsPanel()?.dataset.panelKind).toBe('modes'); + expect(modeChoices()).not.toBeNull(); + expect(document.activeElement).toBe(modeOption('pool')); }); it('disables upcoming modes when their primary-model fallback is unavailable', async () => { await renderSelector([]); await openModeMenu(); - const entry = document.body.querySelector('[data-testid="chat-model-selector-mode-smart"]')!; + const entry = modeOption('smart')!; expect(entry.disabled).toBe(true); await act(async () => entry.click()); expect(configManager.setConfig).not.toHaveBeenCalled(); @@ -650,11 +657,11 @@ describe('ModelSelector provider levels', () => { for (const mode of ['smart', 'pool'] as const) { await act(async () => modeTrigger().click()); - expect(modeOption('manual').textContent).toContain('acme-deep-native'); - await act(async () => modeOption(mode).click()); + expect(modeTooltip('manual')).toBe('acme-deep-native'); + await act(async () => modeOption(mode)!.click()); } await act(async () => modeTrigger().click()); - await act(async () => modeOption('manual').click()); + await act(async () => modeOption('manual')!.click()); expect(agentAPI.updateSessionModel).toHaveBeenLastCalledWith(expect.objectContaining({ sessionId: 'session-a', modelName: 'acme-deep', })); @@ -670,7 +677,7 @@ describe('ModelSelector provider levels', () => { }); await renderSelector(CATALOG_MODELS, 'primary', 'session-a'); await openModeMenu(); - await act(async () => modeOption('smart').click()); + await act(async () => modeOption('smart')!.click()); expect(getRecentManualModel(getActiveSurfaceScope().key('model-selector', 'Standard', 'session-b'))) .toBeUndefined(); expect(getRecentManualModel(surfaceScopedKey('other-device', 'model-selector', 'Standard', 'session-a'))) @@ -680,8 +687,8 @@ describe('ModelSelector provider levels', () => { root = createRoot(container); await renderSelector(CATALOG_MODELS, 'primary', 'session-a'); await openModeMenu(); - expect(modeOption('manual').textContent).toContain('umbra-main-native'); - await act(async () => modeOption('manual').click()); + expect(modeTooltip('manual')).toBe('umbra-main-native'); + await act(async () => modeOption('manual')!.click()); expect(agentAPI.updateSessionModel).toHaveBeenLastCalledWith(expect.objectContaining({ sessionId: 'session-a', modelName: 'umbra-main', })); @@ -690,14 +697,15 @@ describe('ModelSelector provider levels', () => { it('retains the saved manual model when restoration fails and allows retrying', async () => { await renderSelector(CATALOG_MODELS, 'umbra-main'); await openModeMenu(); - await act(async () => modeOption('pool').click()); + await act(async () => modeOption('pool')!.click()); await act(async () => modeTrigger().click()); vi.mocked(configManager.setConfig).mockRejectedValueOnce(new Error('Settings unavailable')); - await act(async () => modeOption('manual').click()); - expect(optionsPanel()?.dataset.panelKind).toBe('modes'); - expect(modeOption('pool').getAttribute('aria-checked')).toBe('true'); - expect(modeOption('manual').textContent).toContain('umbra-main-native'); - await act(async () => modeOption('manual').click()); + await act(async () => modeOption('manual')!.click()); + expect(modeChoices()).not.toBeNull(); + expect(modeOption('pool')?.getAttribute('aria-pressed')).toBe('true'); + expect(modeTooltip('manual')).toBe('umbra-main-native'); + expect(document.activeElement).toBe(modeOption('manual')); + await act(async () => modeOption('manual')!.click()); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.textContent) .toContain('umbra-main-native'); }); @@ -705,7 +713,7 @@ describe('ModelSelector provider levels', () => { it('asks for another model when the remembered model has been removed', async () => { await renderSelector(CATALOG_MODELS, 'umbra-main'); await openModeMenu(); - await act(async () => modeOption('smart').click()); + await act(async () => modeOption('smart')!.click()); vi.mocked(configManager.getConfigs).mockResolvedValue({ 'ai.models': CATALOG_MODELS.filter(model => model.id !== 'umbra-main'), 'ai.default_models': { primary: 'acme-fast' }, @@ -716,7 +724,7 @@ describe('ModelSelector provider levels', () => { }); await act(async () => modeTrigger().click()); vi.mocked(configManager.setConfig).mockClear(); - await act(async () => modeOption('manual').click()); + await act(async () => modeOption('manual')!.click()); expect(configManager.setConfig).not.toHaveBeenCalled(); expect(optionsPanel()?.dataset.panelKind).toBe('models'); expect(getRecentManualModel(getActiveSurfaceScope().key('model-selector', 'Standard', undefined))) @@ -730,15 +738,55 @@ describe('ModelSelector provider levels', () => { modeTrigger().focus(); modeTrigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); }); - expect(optionsPanel()?.dataset.panelKind).toBe('modes'); + expect(optionsPanel()).toBeNull(); + expect(modeChoices()).not.toBeNull(); expect(document.activeElement).toBe(modeOption('manual')); await act(async () => { - optionsPanel()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + modeOption('manual')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + }); + expect(document.activeElement).toBe(modeOption('smart')); + expect(modeOption('manual')?.getAttribute('aria-pressed')).toBe('true'); + expect(configManager.setConfig).not.toHaveBeenCalled(); + await act(async () => { + modeOption('smart')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + }); + expect(document.activeElement).toBe(modeOption('manual')); + await act(async () => { + modeOption('manual')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(modeChoices()).toBeNull(); + expect(document.activeElement).toBe(modeTrigger()); + expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); + }); + + it('retains the card and its background until mode persistence succeeds, without duplicate saves', async () => { + aiApiMocks.getModelCatalog.mockResolvedValue({ + version: 1, default_models: { primary: 'acme-fast' }, + models: [{ id: 'acme-fast', reasoning: { status: 'known', presets: [ + { id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [{ type: 'effort', value: 'high' }] }, + ] } }], }); + await renderSelector(); + await openSettingsMenu(); + const sky = document.body.querySelector('[data-openbitfun-part="reasoningSliderSky"]'); + expect(sky).not.toBeNull(); + await act(async () => modeTrigger().click()); + expect(document.body.querySelector('[data-openbitfun-part="reasoningSliderSky"]')).toBe(sky); + let finish!: () => void; + vi.mocked(configManager.setConfig).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => modeOption('smart')!.click()); + expect(modeChoices()).not.toBeNull(); + expect(Array.from(modeChoices()!.querySelectorAll('button')).every(button => button.disabled)).toBe(true); + expect(modeOption('manual')?.getAttribute('aria-pressed')).toBe('true'); + await act(async () => modeOption('pool')!.click()); + expect(configManager.setConfig).toHaveBeenCalledTimes(1); + await act(async () => finish()); + expect(modeChoices()).toBeNull(); + expect(modeTrigger().textContent).toBe('modelSelector.modes.smart'); expect(document.activeElement).toBe(modeTrigger()); }); - it('keeps auto separate from the five-stop slider and saves without closing the card', async () => { + it('selects the advertised levels inline and keeps Auto separate from Off without closing the card', async () => { aiApiMocks.getModelCatalog.mockResolvedValue({ version: 1, default_models: { primary: 'acme-fast' }, @@ -765,35 +813,37 @@ describe('ModelSelector provider levels', () => { expect(slider()).toBeNull(); expect(document.body.querySelector('[data-reasoning-mode="auto"] [data-testid="chat-model-selector-settings-reasoning"]')?.textContent) .toBe('reasoningSelector.thinking · reasoningSelector.auto'); + expect(document.body.querySelector('[data-testid="chat-model-selector-intensity-control"]')?.getAttribute('data-intensity')).toBe('1'); await openReasoning(); + expect(document.body.querySelectorAll('[data-testid="chat-model-selector-reasoning-options"] [aria-pressed="true"]')).toHaveLength(1); + expect(document.body.querySelector('[data-openbitfun-value="auto"]')?.getAttribute('aria-pressed')).toBe('true'); await act(async () => document.body.querySelector( - '[data-testid="chat-model-selector-reasoning-option"][data-preset-id="high"]', + '[data-openbitfun-value="preset:high"]', )!.click()); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); - expect(slider()?.value).toBe('3'); - expect(slider()?.getAttribute('aria-valuetext')).toBe('reasoningSelector.levels.high'); + expect(slider()).toBeNull(); + expect(optionsPanel()).toBeNull(); + expect(document.body.querySelector('[data-testid="chat-model-selector-intensity-control"]')?.getAttribute('data-intensity')).toBe('3'); const manualRow = document.body.querySelector('[data-reasoning-mode="manual"]')!; expect(manualRow.querySelector('[data-testid="chat-model-selector-settings-reasoning"]')?.textContent) .toBe('reasoningSelector.thinking · reasoningSelector.levels.high'); expect(manualRow.querySelectorAll('[data-testid="chat-model-selector-settings-reasoning"]')).toHaveLength(1); expect(document.body.querySelector('[data-reasoning-mode="manual"] [data-openbitfun-name="reasoning-auto"]')).toBeNull(); - await act(async () => { - slider()!.focus(); - slider()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); - }); + await openReasoning(); expect(getRecentReasoningPreset('acme-fast')).toBe('high'); await act(async () => { - slider()!.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home', bubbles: true })); + document.body.querySelector('[data-openbitfun-value="preset:off"]')!.click(); }); expect(getRecentReasoningPreset('acme-fast')).toBe('off'); - expect(slider()?.value).toBe('0'); + expect(slider()).toBeNull(); + expect(document.body.querySelector('[data-testid="chat-model-selector-intensity-control"]')?.getAttribute('data-intensity')).toBe('1'); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); - expect(document.activeElement).toBe(slider()); + expect(document.activeElement).toBe(document.body.querySelector('[data-testid="chat-model-selector-settings-reasoning"]')); await openReasoning(); await act(async () => document.body.querySelector( - '[data-testid="chat-model-selector-reasoning-option"][data-preset-id="auto"]', + '[data-openbitfun-value="auto"]', )!.click()); expect(container.querySelector('[data-testid="chat-model-selector-btn"]')?.getAttribute('aria-expanded')).toBe('true'); expect(slider()).toBeNull(); @@ -1273,12 +1323,12 @@ describe('ModelSelector provider levels', () => { )!; expect(document.activeElement).toBe(document.body.querySelector('[data-testid="chat-model-selector-settings-model"]')); await act(async () => reasoningRow.click()); - expect(optionsPanel()?.dataset.panelKind).toBe('reasoning'); + expect(optionsPanel()).toBeNull(); expect(document.body.querySelector('[data-testid="chat-model-selector-menu"]')).toBe(surface); - expect(document.body.querySelector('[data-testid="chat-model-selector-settings"]')).toBeNull(); - await act(async () => document.body.querySelector( - '[data-testid="chat-model-selector-summary-back"]', - )!.click()); + expect(document.body.querySelector('[data-testid="chat-model-selector-settings"]')).not.toBeNull(); + await act(async () => document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', bubbles: true, cancelable: true, + }))); expect(optionsPanel()).toBeNull(); expect(document.activeElement).toBe(document.body.querySelector('[data-testid="chat-model-selector-settings-reasoning"]')); }); diff --git a/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.scss b/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.scss new file mode 100644 index 0000000000..87d2cde860 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.scss @@ -0,0 +1,590 @@ +@use 'sass:math'; +@use 'sass:list'; + +// Distant points drift with the foreground stars, which twinkle in place. +$reasoning-dust: (); +@for $index from 1 through 72 { + $x: math.div(($index * 31 + $index * $index * 7) % 137, 137) * 100%; + $y: math.div(($index * 47 + $index * $index * 13) % 139, 139) * 100%; + $reasoning-dust: list.append($reasoning-dust, + radial-gradient(circle at $x $y, var(--_reasoning-star-color) 0 0.45px, transparent 1.05px), comma); +} + +.openbitfun-reasoning-control { + &__segments[data-wrap='true'] { + display: grid; + } + + --_reasoning-control-tint-source: color-mix(in oklch, var(--openbitfun-color-link-default) 70%, var(--openbitfun-color-accent-secondary)); + --_reasoning-control-tint: var(--_reasoning-control-tint-source); + --_reasoning-control-glow: color-mix(in oklch, var(--openbitfun-color-link-default) 64%, var(--openbitfun-color-content-on-dark)); + --_reasoning-control-highlight: color-mix(in oklch, var(--_reasoning-control-glow) 76%, var(--openbitfun-color-content-on-dark)); + --_reasoning-sky-opacity: 0.74; + --_reasoning-light-opacity: 0.78; + --_reasoning-texture-opacity: 0.14; + --_reasoning-cloud-width: 50%; + --_reasoning-cloud-height: 70%; + --_reasoning-cloud-depth: 0.46; + --_reasoning-dust-opacity: 0.16; + --_reasoning-star-color: var(--openbitfun-color-content-on-dark); + + @supports (color: oklch(from currentColor l c h)) { + // Normalize chroma after mixing: the light theme's secondary accent is intentionally muted. + --_reasoning-control-tint: oklch(from var(--_reasoning-control-tint-source) clamp(0.66, l, 0.72) max(c, 0.18) h); + --_reasoning-control-glow: oklch(from var(--openbitfun-color-link-default) clamp(0.76, l, 0.82) max(c, 0.14) h); + } + + :where([data-color-scheme='dark']) & { + // Atmosphere stays deep; the small luminous clouds keep their own exposure. + --_reasoning-sky-opacity: 0.64; + --_reasoning-light-opacity: 0.8; + --_reasoning-texture-opacity: 0.1; + --_reasoning-control-highlight: color-mix(in oklch, var(--_reasoning-control-glow) 58%, var(--openbitfun-color-content-on-dark)); + } + + position: relative; + isolation: isolate; + display: grid; + place-items: center; + min-inline-size: 0; + min-block-size: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); + overflow: hidden; + color: var(--openbitfun-color-content-secondary); + font-family: var(--openbitfun-type-label-md-font-family); + font-size: var(--openbitfun-type-label-md-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); + line-height: var(--openbitfun-type-action-row-line-height); + letter-spacing: var(--openbitfun-type-label-md-letter-spacing); + + &__backdrop, + &__sky, + &__field, + &__aurora, + &__stars { + position: absolute; + inset: 0; + } + + &__sky { + overflow: hidden; + // Lift the translucent base separately from the colored cloud light. + background: color-mix(in srgb, + color-mix(in oklch, var(--_reasoning-control-tint) 12%, var(--openbitfun-color-content-on-dark)) 78%, transparent); + pointer-events: none; + + :where([data-color-scheme='dark']) & { + background: color-mix(in srgb, + color-mix(in oklch, var(--_reasoning-control-tint) 22%, var(--openbitfun-color-surface-raised)) 74%, transparent); + } + } + + &__field, + &__aurora, + &__stars { + // Every level covers the same sky; only the depth and luminosity change. + transition: opacity 180ms var(--openbitfun-motion-easing-standard); + } + + &__stars { + inset: -3%; + opacity: 1; + animation: openbitfun-reasoning-starfield 12s ease-in-out -4s infinite alternate; + // The brightest stars stay around the text, not behind its letterforms. + mask-image: radial-gradient(ellipse 42% 64% at 50% 42%, + transparent 8%, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 30%, transparent) 40%, var(--openbitfun-color-content-on-dark) 88%); + } + + &__field { + opacity: var(--_reasoning-sky-opacity); + background: + radial-gradient(ellipse var(--_reasoning-cloud-width) 90% at 12% 100%, color-mix(in srgb, var(--_reasoning-control-glow) 38%, transparent), transparent 72%), + radial-gradient(ellipse 72% var(--_reasoning-cloud-height) at 94% 8%, color-mix(in srgb, var(--_reasoning-control-tint) 34%, transparent), transparent 76%), + linear-gradient(128deg, + color-mix(in srgb, var(--_reasoning-control-tint) 14%, transparent), + transparent 48%, + color-mix(in srgb, var(--_reasoning-control-tint) 18%, transparent)); + + &::after { + content: ''; + position: absolute; + inset: -30% -10%; + background: + radial-gradient(ellipse 32% 20% at 20% 78%, color-mix(in srgb, var(--_reasoning-control-highlight) 32%, transparent), transparent 74%), + radial-gradient(ellipse 30% 28% at 82% 28%, color-mix(in srgb, var(--_reasoning-control-glow) 38%, transparent), transparent 76%); + animation: openbitfun-reasoning-nebula 6.4s ease-in-out -2.1s infinite alternate; + } + } + + &__nebula, + &__aurora { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + overflow: visible; + } + + &__nebula { + color: var(--_reasoning-control-highlight); + opacity: var(--_reasoning-texture-opacity); + transform-origin: center; + animation: openbitfun-reasoning-cloud 8s ease-in-out -2.7s infinite alternate; + mask-image: radial-gradient(ellipse 46% 64% at 50% 40%, + color-mix(in srgb, var(--openbitfun-color-content-on-dark) 32%, transparent), var(--openbitfun-color-content-on-dark) 90%); + } + + &__aurora { + // Emission is independent of atmosphere opacity; lower tiers use smaller clouds. + opacity: var(--_reasoning-light-opacity); + mask-image: radial-gradient(ellipse 42% 58% at 50% 44%, + transparent, var(--openbitfun-color-content-on-dark) 86%); + + &::before, + &::after { + content: ''; + position: absolute; + inset: -22% -18%; + opacity: 0.88; + transform-origin: center; + animation: openbitfun-reasoning-near-cloud 7.2s ease-in-out -2.4s infinite alternate; + } + + &::before { + filter: var(--openbitfun-effect-blur-base); + background: + radial-gradient(ellipse var(--_reasoning-cloud-width) var(--_reasoning-cloud-height) at 18% 82%, + color-mix(in srgb, var(--_reasoning-control-glow) 64%, transparent), + color-mix(in srgb, var(--_reasoning-control-tint) 42%, transparent) 28%, transparent 72%); + } + + &::after { + // A smaller unblurred light core gives the soft cloud a readable edge and depth. + background: + radial-gradient(ellipse 22% 14% at 80% 20%, color-mix(in srgb, var(--_reasoning-control-highlight) 44%, transparent), transparent 78%), + radial-gradient(ellipse var(--_reasoning-cloud-width) var(--_reasoning-cloud-height) at 86% 20%, + color-mix(in srgb, var(--_reasoning-control-glow) 54%, transparent), + color-mix(in srgb, var(--_reasoning-control-tint) 32%, transparent) 30%, transparent 70%); + animation-duration: 9.6s; + animation-delay: -5.8s; + animation-direction: alternate-reverse; + } + } + + &__stars::before { + content: ''; + position: absolute; + inset: 0; + background-image: #{$reasoning-dust}; + opacity: var(--_reasoning-dust-opacity); + transition: opacity 180ms var(--openbitfun-motion-easing-standard); + } + + &__star-slot { + position: absolute; + inline-size: 1.8px; + block-size: 1.8px; + opacity: 0; + transition: opacity 180ms var(--openbitfun-motion-easing-standard); + + &:nth-child(-n + 12) { + opacity: 1; + } + + // Slots stay mounted across levels so the same stars retain their positions. + @for $index from 1 through 48 { + &:nth-child(#{$index}) { + inset-inline-start: math.div(($index * 37 + $index * $index * 11) % 97, 97) * 96% + 2%; + inset-block-start: math.div(($index * 53 + $index * $index * 7) % 83, 83) * 80% + 10%; + + .openbitfun-reasoning-control__star { + // Brightness phases vary; the containing starfield owns all movement. + animation-duration: 2.8s + ($index % 5) * 0.35s; + animation-delay: $index * -0.417s; + } + } + } + + &:nth-child(3n) { + inline-size: 1px; + block-size: 1px; + } + } + + &__star { + display: block; + inline-size: 100%; + block-size: 100%; + border-radius: var(--openbitfun-radius-pill); + background: var(--_reasoning-star-color); + box-shadow: 0 0 calc(var(--openbitfun-space-1) * 0.5) color-mix(in srgb, var(--_reasoning-star-color) 26%, transparent); + opacity: 0.86; + animation: openbitfun-reasoning-star 4s ease-in-out infinite; + } + + &[data-intensity='2'] { + --_reasoning-control-tint-source: color-mix(in oklch, var(--openbitfun-color-link-default) 55%, var(--openbitfun-color-accent-secondary)); + --_reasoning-cloud-width: 60%; + --_reasoning-cloud-height: 80%; + --_reasoning-cloud-depth: 0.64; + --_reasoning-dust-opacity: 0.24; + + .openbitfun-reasoning-control__star-slot:nth-child(-n + 24) { + opacity: 1; + } + } + + &[data-intensity='3'] { + --_reasoning-control-tint-source: color-mix(in oklch, var(--openbitfun-color-link-default) 40%, var(--openbitfun-color-accent-secondary)); + --_reasoning-cloud-width: 70%; + --_reasoning-cloud-height: 90%; + --_reasoning-cloud-depth: 0.82; + --_reasoning-dust-opacity: 0.38; + + .openbitfun-reasoning-control__star-slot:nth-child(-n + 36) { + opacity: 1; + } + } + + &[data-intensity='4'] { + --_reasoning-control-tint-source: color-mix(in oklch, var(--openbitfun-color-link-default) 25%, var(--openbitfun-color-accent-secondary)); + --_reasoning-cloud-width: 80%; + --_reasoning-cloud-height: 100%; + --_reasoning-cloud-depth: 1; + --_reasoning-dust-opacity: 0.56; + + .openbitfun-reasoning-control__star-slot { + opacity: 1; + } + } + + &[data-reasoning-state='manual']:is([data-intensity='3'], [data-intensity='4']) { + .openbitfun-reasoning-control__star-slot:nth-child(12n) { + inline-size: 2.2px; + block-size: 2.2px; + } + } + + &[data-reasoning-state='off'] { + --_reasoning-control-tint-source: color-mix(in oklch, var(--openbitfun-color-link-default) 64%, var(--openbitfun-color-accent-secondary)); + --_reasoning-control-glow: color-mix(in oklch, var(--openbitfun-color-link-default) 66%, var(--openbitfun-color-content-on-dark)); + --_reasoning-light-opacity: 0.58; + --_reasoning-texture-opacity: 0.12; + --_reasoning-cloud-width: 74%; + --_reasoning-cloud-height: 84%; + --_reasoning-cloud-depth: 0.74; + + .openbitfun-reasoning-control__field { + // An authored silver-blue cloud composition remains when all stars are absent. + background: + radial-gradient(ellipse 90% 100% at 12% 12%, color-mix(in srgb, var(--_reasoning-control-glow) 28%, transparent), transparent 76%), + radial-gradient(ellipse 90% 86% at 78% 86%, color-mix(in srgb, var(--_reasoning-control-tint) 26%, transparent), transparent 82%), + linear-gradient(120deg, color-mix(in srgb, var(--_reasoning-control-glow) 16%, transparent), transparent 70%); + + &::after { + background: + radial-gradient(ellipse 68% 60% at 8% 80%, color-mix(in srgb, var(--_reasoning-control-highlight) 36%, transparent), transparent 78%), + radial-gradient(ellipse 54% 48% at 88% 24%, color-mix(in srgb, var(--_reasoning-control-glow) 30%, transparent), transparent 80%); + } + } + } + + &[data-reasoning-state='auto'] { + --_reasoning-control-tint-source: color-mix(in oklch, var(--openbitfun-color-link-default) 60%, var(--openbitfun-color-accent-secondary)); + --_reasoning-light-opacity: 0.68; + --_reasoning-cloud-width: 62%; + --_reasoning-cloud-height: 88%; + --_reasoning-cloud-depth: 0.6; + --_reasoning-dust-opacity: 0.18; + + .openbitfun-reasoning-control__field { + background: + radial-gradient(ellipse 48% 96% at 20% 50%, color-mix(in srgb, var(--_reasoning-control-glow) 28%, transparent), transparent 80%), + radial-gradient(ellipse 48% 96% at 80% 50%, color-mix(in srgb, var(--_reasoning-control-tint) 28%, transparent), transparent 80%), + linear-gradient(180deg, transparent, color-mix(in srgb, var(--_reasoning-control-tint) 16%, transparent)); + + &::after { + background: + radial-gradient(ellipse 30% 40% at 28% 42%, color-mix(in srgb, var(--_reasoning-control-highlight) 26%, transparent), transparent 80%), + radial-gradient(ellipse 30% 40% at 72% 42%, color-mix(in srgb, var(--_reasoning-control-highlight) 26%, transparent), transparent 80%); + } + } + + .openbitfun-reasoning-control__aurora::before, + .openbitfun-reasoning-control__aurora::after { + filter: none; + animation: openbitfun-reasoning-balance 6s ease-in-out infinite alternate; + } + + .openbitfun-reasoning-control__aurora::before { + background: + radial-gradient(ellipse 28% 24% at 24% 48%, color-mix(in srgb, var(--_reasoning-control-highlight) 24%, transparent), transparent 78%), + radial-gradient(ellipse var(--_reasoning-cloud-width) var(--_reasoning-cloud-height) at 24% 50%, + color-mix(in srgb, var(--_reasoning-control-glow) 48%, transparent), + color-mix(in srgb, var(--_reasoning-control-tint) 24%, transparent) 38%, transparent 78%); + } + + .openbitfun-reasoning-control__aurora::after { + background: + radial-gradient(ellipse 28% 24% at 76% 48%, color-mix(in srgb, var(--_reasoning-control-highlight) 24%, transparent), transparent 78%), + radial-gradient(ellipse var(--_reasoning-cloud-width) var(--_reasoning-cloud-height) at 76% 50%, + color-mix(in srgb, var(--_reasoning-control-glow) 48%, transparent), + color-mix(in srgb, var(--_reasoning-control-tint) 24%, transparent) 38%, transparent 78%); + animation-delay: -6s; + } + + .openbitfun-reasoning-control__star-slot { + opacity: 0; + + &:nth-child(-n + 18) { + opacity: 0.82; + } + } + } + + &__value { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + justify-content: center; + min-inline-size: 0; + inline-size: 100%; + min-block-size: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); + text-align: center; + } + + &__summary, + &__options { + grid-area: 1 / 1; + inline-size: 100%; + min-inline-size: 0; + } + + &__summary { + display: flex; + justify-content: center; + transition: opacity 180ms var(--openbitfun-motion-easing-standard); + + &[aria-hidden='true'] { + opacity: 0; + pointer-events: none; + } + } + + &__value &__options { + // Both faces share one center; height changes move the model, not the choices. + display: flex; + align-items: center; + overflow: visible; + transform: none; + } + + &__options-content { + flex: 0 0 100%; + min-inline-size: 0; + clip-path: inset(calc(-1 * var(--openbitfun-space-1)) 46% round var(--openbitfun-radius-base)); + transition-property: clip-path; + transition-duration: inherit; + transition-timing-function: inherit; + } + + &__options[aria-hidden='false'] &__options-content { + // Reveal sideways without stretching text; leave room for keyboard focus outlines. + clip-path: inset(calc(-1 * var(--openbitfun-space-1)) round var(--openbitfun-radius-base)); + + @starting-style { + clip-path: inset(calc(-1 * var(--openbitfun-space-1)) 46% round var(--openbitfun-radius-base)); + } + } + + &__backdrop { + pointer-events: none; + } + + &__trigger { + inline-size: max-content; + max-inline-size: 100%; + min-inline-size: 0; + min-block-size: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); + color: inherit; + + [data-openbitfun-part='trigger'] { + justify-content: center; + text-align: center; + } + + [data-openbitfun-part='label'] { + flex: 0 1 auto; + inline-size: max-content; + min-inline-size: 4em; + text-align: center; + font-family: var(--openbitfun-type-label-sm-font-family); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); + line-height: var(--openbitfun-type-label-sm-line-height); + letter-spacing: var(--openbitfun-type-label-sm-letter-spacing); + } + } + + &__choices { + display: grid; + gap: var(--openbitfun-space-1); + inline-size: 100%; + min-inline-size: 0; + + [data-openbitfun-component='segmented-control'] { + min-inline-size: 0; + gap: var(--openbitfun-space-1); + padding: 0; + background: transparent; + } + + [data-openbitfun-part='segment'] { + block-size: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); + padding-inline: var(--openbitfun-space-1); + color: var(--openbitfun-color-content-secondary); + font-family: var(--openbitfun-type-label-sm-font-family); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); + line-height: var(--openbitfun-type-label-sm-line-height); + letter-spacing: var(--openbitfun-type-label-sm-letter-spacing); + + &[aria-pressed='true'] { + color: var(--openbitfun-color-content-primary); + background: color-mix(in srgb, var(--_reasoning-control-tint) 14%, transparent); + } + + &:disabled { + color: var(--openbitfun-color-action-neutral-content-disabled); + } + + &:is(:hover, :focus-visible):not(:disabled) { + color: var(--openbitfun-color-content-primary); + background: color-mix(in srgb, var(--_reasoning-control-tint) calc(12% + var(--_reasoning-cloud-depth) * 18%), var(--openbitfun-color-surface-panel)); + } + } + } + + &[data-disabled='true'] &__star, + &[data-disabled='true'] &__stars, + &[data-disabled='true'] &__field::after, + &[data-disabled='true'] &__nebula, + &[data-disabled='true'] &__aurora::before, + &[data-disabled='true'] &__aurora::after { + animation-play-state: paused; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + + &[data-reasoning-state='auto'] &__aurora::before, + &[data-reasoning-state='auto'] &__aurora::after, + &__star, + &__stars, + &__field::after, + &__nebula, + &__aurora::before, + &__aurora::after { + animation: none; + } + + &__field, + &__aurora, + &__stars, + &__stars::before, + &__star-slot, + &__summary, + &__options, + &__options-content { + transition: none; + } + } + + @media (forced-colors: active) { + color: CanvasText; + background: Canvas; + + &__backdrop { + display: none; + } + } +} + +@keyframes openbitfun-reasoning-nebula { + from { + transform: translate3d(-6%, 4%, 0) scale(1.06); + opacity: 0.55; + } + + to { + transform: translate3d(6%, -4%, 0) scale(1.12); + opacity: 0.9; + } +} + +@keyframes openbitfun-reasoning-cloud { + from { + transform: translate3d(-8%, 5%, 0) scale(1.3); + opacity: calc(var(--_reasoning-texture-opacity) * 0.85); + } + + to { + transform: translate3d(8%, -5%, 0) scale(1.3); + opacity: var(--_reasoning-texture-opacity); + } +} + +@keyframes openbitfun-reasoning-near-cloud { + from { + transform: translate3d(-8%, 5%, 0) scale(1.06); + opacity: 0.78; + } + + to { + transform: translate3d(8%, -5%, 0) scale(1.1); + opacity: 0.98; + } +} + +@keyframes openbitfun-reasoning-balance { + from { + transform: translate3d(-6%, 3%, 0) scale(1.06); + opacity: 0.66; + } + + to { + transform: translate3d(6%, -3%, 0) scale(1.06); + opacity: 0.9; + } +} + +@keyframes openbitfun-reasoning-starfield { + from { + transform: translate3d(-6px, 2px, 0); + } + + to { + transform: translate3d(6px, -2px, 0); + } +} + +@keyframes openbitfun-reasoning-star { + 0%, + 100% { + opacity: 0.28; + } + + 20%, + 32% { + opacity: 1; + } + + 58% { + opacity: 0.38; + } + + 78% { + opacity: 0.78; + } +} diff --git a/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.test.tsx b/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.test.tsx new file mode 100644 index 0000000000..411e8db498 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.test.tsx @@ -0,0 +1,271 @@ +/** @vitest-environment jsdom */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; +import { ReasoningIntensityControl } from './ReasoningIntensityControl'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (key: string, options?: { defaultValue?: string }) => ( + key.startsWith('reasoningEffort.') ? options?.defaultValue ?? key : key + ) }), +})); + +const preset = (id: string): ReasoningPresetDescriptor => ({ + id, label: id, order: 0, source: 'models_dev', + actions: id === 'off' ? [{ type: 'toggle', enabled: false }] : [{ type: 'effort', value: id }], +}); +const allPresets = ['off', 'low', 'medium', 'high', 'xhigh'].map(preset); + +describe('ReasoningIntensityControl', () => { + let container: HTMLDivElement; + let root: Root; + const onSelect = vi.fn<(id: string | null) => Promise>(); + const trigger = () => container.querySelector('[data-testid="chat-model-selector-settings-reasoning"]')!; + const choices = () => { + const panel = container.querySelector('[data-testid="chat-model-selector-reasoning-options"]'); + return panel?.closest('[aria-hidden="true"]') ? null : panel; + }; + const control = () => container.querySelector('[data-testid="chat-model-selector-intensity-control"]')!; + const option = (value: string) => Array.from(choices()?.querySelectorAll('button') ?? []) + .find(button => button.dataset.openbitfunValue === value)!; + const render = async (presets = allPresets, selected: string | undefined = 'medium', allowDefaultReset = true, disabled = false, active = true) => { + await act(async () => root.render( + item.id === selected)} + allowDefaultReset={allowDefaultReset} + disabled={disabled} + active={active} + onSelect={onSelect} + />, + )); + }; + const expand = async () => { await act(async () => trigger().click()); }; + const choose = async (value: string) => { await act(async () => option(value).click()); }; + const hover = async (value: string) => { + await act(async () => option(value).dispatchEvent(new Event('pointerover', { bubbles: true }))); + }; + const key = async (value: string) => { + await act(async () => document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { + key: value, bubbles: true, cancelable: true, + }))); + }; + + beforeEach(() => { + onSelect.mockReset().mockResolvedValue(true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('leaves the entire background non-interactive and expands only advertised choices in place', async () => { + await render([preset('medium'), preset('xhigh')]); + expect(container.querySelector('input[type="range"]')).toBeNull(); + const sky = container.querySelector('[data-openbitfun-part="reasoningSliderSky"]')!; + await act(async () => { + sky.dispatchEvent(new Event('pointerdown', { bubbles: true })); + sky.dispatchEvent(new Event('pointerup', { bubbles: true })); + sky.click(); + }); + expect(onSelect).not.toHaveBeenCalled(); + await expand(); + expect(Array.from(choices()!.querySelectorAll('button')).map(button => button.dataset.openbitfunValue)) + .toEqual(['auto', 'preset:medium', 'preset:xhigh']); + expect(document.activeElement).toBe(option('preset:medium')); + expect(container.querySelector('[role="menuitemradio"]')).toBeNull(); + }); + + it('saves once, collapses after success and restores the summary focus', async () => { + await render(); + await expand(); + await choose('preset:high'); + expect(onSelect).toHaveBeenCalledExactlyOnceWith('high'); + expect(choices()).toBeNull(); + expect(document.activeElement).toBe(trigger()); + }); + + it('retains closing choices as inert content and reverses an interrupted collapse', async () => { + await render(); + await expand(); + const panel = choices()!; + const high = option('preset:high'); + + await key('Escape'); + expect(choices()).toBeNull(); + expect(panel.isConnected).toBe(true); + expect(panel.closest('[inert]')?.getAttribute('aria-hidden')).toBe('true'); + expect(high.disabled).toBe(true); + expect(document.activeElement).toBe(trigger()); + await act(async () => high.click()); + expect(onSelect).not.toHaveBeenCalled(); + + await expand(); + expect(choices()).toBe(panel); + expect(high.disabled).toBe(false); + expect(document.activeElement).toBe(option('preset:medium')); + + await key('Escape'); + await act(async () => { await new Promise(resolve => window.setTimeout(resolve, 250)); }); + expect(panel.isConnected).toBe(false); + expect(document.activeElement).toBe(trigger()); + }); + + it('previews the hovered level without selecting it and restores the saved sky on leave', async () => { + await render(); + await expand(); + await hover('preset:xhigh'); + expect(control().dataset.intensity).toBe('4'); + expect(option('preset:medium').getAttribute('aria-pressed')).toBe('true'); + expect(option('preset:xhigh').getAttribute('aria-pressed')).toBe('false'); + await hover('preset:off'); + expect(control().dataset.reasoningState).toBe('off'); + expect(control().querySelector('.openbitfun-reasoning-control__nebula')).not.toBeNull(); + expect(control().querySelector('.openbitfun-reasoning-control__stars')).toBeNull(); + await hover('auto'); + expect(control().dataset.reasoningState).toBe('auto'); + expect(control().querySelector('.openbitfun-reasoning-control__stars')).not.toBeNull(); + await act(async () => option('auto').dispatchEvent(new MouseEvent('pointerout', { + bubbles: true, relatedTarget: container, + }))); + expect(control().dataset.reasoningState).toBe('manual'); + expect(control().dataset.intensity).toBe('2'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('retains nebula without stars for a saved Off choice, including provider toggle aliases', async () => { + const off = { ...preset('off'), id: 'effort-off' }; + await render([off, preset('on')], 'effort-off'); + expect(control().dataset.reasoningState).toBe('off'); + expect(control().querySelector('.openbitfun-reasoning-control__field')).not.toBeNull(); + expect(control().querySelector('.openbitfun-reasoning-control__nebula')).not.toBeNull(); + expect(control().querySelector('.openbitfun-reasoning-control__stars')).toBeNull(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('omits unsupported levels and reserves the peak for the highest available choice', async () => { + await render([preset('medium'), preset('high')]); + await expand(); + expect(option('unavailable:xhigh')).toBeUndefined(); + expect(option('preset:xhigh')).toBeUndefined(); + expect(option('preset:off')).toBeUndefined(); + expect(control().dataset.intensity).toBe('1'); + await key('End'); + expect(document.activeElement).toBe(option('preset:high')); + expect(control().dataset.intensity).toBe('4'); + expect(onSelect).not.toHaveBeenCalled(); + expect(choices()).not.toBeNull(); + }); + + it('uses the provider Extra High id when supported and keeps Maximum distinct', async () => { + const xhigh = { ...preset('xhigh'), id: 'effort-xhigh' }; + await render([preset('medium'), preset('max'), xhigh]); + await expand(); + expect(option('unavailable:xhigh')).toBeUndefined(); + expect(option('preset:effort-xhigh').disabled).toBe(false); + await hover('preset:max'); + expect(control().dataset.intensity).toBe('4'); + await choose('preset:effort-xhigh'); + expect(onSelect).toHaveBeenCalledExactlyOnceWith('effort-xhigh'); + await render([preset('medium'), preset('max')]); + await expand(); + expect(option('preset:max').disabled).toBe(false); + expect(option('unavailable:xhigh')).toBeUndefined(); + }); + + it('retains the choices while saving and after failure so the same choice can be retried', async () => { + let finish!: (saved: boolean) => void; + onSelect.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await render(); + await expand(); + await choose('preset:high'); + expect(Array.from(choices()!.querySelectorAll('button')).every(button => button.disabled)).toBe(true); + await choose('preset:high'); + expect(onSelect).toHaveBeenCalledTimes(1); + await act(async () => finish(false)); + expect(choices()).not.toBeNull(); + expect(document.activeElement).toBe(option('preset:high')); + await choose('preset:high'); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(choices()).toBeNull(); + }); + + it('moves focus horizontally without saving and cancels locally with Escape', async () => { + await render([preset('medium'), preset('xhigh')]); + await expand(); + await key('ArrowRight'); + expect(document.activeElement).toBe(option('preset:xhigh')); + expect(control().dataset.intensity).toBe('4'); + await key('Home'); + expect(document.activeElement).toBe(option('auto')); + expect(control().dataset.reasoningState).toBe('auto'); + expect(onSelect).not.toHaveBeenCalled(); + await key('Escape'); + expect(choices()).toBeNull(); + expect(document.activeElement).toBe(trigger()); + expect(control().dataset.intensity).toBe('1'); + }); + + it('folds enable into Auto for toggle-only models and preserves the enable request', async () => { + const off = { ...preset('off'), id: 'effort-off' }; + const on: ReasoningPresetDescriptor = { ...preset('on'), actions: [{ type: 'toggle', enabled: true }] }; + await render([off, on], 'on'); + await expand(); + expect(option('preset:on')).toBeUndefined(); + expect(option('auto').textContent).toBe('reasoningSelector.auto'); + await choose('preset:effort-off'); + expect(onSelect).toHaveBeenLastCalledWith('effort-off'); + await render([off, on], 'effort-off'); + await expand(); + await choose('auto'); + expect(onSelect).toHaveBeenLastCalledWith('on'); + }); + + it('offers authored presets and ACP Auto as advertised without introducing a reset choice', async () => { + const custom: ReasoningPresetDescriptor = { ...preset('custom'), label: 'Deep analysis', source: 'model_config' }; + await render([preset('auto'), custom], 'custom', false); + await expand(); + expect(option('auto').textContent).toBe('reasoningSelector.auto'); + expect(option('preset:auto')).toBeUndefined(); + expect(option('preset:custom').textContent).toBe('Deep analysis'); + await choose('auto'); + expect(onSelect).toHaveBeenLastCalledWith('auto'); + }); + + it('respects disabled controls and clears expansion when the containing card closes', async () => { + await render(allPresets, 'medium', true, true); + await expand(); + expect(choices()).toBeNull(); + await render(); + await expand(); + await render(allPresets, 'medium', true, false, false); + expect(choices()).toBeNull(); + await render(); + expect(choices()).toBeNull(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('keeps five real effort choices and reserves the fourth material for Maximum', async () => { + await render(['low', 'medium', 'high', 'xhigh', 'max'].map(preset), 'medium'); + await expand(); + expect(Array.from(choices()!.querySelectorAll('button')).map(button => button.dataset.openbitfunValue)) + .toEqual(['auto', 'preset:low', 'preset:medium', 'preset:high', 'preset:xhigh', 'preset:max']); + expect(choices()!.querySelectorAll('[aria-pressed="true"]')).toHaveLength(1); + expect(option('preset:medium').getAttribute('aria-pressed')).toBe('true'); + expect(option('auto').getAttribute('aria-pressed')).toBe('false'); + await hover('preset:high'); + expect(control().dataset.intensity).toBe('3'); + await hover('preset:xhigh'); + expect(control().dataset.intensity).toBe('3'); + await hover('preset:max'); + expect(control().dataset.intensity).toBe('4'); + await choose('preset:xhigh'); + expect(onSelect).toHaveBeenCalledExactlyOnceWith('xhigh'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.tsx b/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.tsx new file mode 100644 index 0000000000..e8f5a6e789 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ReasoningIntensityControl.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { MenuItem, SegmentedControl } from '@openbitfun/ui'; +import { FlowChatCollapse } from '@openbitfun/ui/flow-chat'; +import { useI18n } from '@/infrastructure/i18n'; +import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; +import { isAutomaticReasoningPreset, presetDisplayLabel, presetSemanticKey, reasoningAutomaticValue, reasoningPresetChoices, reasoningSelectionLabel, reasoningVisualTier, resolveReasoningPresetChoice } from './reasoningPresetPresentation'; +import './ReasoningIntensityControl.scss'; + +interface ReasoningIntensityControlProps { + presets: ReasoningPresetDescriptor[]; + selectedPreset?: ReasoningPresetDescriptor; + allowDefaultReset?: boolean; + active?: boolean; + disabled?: boolean; + onSelect: (presetId: string | null) => boolean | Promise; +} + +interface ReasoningChoice { + value: string; + presetId: string | null; + label: string; +} + +const stars = Array.from({ length: 48 }, (_, index) => index); +const reasoningTransitionMs = 180; + +export const ReasoningIntensityControl: React.FC = ({ + presets, + selectedPreset, + allowDefaultReset = true, + active = true, + disabled = false, + onSelect, +}) => { + const { t } = useI18n('flow-chat'); + const skyId = useId(); + const choicesId = useId(); + const [expanded, setExpanded] = useState(false); + const [previewValue, setPreviewValue] = useState(null); + const [pending, setPending] = useState(false); + const [focusRevision, setFocusRevision] = useState(0); + const triggerRef = useRef(null); + const choicesRef = useRef(null); + const pendingRef = useRef(false); + const mountedRef = useRef(true); + const activeRef = useRef(active); + const restoreFocusRef = useRef(false); + const retryFocusRef = useRef(undefined); + activeRef.current = active; + + const selectedChoice = resolveReasoningPresetChoice(selectedPreset, presets); + const label = reasoningSelectionLabel(selectedChoice, t); + const selectedValue = selectedChoice && !isAutomaticReasoningPreset(selectedChoice) ? `preset:${selectedChoice.id}` : 'auto'; + const advertisedPresets = reasoningPresetChoices(presets); + const manualPresets = advertisedPresets.filter(preset => !isAutomaticReasoningPreset(preset)); + const presetChoices = manualPresets.map(preset => ({ + value: `preset:${preset.id}`, + presetId: preset.id, + label: presetDisplayLabel(preset, t), + })); + const automaticValue = reasoningAutomaticValue(advertisedPresets, allowDefaultReset); + const automaticChoices: ReasoningChoice[] = automaticValue !== undefined + ? [{ value: 'auto', presetId: automaticValue, label: t('reasoningSelector.auto') }] : []; + const choices: ReasoningChoice[] = [...automaticChoices, ...presetChoices]; + const previewChoice = expanded ? choices.find(choice => choice.value === previewValue) : undefined; + const visualPreset = previewChoice + ? presets.find(preset => preset.id === previewChoice.presetId) + : selectedChoice; + const visualMeaning = visualPreset && presetSemanticKey(visualPreset); + const visualState = visualMeaning === 'off' ? 'off' + : visualPreset && isAutomaticReasoningPreset(visualPreset) ? 'auto' + : visualPreset ? 'manual' : 'auto'; + const visualTier = reasoningVisualTier(visualPreset, presets); + const locked = disabled || pending || !active; + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + useEffect(() => { + if (active) return; + restoreFocusRef.current = false; + setPreviewValue(null); + setExpanded(false); + }, [active]); + + useLayoutEffect(() => { + if (!active || locked) return; + if (expanded) { + const buttons = Array.from(choicesRef.current?.querySelectorAll('button:not(:disabled)') ?? []); + const preferred = buttons.find(button => button.dataset.openbitfunValue === retryFocusRef.current) + ?? buttons.find(button => button.getAttribute('aria-pressed') === 'true') + ?? buttons[0]; + preferred?.focus({ preventScroll: true }); + retryFocusRef.current = undefined; + } else if (restoreFocusRef.current) { + restoreFocusRef.current = false; + triggerRef.current?.focus({ preventScroll: true }); + } + }, [active, expanded, focusRevision, locked]); + + const collapse = () => { + restoreFocusRef.current = activeRef.current; + setPreviewValue(null); + setExpanded(false); + }; + + const previewTarget = (target: EventTarget) => { + if (!expanded || locked || !(target instanceof Element)) return; + const button = target.closest('button[data-openbitfun-value]'); + if (button && !button.disabled) setPreviewValue(button.dataset.openbitfunValue ?? null); + }; + + const select = async (value: string) => { + const choice = choices.find(item => item.value === value); + if (!expanded || locked || pendingRef.current || !choice) return; + if (value === selectedValue && (choice.presetId ?? undefined) === selectedPreset?.id) { + collapse(); + return; + } + pendingRef.current = true; + retryFocusRef.current = value; + setPending(true); + try { + if (await onSelect(choice.presetId) && mountedRef.current) collapse(); + } finally { + pendingRef.current = false; + if (mountedRef.current) { + setPending(false); + // Fast failures can batch both pending updates into one render. + setFocusRevision(revision => revision + 1); + } + } + }; + + const handleChoiceKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + collapse(); + return; + } + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + const buttons = Array.from(event.currentTarget.querySelectorAll('button:not(:disabled)')); + if (!buttons.length) return; + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'; + const forward = event.key === 'ArrowRight' ? !rtl : rtl; + const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 + : (current + (forward ? 1 : -1) + buttons.length) % buttons.length; + buttons[next]?.focus({ preventScroll: true }); + }; + + return ( +
+
+
+ setExpanded(true)} + onKeyDown={event => { + if (event.key !== 'ArrowRight') return; + event.preventDefault(); + event.stopPropagation(); + if (!locked && choices.length > 0) setExpanded(true); + }} + > + {t('reasoningSelector.thinking')}{' · '}{label} + +
+ +
previewTarget(event.target)} + onPointerLeave={() => setPreviewValue(null)} + onFocus={event => previewTarget(event.target)} + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget)) setPreviewValue(null); + }} + > + 5 ? 'true' : undefined} + style={choices.length > 5 ? { + gridTemplateColumns: `repeat(${Math.min(4, Math.ceil(choices.length / 2))}, minmax(0, 1fr))`, + } : undefined} + aria-label={t('reasoningSelector.title')} + interaction="buttons" + distribution="fill" + tone="neutral" + options={choices} + value={selectedValue} + disabled={locked || !expanded} + onValueChange={value => { void select(value); }} + /> +
+
+
+ +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.scss b/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.scss deleted file mode 100644 index 68ede0b571..0000000000 --- a/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.scss +++ /dev/null @@ -1,281 +0,0 @@ -@use 'sass:list'; -@use 'sass:math'; - -$reasoning-rail-grain: (); -$reasoning-rail-grain-sizes: (); -@for $grain-index from 1 through 16 { - $grain-x: math.div(($grain-index * 37 + $grain-index * $grain-index * 11) % 101, 101) * 100%; - $grain-y: math.div(($grain-index * 53 + $grain-index * $grain-index * 7) % 103, 103) * 100%; - $grain-color: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 28%, transparent); - @if $grain-index % 3 == 0 { - $grain-color: color-mix(in srgb, var(--openbitfun-color-content-on-light) 8%, transparent); - } - $reasoning-rail-grain: list.append( - $reasoning-rail-grain, - radial-gradient(circle at $grain-x $grain-y, $grain-color 0 0.3px, transparent 0.8px), - comma - ); - $reasoning-rail-grain-sizes: list.append($reasoning-rail-grain-sizes, 23px 19px, comma); -} - -.openbitfun-reasoning-slider { - --_reasoning-slider-thumb-size: var(--openbitfun-space-4); - --_reasoning-slider-tint: color-mix(in oklch, var(--openbitfun-color-accent-default) 75%, var(--openbitfun-color-accent-secondary)); - --_reasoning-slider-sky: color-mix(in srgb, var(--_reasoning-slider-tint) 22%, var(--openbitfun-color-content-on-dark)); - --_reasoning-slider-star: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 88%, transparent); - --_reasoning-slider-star-dim: color-mix(in srgb, var(--_reasoning-slider-tint) 58%, transparent); - - display: flex; - flex: 1; - flex-direction: column; - gap: var(--openbitfun-space-2); - min-inline-size: 0; - - // Match MenuItem's label role for the header outside a shared action row. - color: var(--openbitfun-color-action-neutral-content); - font-family: var(--openbitfun-type-label-md-font-family); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-md-font-weight); - line-height: var(--openbitfun-type-action-row-line-height); - letter-spacing: var(--openbitfun-type-label-md-letter-spacing); - - &__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--openbitfun-space-3); - min-inline-size: 0; - min-block-size: var(--openbitfun-overlay-menu-item-height); - } - - &__title { - min-inline-size: 0; - } - - &__value { - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - inline-size: max-content; - min-inline-size: 0; - max-inline-size: 60%; - margin-inline-start: auto; - text-align: center; - } - - &__control { - position: relative; - display: flex; - flex: 1 0 var(--openbitfun-space-6); - align-items: center; - min-inline-size: 0; - min-block-size: var(--openbitfun-space-6); - } - - &__track { - position: absolute; - inset-inline: 0; - inset-block-start: calc(50% - var(--openbitfun-space-5) / 2); - block-size: var(--openbitfun-space-5); - overflow: hidden; - border-radius: var(--openbitfun-radius-pill); - background-color: var(--_reasoning-slider-sky); - background-image: - #{$reasoning-rail-grain}, - radial-gradient(ellipse at 18% 10%, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 36%, transparent), transparent 72%), - linear-gradient(160deg, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 24%, transparent), transparent 54%); - background-size: #{$reasoning-rail-grain-sizes}, auto, auto; - box-shadow: - inset 0 0 0 var(--openbitfun-border-width-default) color-mix(in srgb, var(--openbitfun-color-content-on-dark) 46%, transparent), - inset 0 var(--openbitfun-border-width-default) 0 color-mix(in srgb, var(--openbitfun-color-content-on-dark) 72%, transparent); - pointer-events: none; - - @supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { - background-color: color-mix(in srgb, var(--_reasoning-slider-sky) 80%, transparent); - -webkit-backdrop-filter: var(--openbitfun-effect-blur-medium); - backdrop-filter: var(--openbitfun-effect-blur-medium); - } - - @media (prefers-reduced-transparency: reduce) { - background-color: var(--_reasoning-slider-sky); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } - - &::after { - content: ''; - position: absolute; - inset: 0 calc(-1 * var(--openbitfun-space-6)); - background-image: - radial-gradient(circle at 7% 30%, var(--_reasoning-slider-star) 0 0.5px, transparent 1.2px), - radial-gradient(circle at 18% 72%, var(--_reasoning-slider-star-dim) 0 0.5px, transparent 1.2px), - radial-gradient(circle at 29% 42%, var(--_reasoning-slider-star) 0 0.65px, transparent 1.4px), - radial-gradient(circle at 41% 78%, var(--_reasoning-slider-star-dim) 0 0.5px, transparent 1.2px), - radial-gradient(circle at 53% 24%, var(--_reasoning-slider-star) 0 0.5px, transparent 1.2px), - radial-gradient(circle at 64% 65%, var(--_reasoning-slider-star-dim) 0 0.65px, transparent 1.5px), - radial-gradient(circle at 76% 38%, var(--_reasoning-slider-star) 0 0.4px, transparent 1px), - radial-gradient(circle at 88% 74%, var(--_reasoning-slider-star-dim) 0 0.5px, transparent 1.2px), - radial-gradient(circle at 95% 23%, var(--_reasoning-slider-star) 0 0.4px, transparent 1px), - radial-gradient(ellipse at 36% 50%, color-mix(in srgb, var(--openbitfun-color-content-on-dark) 24%, transparent), transparent 72%); - background-repeat: no-repeat; - opacity: 0.7; - animation: - openbitfun-reasoning-rail-drift 10s ease-in-out -3s infinite alternate, - openbitfun-reasoning-rail-twinkle 3.6s ease-in-out -1.2s infinite; - } - } - - &__fill { - position: absolute; - inset-block: 0; - inset-inline-start: 0; - // Match the native thumb's travel, including its inset at both endpoints. - inline-size: calc( - var(--_reasoning-slider-thumb-size) / 2 - + (100% - var(--_reasoning-slider-thumb-size)) * var(--_reasoning-slider-progress) - ); - // Progress is a light tint over the continuous glass, with the thumb as the main indicator. - background-color: color-mix(in srgb, var(--_reasoning-slider-tint) 14%, transparent); - background-image: linear-gradient(180deg, - color-mix(in srgb, var(--openbitfun-color-content-on-dark) 28%, transparent), - transparent 72%); - } - - &__input { - position: relative; - display: block; - inline-size: 100%; - min-inline-size: 0; - block-size: var(--openbitfun-space-6); - margin: 0; - padding: 0; - box-sizing: border-box; - border: 0; - appearance: none; - background: transparent; - cursor: pointer; - touch-action: pan-y; - - &::-webkit-slider-runnable-track { - block-size: var(--openbitfun-space-5); - border-radius: var(--openbitfun-radius-pill); - background: transparent; - } - - &::-moz-range-track { - block-size: var(--openbitfun-space-5); - border-radius: var(--openbitfun-radius-pill); - background: transparent; - } - - &::-moz-range-progress { - background: transparent; - } - - &::-webkit-slider-thumb { - inline-size: var(--_reasoning-slider-thumb-size); - block-size: var(--_reasoning-slider-thumb-size); - box-sizing: border-box; - margin-block-start: calc((var(--openbitfun-space-5) - var(--_reasoning-slider-thumb-size)) / 2); - border: var(--openbitfun-border-width-default) solid color-mix(in srgb, - var(--_reasoning-slider-tint) 65%, - var(--openbitfun-color-content-on-dark)); - border-radius: var(--openbitfun-radius-pill); - appearance: none; - background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 92%, var(--_reasoning-slider-sky)); - box-shadow: none; - } - - &::-moz-range-thumb { - inline-size: var(--_reasoning-slider-thumb-size); - block-size: var(--_reasoning-slider-thumb-size); - box-sizing: border-box; - border: var(--openbitfun-border-width-default) solid color-mix(in srgb, - var(--_reasoning-slider-tint) 65%, - var(--openbitfun-color-content-on-dark)); - border-radius: var(--openbitfun-radius-pill); - background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 92%, var(--_reasoning-slider-sky)); - box-shadow: none; - } - - &:focus-visible { - outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); - outline-offset: var(--openbitfun-focus-offset); - border-radius: var(--openbitfun-radius-pill); - } - - &:disabled { - cursor: default; - } - } - - &[data-disabled='true'] &__control { - opacity: var(--openbitfun-opacity-disabled); - } - - @supports (color: oklch(from currentColor l c h)) { - // Preserve the mixed accent's blue hue without carrying muted theme chroma. - --_reasoning-slider-tint: oklch( - from color-mix(in oklch, var(--openbitfun-color-accent-default) 75%, var(--openbitfun-color-accent-secondary)) - l max(c, 0.22) h - ); - } - - @media (prefers-reduced-motion: reduce) { - &__track::after { - animation: none; - transform: none; - } - } - - @media (forced-colors: active) { - &__track { - display: none; - } - - &__input { - appearance: auto; - accent-color: Highlight; - - &::-webkit-slider-runnable-track { - background: Canvas; - } - - &::-webkit-slider-thumb { - border-color: Highlight; - background: Highlight; - } - - &::-moz-range-track { - background: Canvas; - } - - &::-moz-range-thumb { - border-color: Highlight; - background: Highlight; - } - } - } -} - -@keyframes openbitfun-reasoning-rail-drift { - from { - transform: translate3d(calc(-1 * var(--openbitfun-space-4)), 0, 0); - } - - to { - transform: translate3d(var(--openbitfun-space-4), 0, 0); - } -} - -@keyframes openbitfun-reasoning-rail-twinkle { - 0%, - 100% { - opacity: 0.35; - } - - 50% { - opacity: 1; - } -} diff --git a/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.test.tsx b/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.test.tsx deleted file mode 100644 index 2d3895301f..0000000000 --- a/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; -import { ReasoningIntensitySlider } from './ReasoningIntensitySlider'; -import { reasoningSliderPresets } from './reasoningPresetPresentation'; - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }), -})); - -const preset = (id: string): ReasoningPresetDescriptor => ({ - id, label: id, order: 0, source: 'models_dev', - actions: id === 'off' ? [{ type: 'toggle', enabled: false }] : [{ type: 'effort', value: id }], -}); -const allPresets = ['off', 'low', 'medium', 'high', 'xhigh'].map(preset); - -describe('ReasoningIntensitySlider', () => { - let container: HTMLDivElement; - let root: Root; - const onSelect = vi.fn(); - const input = () => container.querySelector('input[type="range"]')!; - const render = async (presets = allPresets, selected = 'medium', disabled = false) => { - await act(async () => root.render( - item.id === selected)} - onSelect={onSelect} - disabled={disabled} - />, - )); - }; - const pointer = async (type: 'pointerdown' | 'pointerup' | 'pointercancel') => { - await act(async () => { - input().dispatchEvent(new Event(type, { bubbles: true })); - }); - }; - const move = async (value: number) => { - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), String(value)); - input().dispatchEvent(new Event('input', { bubbles: true })); - }); - }; - const key = async (type: 'keydown' | 'keyup', value: string) => { - await act(async () => { - input().dispatchEvent(new KeyboardEvent(type, { - key: value, bubbles: true, cancelable: true, - })); - }); - }; - - beforeEach(() => { - onSelect.mockReset(); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - it('previews a drag and saves the final supported preset once on release', async () => { - await render(); - await pointer('pointerdown'); - await move(1); - await move(4); - expect(input().value).toBe('4'); - expect(input().getAttribute('aria-valuetext')).toBe('reasoningSelector.levels.xhigh'); - expect(container.textContent).toBe('reasoningSelector.levels.xhigh'); - expect(onSelect).not.toHaveBeenCalled(); - await pointer('pointerup'); - await act(async () => { - input().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); - }); - expect(onSelect).toHaveBeenCalledExactlyOnceWith('xhigh'); - }); - - it('keeps the fixed five levels and skips unsupported positions with the keyboard', async () => { - await render([preset('medium'), preset('xhigh')]); - expect(input().value).toBe('2'); - expect(input().min).toBe('0'); - expect(input().max).toBe('4'); - expect(input().step).toBe('1'); - expect(container.textContent).toBe('reasoningSelector.levels.medium'); - await key('keydown', 'ArrowRight'); - expect(input().value).toBe('4'); - expect(container.textContent).toBe('reasoningSelector.levels.xhigh'); - expect(onSelect).not.toHaveBeenCalled(); - await key('keyup', 'ArrowRight'); - expect(onSelect).toHaveBeenCalledExactlyOnceWith('xhigh'); - await key('keydown', 'Home'); - await key('keyup', 'Home'); - // Off is unavailable, so Home stays at Medium instead of clearing to Auto. - expect(input().value).toBe('2'); - expect(onSelect).toHaveBeenCalledTimes(1); - }); - - it('submits the advertised off preset rather than resetting to automatic', async () => { - const off = { ...preset('off'), id: 'effort-off' }; - await render([off, preset('high')], 'high'); - await key('keydown', 'Home'); - await key('keyup', 'Home'); - expect(onSelect).toHaveBeenCalledExactlyOnceWith('effort-off'); - }); - - it('snaps a pointer selection to a supported level', async () => { - await render([preset('low'), preset('xhigh')], 'low'); - await pointer('pointerdown'); - await move(3); - expect(input().value).toBe('4'); - await pointer('pointerup'); - expect(onSelect).toHaveBeenCalledExactlyOnceWith('xhigh'); - }); - - it('discards cancelled gestures and stale drafts after a catalog refresh', async () => { - await render(); - await pointer('pointerdown'); - await move(4); - await pointer('pointercancel'); - expect(input().value).toBe('2'); - await pointer('pointerup'); - await key('keydown', 'ArrowRight'); - await key('keydown', 'Escape'); - await key('keyup', 'ArrowRight'); - expect(input().value).toBe('2'); - await pointer('pointerdown'); - await move(4); - await render([preset('medium'), preset('high')]); - await pointer('pointerup'); - expect(input().value).toBe('2'); - expect(onSelect).not.toHaveBeenCalled(); - }); - - it('supports changes without pointer events and respects a disabled control', async () => { - await render(); - await move(3); - expect(onSelect).toHaveBeenCalledExactlyOnceWith('high'); - await render(allPresets, 'medium', true); - await key('keydown', 'End'); - await key('keyup', 'End'); - expect(input().disabled).toBe(true); - expect(onSelect).toHaveBeenCalledTimes(1); - }); - - it('shows toggle-only reasoning as Off/Low while preserving its advertised ids', async () => { - const on: ReasoningPresetDescriptor = { - ...preset('on'), actions: [{ type: 'toggle', enabled: true }], - }; - await render([preset('off'), on], 'on'); - expect(input().value).toBe('1'); - expect(input().getAttribute('aria-valuetext')).toBe('reasoningSelector.levels.low'); - await render([preset('off'), on], 'off'); - await key('keydown', 'ArrowRight'); - await key('keyup', 'ArrowRight'); - expect(onSelect).toHaveBeenCalledExactlyOnceWith('on'); - - await render([preset('off'), on, preset('low'), preset('high')], 'on'); - expect(input().value).toBe('1'); - expect(input().getAttribute('aria-valuetext')).toBe('reasoningSelector.levels.low'); - await render([preset('off'), on, preset('low'), preset('high')], 'off'); - await key('keydown', 'ArrowRight'); - await key('keyup', 'ArrowRight'); - expect(onSelect).toHaveBeenLastCalledWith('low'); - }); - - it('preserves catalog semantics instead of inventing levels for custom presets', async () => { - const custom: ReasoningPresetDescriptor = { - ...preset('custom'), source: 'model_config', actions: [{ type: 'effort', value: 'high' }], - }; - expect(reasoningSliderPresets([preset('medium'), preset('high')]).map(item => item?.id)) - .toEqual([undefined, undefined, 'medium', 'high', undefined]); - expect(reasoningSliderPresets([preset('minimal'), preset('max')]).map(item => item?.id)) - .toEqual([undefined, 'minimal', undefined, undefined, 'max']); - await render([preset('off'), custom], custom.id); - expect(input()).toBeNull(); - const openNamedPicker = vi.fn(); - await act(async () => root.render( - } - />, - )); - expect(input()).toBeNull(); - const namedChoice = container.querySelector('button')!; - expect(namedChoice.textContent).toBe('reasoningEffort.custom'); - await act(async () => namedChoice.click()); - expect(openNamedPicker).toHaveBeenCalledOnce(); - expect(onSelect).not.toHaveBeenCalled(); - }); -}); diff --git a/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.tsx b/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.tsx deleted file mode 100644 index 8ff4981c98..0000000000 --- a/src/web-ui/src/flow_chat/components/ReasoningIntensitySlider.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useI18n } from '@/infrastructure/i18n'; -import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; -import { presetDisplayLabel, reasoningSliderPresets, resolveReasoningPresetChoice } from './reasoningPresetPresentation'; -import './ReasoningIntensitySlider.scss'; - -interface ReasoningIntensitySliderProps { - presets: ReasoningPresetDescriptor[]; - selectedPreset?: ReasoningPresetDescriptor; - title?: string; - headerStart?: React.ReactNode; - disabled?: boolean; - onSelect: (presetId: string) => void | Promise; - renderValue?: (label: string) => React.ReactNode; -} - -const adjustmentKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']); - -export const ReasoningIntensitySlider: React.FC = ({ - presets, - selectedPreset, - title, - headerStart, - disabled = false, - onSelect, - renderValue, -}) => { - const { t } = useI18n('flow-chat'); - const stops = reasoningSliderPresets(presets); - const supported = stops.flatMap((preset, index) => preset ? [index] : []); - const selectedChoice = resolveReasoningPresetChoice(selectedPreset, presets); - const selectedIndex = stops.findIndex(preset => preset && preset.id === selectedChoice?.id); - const catalogKey = stops.map(preset => preset?.id ?? '').join('\0'); - const [draft, setDraft] = useState(null); - const draftRef = useRef(null); - const pointerActiveRef = useRef(false); - - useEffect(() => { - draftRef.current = null; - setDraft(null); - }, [catalogKey, selectedPreset?.id]); - - // Keep custom presets reachable through their named picker. - if (selectedIndex < 0 && !renderValue) return null; - - const value = draft ?? selectedIndex; - const displayedPreset = stops[value] ?? selectedPreset; - const label = displayedPreset ? presetDisplayLabel(displayedPreset, t) : t('reasoningSelector.auto'); - const locked = disabled || supported.length < 2; - - const cancel = () => { - draftRef.current = null; - setDraft(null); - }; - - const preview = (requested: number) => { - if (locked) return; - const previous = draftRef.current ?? selectedIndex; - const nearest = supported.reduce((best, candidate) => { - const distance = Math.abs(candidate - requested); - const bestDistance = Math.abs(best - requested); - if (distance < bestDistance) return candidate; - if (distance === bestDistance) return requested > previous ? Math.max(best, candidate) : Math.min(best, candidate); - return best; - }, supported[0]); - draftRef.current = nearest; - setDraft(nearest); - }; - - const commit = async () => { - const next = draftRef.current; - draftRef.current = null; - if (locked || next === null || next === selectedIndex || !stops[next]) { - setDraft(null); - return; - } - try { - await onSelect(stops[next].id); - } finally { - setDraft(null); - } - }; - - return ( -
= 0 && locked) ? 'true' : undefined} - style={{ '--_reasoning-slider-progress': Math.max(0, value) / 4 } as React.CSSProperties} - > -
- {headerStart ?? (title && {title})} -
- {renderValue ? renderValue(label) : label} -
-
-
-
-
- ); -}; diff --git a/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.test.tsx b/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.test.tsx index 26184e57e5..addcb19c4d 100644 --- a/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.test.tsx +++ b/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.test.tsx @@ -231,7 +231,7 @@ describe('ReasoningPresetSelector', () => { expect(options[2]?.getAttribute('aria-checked')).toBe('true'); }); - it('merges On into Low and restores an older enabled selection without writing it back', async () => { + it('presents an older enabled selection as Auto without rewriting it', async () => { const onSelect = vi.fn(); await act(async () => { root.render( @@ -275,8 +275,9 @@ describe('ReasoningPresetSelector', () => { const trigger = container.querySelector( '[data-testid="chat-reasoning-preset-selector-btn"]', ); - expect(trigger?.getAttribute('aria-label')).toBe('Thinking: Low'); - expect(options.find(option => option.dataset.presetId === 'low')?.getAttribute('aria-checked')).toBe('true'); + expect(trigger?.getAttribute('aria-label')).toBe('Thinking: Auto'); + expect(options.find(option => option.dataset.presetId === 'low')?.getAttribute('aria-checked')).toBe('false'); + expect(document.body.querySelector('.openbitfun-reasoning-preset-selector__auto-row [aria-checked="true"]')).not.toBeNull(); expect(onSelect).not.toHaveBeenCalled(); for (const [label, presetId] of [['Low', 'low'], ['Off', 'off']] as const) { @@ -293,7 +294,7 @@ describe('ReasoningPresetSelector', () => { } expect(zhCnFlowChat.reasoningSelector.levels) - .toMatchObject({ off: '关闭', low: '轻', medium: '中', high: '高', max: '最高' }); + .toMatchObject({ off: '关闭', low: '轻', medium: '中', high: '高', max: '最大' }); }); it('keeps the reasoning menu open and its focused choice available after selection', async () => { diff --git a/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx b/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx index fbe2a1de1c..aab44446b6 100644 --- a/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx @@ -6,7 +6,7 @@ import { RetainedMountBoundary } from '@/shared/presence'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import type { ReasoningCatalogProjection } from '@/infrastructure/config/types'; import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition'; -import { presetLabel, presetDisplayLabel, reasoningPresetChoices, resolveReasoningPresetChoice } from './reasoningPresetPresentation'; +import { isAutomaticReasoningPreset, presetLabel, presetDisplayLabel, reasoningAutomaticValue, reasoningPresetChoices, reasoningSelectionLabel, resolveReasoningPresetChoice } from './reasoningPresetPresentation'; import './ReasoningPresetSelector.scss'; interface ReasoningPresetSelectorProps { @@ -104,24 +104,21 @@ export const ReasoningPresetSelector: React.FC = ( if (presets.length === 0) return null; - const orderedPresets = [...reasoningPresetChoices(presets)].sort((left, right) => left.order - right.order); + const orderedPresets = reasoningPresetChoices(presets).filter(preset => !isAutomaticReasoningPreset(preset)); + const automaticValue = reasoningAutomaticValue(presets, true) ?? null; + const automatic = !selected || isAutomaticReasoningPreset(selected); const presetLabels = orderedPresets.map(preset => ( presetDisplayLabel(preset, t) )); - const currentLabel = selected - ? presetLabel(selected, t) - : t('reasoningSelector.auto'); const effectivePreset = selected ?? defaultPreset; - const statusLabel = effectivePreset - ? presetDisplayLabel(effectivePreset, t) - : currentLabel; + const statusLabel = reasoningSelectionLabel(selected, t); const showLabel = triggerPresentation === 'label' || Boolean(selected); // Preserve the advertised preset's meaning in the text and accessible name. const tooltip = selected ? t('reasoningSelector.current', { preset: statusLabel }) : t('reasoningSelector.currentAuto', { - preset: effectivePreset ? statusLabel : t('reasoningSelector.modelDefault'), + preset: effectivePreset ? presetDisplayLabel(effectivePreset, t) : t('reasoningSelector.modelDefault'), }); return ( @@ -210,12 +207,12 @@ export const ReasoningPresetSelector: React.FC = ( select(null)} + data-openbitfun-state={automatic ? 'selected' : undefined} + onClick={() => select(automaticValue)} > {t('reasoningSelector.auto')} diff --git a/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.test.ts b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.test.ts new file mode 100644 index 0000000000..94ec46ef87 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; +import { + presetDisplayLabel, reasoningAutomaticValue, reasoningPresetChoices, + reasoningVisualTier, resolveReasoningPresetChoice, +} from './reasoningPresetPresentation'; + +const preset = (value: string, order = 0): ReasoningPresetDescriptor => ({ + id: value, label: value, order, source: 'models_dev', + actions: [{ type: 'effort', value }], +}); + +describe('reasoning preset presentation', () => { + it.each([ + [['high', 'max'], [1, 4]], + [['low', 'high', 'max'], [1, 3, 4]], + [['low', 'medium', 'xhigh'], [1, 3, 4]], + [['minimal', 'low', 'medium', 'high'], [1, 2, 3, 4]], + [['low', 'medium', 'high', 'xhigh', 'max'], [1, 2, 3, 3, 4]], + ])('maps advertised levels %j to four materials with an exclusive peak', (levels, expected) => { + const presets = (levels as string[]).map(preset); + expect(presets.map(item => reasoningVisualTier(item, presets))).toEqual(expected); + expect(reasoningPresetChoices(presets)).toHaveLength(levels.length); + }); + + it('does not rank automatic, off or toggle values as reasoning levels', () => { + const on: ReasoningPresetDescriptor = { ...preset('on'), actions: [{ type: 'toggle', enabled: true }] }; + const presets = [preset('auto'), preset('none'), on, preset('low'), preset('high')]; + expect(presets.map(item => reasoningVisualTier(item, presets))).toEqual([1, 1, 1, 1, 4]); + expect(reasoningAutomaticValue(presets, false)).toBe('auto'); + expect(reasoningAutomaticValue([on, preset('none')], true)).toBe('on'); + expect(reasoningAutomaticValue([on, preset('low'), preset('high')], true)).toBeNull(); + expect(reasoningAutomaticValue([preset('low'), preset('high')], false)).toBeUndefined(); + }); + + it('groups only adapter-confirmed aliases and keeps a legacy selection readable', () => { + const presets = ['low', 'medium', 'high', 'xhigh', 'max'].map((value, index) => ({ + ...preset(value, index), effective_effort: index < 3 ? 'high' : 'max', + })); + expect(reasoningPresetChoices(presets).map(item => item.id)).toEqual(['high', 'max']); + expect(resolveReasoningPresetChoice(presets[0], presets)?.id).toBe('high'); + expect(presets[0].actions).toEqual([{ type: 'effort', value: 'low' }]); + expect(reasoningPresetChoices(presets.map(({ effective_effort: _, ...item }) => item))).toHaveLength(5); + }); + + it('preserves authored labels and compares budgets by their actual token amounts', () => { + const custom: ReasoningPresetDescriptor = { ...preset('high'), label: 'Careful', source: 'model_config' }; + expect(presetDisplayLabel(custom, key => key)).toBe('Careful'); + const budgets = [8192, 1024, 4096].map((value, order): ReasoningPresetDescriptor => ({ + ...preset(`budget-${value}`, order), actions: [{ type: 'budget_tokens', value }], + })); + expect(budgets.map(item => reasoningVisualTier(item, budgets))).toEqual([4, 1, 3]); + const patch: ReasoningPresetDescriptor = { ...preset('max'), actions: [{ type: 'request_patch', body: { custom: true } }] }; + expect(reasoningVisualTier(patch, [patch])).toBe(1); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts index adfe2a84c2..3b1a564a71 100644 --- a/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts +++ b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts @@ -1,113 +1,125 @@ import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; type Translate = (key: string, options?: Record) => string; +const effortLevels = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const; +type ReasoningPresetSemanticKey = typeof effortLevels[number] | 'off' | 'on' | 'auto' | 'adaptive'; -export function presetLabel( - preset: ReasoningPresetDescriptor, - t: Translate, -): string { +function semanticKey(value: string): ReasoningPresetSemanticKey | undefined { + const normalized = value.trim().toLowerCase(); + if (normalized === 'none') return 'off'; + return [...effortLevels, 'off', 'on', 'auto', 'adaptive'].includes(normalized) + ? normalized as ReasoningPresetSemanticKey : undefined; +} + +export function presetLabel(preset: ReasoningPresetDescriptor, t: Translate): string { + if (preset.source === 'model_config') return preset.label || preset.id; return t(`reasoningEffort.${preset.id}`, { defaultValue: preset.label || preset.id }); } -export function presetDisplayLabel( - preset: ReasoningPresetDescriptor, - t: Translate, -): string { +export function presetDisplayLabel(preset: ReasoningPresetDescriptor, t: Translate): string { const fallback = presetLabel(preset, t); - const semanticKey = presetSemanticKey(preset); - return semanticKey - ? t(`reasoningSelector.levels.${semanticKey === 'on' ? 'low' : semanticKey}`, { defaultValue: fallback }) - : fallback; + if (isAutomaticReasoningPreset(preset)) return t('reasoningSelector.auto'); + if (preset.source === 'model_config') return fallback; + const meaning = presetSemanticKey(preset); + return meaning ? t(`reasoningSelector.levels.${meaning}`, { defaultValue: fallback }) : fallback; } -type ReasoningPresetSemanticKey = - | 'off' - | 'on' - | 'minimal' - | 'low' - | 'medium' - | 'high' - | 'xhigh' - | 'max'; - -const REASONING_PRESET_SEMANTIC_KEYS = new Set([ - 'off', - 'on', - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', -]); - -function asReasoningPresetSemanticKey(value: string): ReasoningPresetSemanticKey | undefined { - const normalized = value.trim().toLowerCase(); - if (normalized === 'none') return 'off'; - return REASONING_PRESET_SEMANTIC_KEYS.has(normalized as ReasoningPresetSemanticKey) - ? normalized as ReasoningPresetSemanticKey - : undefined; +export function presetSemanticKey(preset: ReasoningPresetDescriptor): ReasoningPresetSemanticKey | undefined { + if (preset.effective_effort) return semanticKey(preset.effective_effort); + // Actions own meaning. Authored names never turn a budget or patch into an effort level. + if (preset.actions.some(action => action.type === 'request_patch')) return undefined; + const toggle = preset.actions.find(action => action.type === 'toggle'); + if (toggle?.type === 'toggle' && !toggle.enabled) return 'off'; + const effort = preset.actions.find(action => action.type === 'effort'); + if (effort?.type === 'effort') return semanticKey(effort.value); + if (preset.actions.some(action => action.type === 'budget_tokens')) return undefined; + if (toggle?.type === 'toggle') return 'on'; + return preset.source === 'model_config' ? undefined : semanticKey(preset.id); } -function presetSemanticKey( - preset: ReasoningPresetDescriptor, -): ReasoningPresetSemanticKey | undefined { - const idKey = asReasoningPresetSemanticKey(preset.id); - if (idKey) return idKey; +export function reasoningPresetKind(preset: ReasoningPresetDescriptor): 'level' | 'budget' | 'special' | 'custom' { + const meaning = presetSemanticKey(preset); + if (meaning === 'off' || meaning === 'on' || meaning === 'auto' || meaning === 'adaptive') return 'special'; + if (preset.actions.some(action => action.type === 'request_patch')) return 'custom'; + if (preset.actions.some(action => action.type === 'effort')) return 'level'; + if (preset.actions.some(action => action.type === 'budget_tokens')) return 'budget'; + return meaning ? 'level' : 'custom'; +} - // Generated presets can prefix or replace their semantic id (for example - // effort-off and budget-max). Their action is the stable meaning to localize. - // Custom presets keep their authored label instead of being renamed by an - // implementation detail in their request actions. - if (preset.source === 'model_config') return undefined; +function equivalenceKey(preset: ReasoningPresetDescriptor): string { + // Only the executing adapter may declare provider aliases equivalent. + // Authored presets may combine effort with other actions and keep their names. + if (preset.source !== 'model_config' && presetSemanticKey(preset) === 'off') return 'special:off'; + return preset.source !== 'model_config' && preset.effective_effort + ? `effort:${preset.effective_effort}` : `preset:${preset.id}`; +} - for (const action of preset.actions) { - if (action.type === 'toggle') return action.enabled ? 'on' : 'off'; - if (action.type === 'effort') { - const effortKey = asReasoningPresetSemanticKey(action.value); - if (effortKey) return effortKey; - } - if (action.type === 'budget_tokens') { - if (preset.id.toLowerCase().includes('max')) return 'max'; - if (preset.id.toLowerCase().includes('high')) return 'high'; - } - } +export function isAutomaticReasoningPreset(preset: ReasoningPresetDescriptor): boolean { + const meaning = presetSemanticKey(preset); + return (meaning === 'on' || meaning === 'auto' || meaning === 'adaptive') + && (preset.source !== 'model_config' || ['on', 'auto', 'adaptive'].includes(preset.id)); +} - return undefined; +/** One Auto choice; the execution channel owns its concrete request. */ +export function reasoningAutomaticValue( + presets: ReasoningPresetDescriptor[], + allowDefaultReset: boolean, +): string | null | undefined { + const automatic = presets.filter(isAutomaticReasoningPreset); + const explicit = automatic.find(preset => presetSemanticKey(preset) !== 'on'); + if (explicit) return explicit.id; + // Toggle-only models need an explicit enable action to leave Off. + const hasIntensity = presets.some(preset => ['level', 'budget'].includes(reasoningPresetKind(preset))); + if ((!hasIntensity || !allowDefaultReset) && automatic.length > 0) return automatic[0].id; + return allowDefaultReset ? null : undefined; } -/** Collapse the enabled toggle into Low without changing persisted or wire ids. */ export function reasoningPresetChoices(presets: ReasoningPresetDescriptor[]): ReasoningPresetDescriptor[] { - return presets.some(preset => presetSemanticKey(preset) === 'low') - ? presets.filter(preset => presetSemanticKey(preset) !== 'on') - : presets; + const choices = new Map(); + for (const preset of [...presets].sort((left, right) => left.order - right.order)) { + const key = equivalenceKey(preset); + const existing = choices.get(key); + const canonical = preset.actions.some(action => action.type === 'effort' && action.value === preset.effective_effort); + if (!existing || canonical) choices.set(key, preset); + } + return [...choices.values()]; } export function resolveReasoningPresetChoice( preset: ReasoningPresetDescriptor | undefined, presets: ReasoningPresetDescriptor[], ): ReasoningPresetDescriptor | undefined { - return preset && presetSemanticKey(preset) === 'on' - ? presets.find(candidate => presetSemanticKey(candidate) === 'low') ?? preset - : preset; + return preset && (reasoningPresetChoices(presets).find(candidate => equivalenceKey(candidate) === equivalenceKey(preset)) ?? preset); } -/** Fixed slider positions; use the advertised enabled toggle when no Low effort exists. */ -export function reasoningSliderPresets( +export function reasoningSelectionLabel( + selected: ReasoningPresetDescriptor | undefined, + t: Translate, +): string { + return selected ? presetDisplayLabel(selected, t) : t('reasoningSelector.auto'); +} + +/** Four materials, with the peak reserved for the highest comparable level. */ +export function reasoningVisualTier( + preset: ReasoningPresetDescriptor | undefined, presets: ReasoningPresetDescriptor[], -): Array { - const byMeaning = new Map(); - for (const preset of presets) { - const meaning = presetSemanticKey(preset); - if (meaning && !byMeaning.has(meaning)) byMeaning.set(meaning, preset); - } - // Custom presets retain their named picker. Toggle-only models expose Off/Low - // while still sending the original off/on ids to their provider. - return [ - byMeaning.get('off'), - byMeaning.get('low') ?? byMeaning.get('on') ?? byMeaning.get('minimal'), - byMeaning.get('medium'), - byMeaning.get('high'), - byMeaning.get('xhigh') ?? byMeaning.get('max'), - ]; +): 1 | 2 | 3 | 4 { + if (!preset) return 1; + const kind = reasoningPresetKind(preset); + if (kind !== 'level' && kind !== 'budget') return 1; + const candidates = reasoningPresetChoices(presets).filter(candidate => reasoningPresetKind(candidate) === kind); + const rank = (candidate: ReasoningPresetDescriptor): number => { + if (kind === 'budget') { + const budget = candidate.actions.find(action => action.type === 'budget_tokens'); + return budget?.type === 'budget_tokens' ? budget.value : candidate.order; + } + const index = effortLevels.findIndex(level => level === presetSemanticKey(candidate)); + return index >= 0 ? index : candidate.order; + }; + const ranks = [...new Set(candidates.map(rank))].sort((left, right) => left - right); + const index = ranks.indexOf(rank(preset)); + if (index < 0) return 1; + if (index === ranks.length - 1) return 4; + // Long catalogs may share materials, but only their highest rank gets tier 4. + return Math.min(3, 1 + Math.round(index * 3 / (ranks.length - 1))) as 1 | 2 | 3; } diff --git a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.scss b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.scss index ee3fdcea9a..0b5c66b1c9 100644 --- a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.scss @@ -1,12 +1,25 @@ .default-model-config { padding: 0; - &__optional-label { - margin-left: 4px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - font-weight: var(--openbitfun-type-body-sm-font-weight); - opacity: 0.75; + &--compact { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: var(--openbitfun-space-4); + + > .openbitfun-config-page-row { + min-width: 0; + padding: 0; + border: 0; + align-content: start; + background: transparent; + } + } + + &__slot-label-content { + display: inline-flex; + align-items: center; + gap: var(--openbitfun-space-1); + min-width: 0; } diff --git a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx index a76bb1b8a4..702d8fddd4 100644 --- a/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/DefaultModelConfig.tsx @@ -1,8 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Layers } from 'lucide-react'; -import { Combobox } from '@openbitfun/ui'; -import { Spinner } from '@openbitfun/ui'; +import { Combobox, Icon, IconButton, Spinner, Tooltip } from '@openbitfun/ui'; import { notificationService } from '@/shared/notification-system'; import { configManager } from '../services/ConfigManager'; import type { @@ -25,14 +24,21 @@ const normalizeSelectValue = (value: string | number | (string | number)[]): str type DefaultModelSlot = 'primary' | 'fast' | 'image_understanding' | 'speech_recognition'; -export const DefaultModelConfig: React.FC = () => { +export const DefaultModelConfig: React.FC<{ compact?: boolean }> = ({ compact = false }) => { const { t } = useTranslation('settings/default-model'); const { buildModelOption } = useModelSelectPresentation(); - const renderOptionalLabel = (text: string) => ( - <> - {text} - ({t('core.optional')}) - + const renderSlotLabel = (label: string, description: string) => ( + + {label} + + } + /> + + ); @@ -182,11 +188,11 @@ export const DefaultModelConfig: React.FC = () => { } return ( -
+
{ { { { it('marks the primary model slot as required in both label and control semantics', () => { expect(defaultModelSource).toMatch( - /label=\{t\('core\.primary\.label'\)\}[\s\S]*?description=\{t\('core\.primary\.description'\)\}[\s\S]*?required[\s\S]*? void; +} + +export default function ModelTagsField({ tags, recommendedTags, disabled, onChange }: ModelTagsFieldProps) { + const { t } = useI18n('settings/models'); + const recommendations = normalizeModelTags(recommendedTags); + const atTagLimit = tags.length >= MAX_MODEL_USER_TAGS; + + return ( +
+ + ({ value: tag, label: tag }))} + maxVisibleTags={Math.max(MAX_MODEL_USER_TAGS, tags.length)} + onCreateValue={atTagLimit ? undefined : value => value} + onValueChange={values => { + const nextTags = normalizeModelTags(values); + if (nextTags.length > MAX_MODEL_USER_TAGS + && nextTags.some(tag => !tags.includes(tag))) return; + onChange(nextTags); + }} + placeholder={t('pool.tagsPlaceholder')} disabled={disabled} /> + + {recommendations.length > 0 && ( + +
+ {recommendations.map(tag => {tag})} +
+
+ )} +
+ ); +} diff --git a/src/web-ui/src/infrastructure/config/components/modelConnectionTestPlan.test.ts b/src/web-ui/src/infrastructure/config/components/modelConnectionTestPlan.test.ts index 2e63bc4772..203f2a2054 100644 --- a/src/web-ui/src/infrastructure/config/components/modelConnectionTestPlan.test.ts +++ b/src/web-ui/src/infrastructure/config/components/modelConnectionTestPlan.test.ts @@ -51,6 +51,7 @@ describe('configsNeedingAutoTest', () => { ...previous[0], context_window: 200000, max_tokens: 32000, + metadata: { user_tags: ['planning', 'execution'] }, reasoning: { catalog: { source: 'auto' as const }, presets: [], @@ -58,6 +59,7 @@ describe('configsNeedingAutoTest', () => { }]; expect(configsNeedingAutoTest(previous, next, true)).toEqual([]); + expect(configsNeedingAutoTest(previous, next, false)).toEqual([]); }); it('retests a model when its image probe requirement changes', () => { diff --git a/src/web-ui/src/infrastructure/config/services/modelTags.test.ts b/src/web-ui/src/infrastructure/config/services/modelTags.test.ts new file mode 100644 index 0000000000..80bd139cc5 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/modelTags.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import type { AIModelConfig } from '../types'; +import { + getModelTags, + getModelUserTags, + normalizeModelTags, + preserveModelAnnotations, + withModelUserTags, +} from './modelTags'; + +const model = (overrides: Partial = {}): AIModelConfig => ({ + id: 'model-a', + name: 'Private provider', + model_name: 'model-a', + provider: 'openai', + base_url: 'https://example.invalid/v1', + api_key: 'test-key', + enabled: true, + category: 'general_chat', + capabilities: ['text_chat', 'function_calling'], + ...overrides, +}); + +describe('model annotations', () => { + it('reads legacy configs without tags and tolerates unknown annotation shapes', () => { + expect(getModelUserTags(model())).toEqual([]); + expect(getModelTags(model({ recommended_for: ['planning'] }))).toEqual(['planning']); + expect(getModelUserTags(model({ metadata: { user_tags: 'old external value' } }))).toEqual([]); + expect(normalizeModelTags([' planning ', null, 'planning', '', 1, 'execution'])) + .toEqual(['planning', 'execution']); + }); + + it('round-trips a legacy config while only adding user annotations', () => { + const legacy = model({ + metadata: { provider_instance_id: 'connection-a', external: { version: 3 } }, + recommended_for: ['planning'], + auth: { type: 'subscription', provider: 'codex' }, + custom_headers: { 'x-workspace': 'private' }, + }); + const saved = JSON.parse(JSON.stringify(withModelUserTags(legacy, ['fast', 'fast', ' planning ']))); + expect(saved).toEqual({ + ...legacy, + metadata: { ...legacy.metadata, user_tags: ['fast', 'planning'] }, + }); + expect(getModelTags(saved)).toEqual(['fast', 'planning']); + expect(legacy.metadata).not.toHaveProperty('user_tags'); + }); + + it('does not copy the first model annotations to siblings during connection editing', () => { + const first = model({ metadata: { provider_instance_id: 'connection-a', user_tags: ['reasoning'] }, recommended_for: ['code'] }); + const sibling = model({ id: 'model-b', metadata: { provider_instance_id: 'connection-a', user_tags: ['quick'] }, recommended_for: ['titles'] }); + const editedSibling = { ...first, id: sibling.id, base_url: 'https://example.invalid/updated' }; + const savedSibling = preserveModelAnnotations(sibling, editedSibling); + expect(savedSibling.base_url).toBe(editedSibling.base_url); + expect(savedSibling.metadata).toEqual({ provider_instance_id: 'connection-a', user_tags: ['quick'] }); + expect(savedSibling.recommended_for).toEqual(['titles']); + const untaggedSibling = preserveModelAnnotations(model(), editedSibling); + expect(getModelUserTags(untaggedSibling)).toEqual([]); + expect(untaggedSibling.recommended_for).toEqual([]); + }); + + it('supports three custom tags independently of recommended tags and ignores duplicates', () => { + const current = model({ recommended_for: ['vision', 'fast'] }); + const saved = withModelUserTags(current, [' reasoning ', 'planning', 'execution', 'reasoning']); + expect(getModelUserTags(saved)).toEqual(['reasoning', 'planning', 'execution']); + expect(saved.recommended_for).toEqual(['vision', 'fast']); + expect(() => withModelUserTags(saved, ['reasoning', 'planning', 'execution', 'review'])) + .toThrow('up to 3 custom tags'); + expect(getModelUserTags(saved)).toEqual(['reasoning', 'planning', 'execution']); + }); + + it('preserves older tag lists above the limit and allows removing tags without truncation', () => { + const tags = ['reasoning', 'planning', 'execution', 'review', 'quick']; + const legacy = model({ metadata: { user_tags: tags } }); + const roundTrip = JSON.parse(JSON.stringify(withModelUserTags(legacy, tags))); + expect(getModelUserTags(roundTrip)).toEqual(tags); + expect(getModelUserTags(withModelUserTags(legacy, tags.slice(0, 4)))).toEqual(tags.slice(0, 4)); + expect(() => withModelUserTags(legacy, [...tags, 'vision'])).toThrow('up to 3 custom tags'); + expect(getModelUserTags(withModelUserTags(legacy, ['reasoning', 'planning', 'vision']))) + .toEqual(['reasoning', 'planning', 'vision']); + }); + + it('preserves freshly saved or cleared tags instead of a stale editor snapshot', () => { + const stale = model({ metadata: { user_tags: ['old'] }, recommended_for: ['old'] }); + const current = model({ metadata: { user_tags: ['new'] }, recommended_for: ['new'] }); + expect(getModelTags(preserveModelAnnotations(current, stale))).toEqual(['new']); + expect(getModelUserTags(preserveModelAnnotations(withModelUserTags(current, []), stale))).toEqual([]); + expect(getModelTags(preserveModelAnnotations(undefined, stale))).toEqual([]); + }); + + it('saves explicit tag edits with model parameters while preserving recommended tags', () => { + const current = model({ metadata: { user_tags: ['old'] }, recommended_for: ['vision'] }); + const draft = { ...current, context_window: 256000, recommended_for: ['stale'] }; + const saved = preserveModelAnnotations(current, draft, ['planning', 'execution']); + expect(saved.context_window).toBe(256000); + expect(getModelUserTags(saved)).toEqual(['planning', 'execution']); + expect(saved.recommended_for).toEqual(['vision']); + expect(getModelUserTags(preserveModelAnnotations(saved, draft, []))).toEqual([]); + expect(getModelUserTags(current)).toEqual(['old']); + expect(() => preserveModelAnnotations(current, draft, ['a', 'b', 'c', 'd'])) + .toThrow('up to 3 custom tags'); + }); + + it('adds tags to a new model without inheriting provider-template annotations', () => { + const template = model({ metadata: { user_tags: ['other-model'] }, recommended_for: ['other-use'] }); + const saved = preserveModelAnnotations(undefined, template, ['planning']); + expect(getModelUserTags(saved)).toEqual(['planning']); + expect(saved.recommended_for).toEqual([]); + }); + + it('leaves unknown stored tag values intact when only editing a connection', () => { + const current = model({ metadata: { user_tags: { future: true } } }); + const saved = preserveModelAnnotations(current, model({ metadata: { user_tags: ['stale'] } })); + expect(saved.metadata?.user_tags).toEqual({ future: true }); + }); + + it('refuses to silently overwrite unsupported metadata', () => { + const unsupported = model({ metadata: ['external format'] as unknown as Record }); + expect(() => withModelUserTags(unsupported, ['planning'])).toThrow('unsupported metadata'); + expect(() => preserveModelAnnotations(unsupported, model())).toThrow('unsupported metadata'); + expect(() => preserveModelAnnotations(model(), unsupported)).toThrow('unsupported metadata'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/modelTags.ts b/src/web-ui/src/infrastructure/config/services/modelTags.ts new file mode 100644 index 0000000000..5d14dd93e1 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/modelTags.ts @@ -0,0 +1,66 @@ +import type { AIModelConfig } from '../types'; + +const USER_TAGS_KEY = 'user_tags'; +export const MAX_MODEL_USER_TAGS = 3; + +function checkedMetadata(model: Pick): Record { + if (model.metadata != null + && (typeof model.metadata !== 'object' || Array.isArray(model.metadata))) { + throw new Error('Cannot update model annotations with unsupported metadata'); + } + return model.metadata ?? {}; +} + +export function normalizeModelTags(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value + .filter((tag): tag is string => typeof tag === 'string') + .map(tag => tag.trim()) + .filter(Boolean))]; +} + +export function getModelUserTags(model: Pick): string[] { + return normalizeModelTags(model.metadata?.[USER_TAGS_KEY]); +} + +export function getModelTags(model: Pick): string[] { + return normalizeModelTags([ + ...getModelUserTags(model), + ...normalizeModelTags(model.recommended_for), + ]); +} + +/** User labels are annotations, never model capabilities or default selectors. */ +export function withModelUserTags(model: AIModelConfig, tags: readonly string[]): AIModelConfig { + const normalizedTags = normalizeModelTags(tags); + const existingTags = new Set(getModelUserTags(model)); + // Preserve older configurations above the limit and allow their tags to be removed. + if (normalizedTags.length > MAX_MODEL_USER_TAGS + && normalizedTags.some(tag => !existingTags.has(tag))) { + throw new Error(`Models support up to ${MAX_MODEL_USER_TAGS} custom tags`); + } + return { + ...model, + metadata: { ...checkedMetadata(model), [USER_TAGS_KEY]: normalizedTags }, + }; +} + +/** Keep each model's annotations unless its own draft explicitly edits user tags. */ +export function preserveModelAnnotations( + current: AIModelConfig | undefined, + next: AIModelConfig, + userTags?: readonly string[], +): AIModelConfig { + const metadata = { ...checkedMetadata(next) }; + delete metadata[USER_TAGS_KEY]; + const currentMetadata = current ? checkedMetadata(current) : {}; + if (Object.prototype.hasOwnProperty.call(currentMetadata, USER_TAGS_KEY)) { + metadata[USER_TAGS_KEY] = currentMetadata[USER_TAGS_KEY]; + } + const preserved = { + ...next, + recommended_for: current?.recommended_for ?? [], + metadata, + }; + return userTags === undefined ? preserved : withModelUserTags(preserved, userTags); +} diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 18d3471b23..d427ac7258 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -238,6 +238,8 @@ export interface ReasoningPresetDescriptor { order: number; actions: ReasoningPresetAction[]; source: ReasoningPresetSource; + /** Effective effort reported by the executing adapter; wire ids stay unchanged. */ + effective_effort?: string; } export interface ReasoningCatalogProjection { diff --git a/src/web-ui/src/locales/en-US/settings/default-model.json b/src/web-ui/src/locales/en-US/settings/default-model.json index 61c8042f2d..1021f4bad7 100644 --- a/src/web-ui/src/locales/en-US/settings/default-model.json +++ b/src/web-ui/src/locales/en-US/settings/default-model.json @@ -13,7 +13,7 @@ "optional": "Optional", "primary": { "label": "Primary Model", - "description": "Used for complex reasoning, code generation, and multi-step tasks", + "description": "Used when no model is specified, for everyday conversations and complex tasks", "placeholder": "Select primary model" }, "fast": { @@ -21,7 +21,7 @@ "description": "Used for lightweight tasks; falls back to the primary model when unset", "hint": "When not configured, all tasks use the primary model", "placeholder": "Select fast model", - "notSet": "Not set (use primary model)" + "notSet": "Use primary model" } }, "optional": { @@ -52,7 +52,7 @@ "notConfigured": "Not configured, will use primary model" }, "empty": { - "noModels": "No models available. Please add models in the \"Model List\" tab first." + "noModels": "No models available. Add API models or import a subscription first." }, "messages": { "loadFailed": "Failed to load configuration", diff --git a/src/web-ui/src/locales/en-US/settings/models.json b/src/web-ui/src/locales/en-US/settings/models.json index 84875ab3e1..030667a375 100644 --- a/src/web-ui/src/locales/en-US/settings/models.json +++ b/src/web-ui/src/locales/en-US/settings/models.json @@ -69,7 +69,7 @@ "useFormat": "Use {{format}}", "useFormatWithCount": "Use {{format}} ({{modelCount}})", "detected": "Will use {{label}} ({{account}})", - "notConnected": "{{kind}} is not signed in; sign in above first", + "notConnected": "Not signed in to {{kind}}. Sign in under Subscriptions first.", "unknownAccount": "current account", "reauthenticationRequired": "No usable credential was found in credential storage. Sign in again once.", "vaultUnavailable": "Credential storage is temporarily unavailable. Try again; your sign-in has not been removed.", @@ -569,7 +569,8 @@ "offlineTitle": "Updating without network access", "offlineDescription": "On a device with internet access, download the original models.dev file, name it catalog.json, and place it in the cache directory above. Restart OpenBitFun to apply it; Update now may overwrite this file.", "downloadOriginal": "Download original catalog", - "openCacheDirectory": "Open cache directory" + "openCacheDirectory": "Open cache directory", + "fetchCatalog": "Get the models.dev catalog" }, "advancedSettings": { "title": "Advanced Settings", @@ -610,7 +611,61 @@ }, "description": "Adapt requests and responses to service-specific requirements; keep the defaults if unsure" }, - "providersDescription": "Add multiple models to each service, then choose defaults for each task above", + "providersDescription": "Manage endpoints, authentication, and the models sharing each connection", "editSettingsTitle": "Model parameters", - "editProviderSettingsTitle": "Service connection" + "editProviderSettingsTitle": "Service connection", + "sections": { + "acquisition": "Add or import models", + "selectionModes": "Model selection modes", + "pool": "Model pool", + "more": "More settings" + }, + "acquisition": { + "api": "API models", + "apiHint": "Add models with an API key or a custom endpoint", + "configuredSummary": "{{providers}} services configured, {{models}} models", + "noneConfigured": "No API services configured yet", + "configureProvider": "Configure {{provider}}", + "subscriptions": "Subscriptions", + "importedSummary": "{{models}} models imported from {{providers}} services", + "noneImported": "No subscriptions imported yet", + "import": "Import", + "importTitle": "Import subscriptions", + "loadFailed": "Could not load subscriptions. Retry to refresh; existing model configurations are retained.", + "noServices": "No subscriptions are available to import on this host" + }, + "selectionModes": { + "smart": "Smart mode", + "smartHint": "Powered by OpenBitFun adaptive routing and collaborative multi-model reasoning to improve cost efficiency and task performance.", + "battery": "Battery mode", + "batteryHint": "Multi-source model pooling and failover automatically hand off when quota is exhausted or a connection fails.", + "unavailable": "Not implemented yet" + }, + "pool": { + "manageConnections": "Manage connections", + "search": "Search models, sources, or tags", + "capabilityFilter": "Filter model capabilities", + "allCapabilities": "All capabilities", + "recommendationsUnavailable": "The recommended tag service has not launched, so sync is not connected yet. You can add model tags manually.", + "syncTags": "Sync recommended tags", + "clearFilter": "Clear filters", + "noMatches": "No models match these filters", + "empty": "Add API models or import a subscription to build your model pool.", + "editTags": "Add tags to {{model}}", + "tagsLabel": "Custom tags", + "tagsHint": "Type a tag and press Enter to add it, up to {{max}}. Slot labels such as Primary and Fast do not count toward this limit.", + "tagsPlaceholder": "Add tags", + "tagsCount": "{{count}} / {{max}}", + "tagsLimitReached": "You can add up to {{max}} custom tags. Remove one to add another.", + "recommendedTags": "Existing recommended tags", + "tagsSaveFailed": "Could not save tags. Your changes are retained; please retry.", + "addTags": "Add tags", + "moreActions": "More actions for {{model}}", + "details": "Details", + "enableModel": "Enable {{model}}", + "editConnection": "Edit the {{provider}} connection", + "providerFilter": "Filter providers", + "allProviders": "All providers", + "acknowledge": "Got it" + } } 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 15ee631d0e..2677ebda39 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -3383,7 +3383,7 @@ "medium": "中", "high": "高", "xhigh": "极高", - "max": "最高" + "max": "最大" } }, "coworkScope": { diff --git a/src/web-ui/src/locales/zh-CN/settings/default-model.json b/src/web-ui/src/locales/zh-CN/settings/default-model.json index d8faf5ae6b..c68f09b5f9 100644 --- a/src/web-ui/src/locales/zh-CN/settings/default-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/default-model.json @@ -13,7 +13,7 @@ "optional": "可选", "primary": { "label": "主力模型", - "description": "用于复杂推理、代码生成和多步骤任务", + "description": "不单独指定时使用主力模型,适合日常对话和复杂任务", "placeholder": "请选择主力模型" }, "fast": { @@ -21,7 +21,7 @@ "description": "用于轻量任务;未选择时使用主力模型", "hint": "未配置时,所有任务都使用主力模型", "placeholder": "请选择快速模型", - "notSet": "不设置(使用主力模型)" + "notSet": "跟随主力模型" } }, "optional": { @@ -52,7 +52,7 @@ "notConfigured": "未配置,将使用主力模型" }, "empty": { - "noModels": "暂无可用模型,请先在「模型列表」中添加模型" + "noModels": "暂无可用模型,请先添加 API 模型或导入订阅服务" }, "messages": { "loadFailed": "加载配置失败", diff --git a/src/web-ui/src/locales/zh-CN/settings/models.json b/src/web-ui/src/locales/zh-CN/settings/models.json index a2f7924e9b..f281d7b721 100644 --- a/src/web-ui/src/locales/zh-CN/settings/models.json +++ b/src/web-ui/src/locales/zh-CN/settings/models.json @@ -69,7 +69,7 @@ "useFormat": "使用 {{format}}", "useFormatWithCount": "使用 {{format}}({{modelCount}} 个模型)", "detected": "将使用 {{label}}(账号 {{account}})", - "notConnected": "尚未登录 {{kind}},请先在上方完成登录", + "notConnected": "尚未登录 {{kind}},请先在「订阅服务」中登录", "unknownAccount": "当前登录账号", "reauthenticationRequired": "凭据存储中未找到可用凭据,请重新登录一次", "vaultUnavailable": "凭据存储暂时不可用,请重试;你的登录信息并未被移除", @@ -569,7 +569,8 @@ "offlineTitle": "无法联网时如何更新", "offlineDescription": "在可联网的设备上下载 models.dev 原始文件,将其命名为 catalog.json 并放入上方缓存目录。替换后重启 OpenBitFun 即可生效;点击立即更新可能会覆盖该文件。", "downloadOriginal": "下载原始目录", - "openCacheDirectory": "打开缓存目录" + "openCacheDirectory": "打开缓存目录", + "fetchCatalog": "获取 models.dev 模型目录" }, "advancedSettings": { "title": "高级设置", @@ -610,7 +611,61 @@ }, "description": "用于适配服务的特殊请求与响应要求;不确定时保留默认值" }, - "providersDescription": "同一服务可添加多个模型,默认模型在上方按任务单独选择", + "providersDescription": "统一管理 API 地址、认证方式和同一服务下的模型", "editSettingsTitle": "模型参数", - "editProviderSettingsTitle": "服务连接" + "editProviderSettingsTitle": "服务连接", + "sections": { + "acquisition": "添加或导入模型", + "selectionModes": "模型选用模式", + "pool": "模型池", + "more": "更多设置" + }, + "acquisition": { + "api": "API 模型", + "apiHint": "通过 API Key 或自定义地址添加模型", + "configuredSummary": "已配置 {{providers}} 个服务,共 {{models}} 个模型", + "noneConfigured": "尚未配置 API 服务", + "configureProvider": "配置 {{provider}}", + "subscriptions": "订阅服务", + "importedSummary": "已导入 {{providers}} 个服务,共 {{models}} 个模型", + "noneImported": "尚未导入订阅服务", + "import": "导入", + "importTitle": "导入订阅服务", + "loadFailed": "无法读取订阅服务,请重试。已有模型配置仍会保留。", + "noServices": "当前主机没有可导入的订阅服务" + }, + "selectionModes": { + "smart": "智能模式", + "smartHint": "基于 OpenBitFun 自适应路由与多模型协同推理技术,提升经济效益和任务表现。", + "battery": "电池模式", + "batteryHint": "基于多源模型池化与故障转移技术,额度耗尽或连接失效时自动接力。", + "unavailable": "尚未开发" + }, + "pool": { + "manageConnections": "管理连接", + "search": "搜索模型、来源或标签", + "capabilityFilter": "筛选模型能力", + "allCapabilities": "全部能力", + "recommendationsUnavailable": "推荐标签服务暂未上线,当前尚未接入同步。你可以先手动给模型添加标签。", + "syncTags": "同步推荐标签", + "clearFilter": "清除筛选", + "noMatches": "没有符合条件的模型", + "empty": "先添加 API 模型或导入订阅服务,模型会汇集在这里。", + "editTags": "为 {{model}} 添加标签", + "tagsLabel": "自定义标签", + "tagsHint": "输入标签后按 Enter 添加,最多 {{max}} 个。主力、快速等槽位标签不占名额。", + "tagsPlaceholder": "添加标签", + "tagsCount": "{{count}} / {{max}}", + "tagsLimitReached": "最多添加 {{max}} 个自定义标签,移除一个后可继续添加。", + "recommendedTags": "已有推荐标签", + "tagsSaveFailed": "标签保存失败,修改已保留,请重试。", + "addTags": "添加标签", + "moreActions": "{{model}} 的更多操作", + "details": "详情", + "enableModel": "启用 {{model}}", + "editConnection": "编辑 {{provider}} 的连接", + "providerFilter": "筛选供应商", + "allProviders": "全部供应商", + "acknowledge": "知道了" + } } 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 066da1140f..c6a91ee67e 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -3383,7 +3383,7 @@ "medium": "中", "high": "高", "xhigh": "極高", - "max": "最高" + "max": "最大" } }, "coworkScope": { diff --git a/src/web-ui/src/locales/zh-TW/settings/default-model.json b/src/web-ui/src/locales/zh-TW/settings/default-model.json index 411e48c8f7..e9ae0b29c8 100644 --- a/src/web-ui/src/locales/zh-TW/settings/default-model.json +++ b/src/web-ui/src/locales/zh-TW/settings/default-model.json @@ -13,7 +13,7 @@ "optional": "可選", "primary": { "label": "主力模型", - "description": "用於複雜推理、程式碼產生與多步驟任務", + "description": "未單獨指定時使用主力模型,適合日常對話與複雜任務", "placeholder": "請選擇主力模型" }, "fast": { @@ -21,7 +21,7 @@ "description": "用於輕量任務;未選擇時使用主力模型", "hint": "未設定時,所有任務都使用主力模型", "placeholder": "請選擇快速模型", - "notSet": "不設置(使用主力模型)" + "notSet": "跟隨主力模型" } }, "optional": { @@ -52,7 +52,7 @@ "notConfigured": "未設定,將使用主力模型" }, "empty": { - "noModels": "暫無可用模型,請先在「模型列表」中新增模型" + "noModels": "尚無可用模型,請先新增 API 模型或匯入訂閱服務" }, "messages": { "loadFailed": "載入設定失敗", diff --git a/src/web-ui/src/locales/zh-TW/settings/models.json b/src/web-ui/src/locales/zh-TW/settings/models.json index 9ce12fb05a..cd0cc8de7d 100644 --- a/src/web-ui/src/locales/zh-TW/settings/models.json +++ b/src/web-ui/src/locales/zh-TW/settings/models.json @@ -69,7 +69,7 @@ "useFormat": "使用 {{format}}", "useFormatWithCount": "使用 {{format}}({{modelCount}} 個模型)", "detected": "將使用 {{label}}(賬號 {{account}})", - "notConnected": "尚未登入 {{kind}},請先在上方完成登入", + "notConnected": "尚未登入 {{kind}},請先在「訂閱服務」中登入", "unknownAccount": "目前登入賬號", "reauthenticationRequired": "憑據儲存中未找到可用憑據,請重新登入一次", "vaultUnavailable": "憑據儲存暫時無法使用,請重試;你的登入資訊並未被移除", @@ -569,7 +569,8 @@ "offlineTitle": "無法連線時如何更新", "offlineDescription": "在可連線的裝置上下載 models.dev 原始檔案,將其命名為 catalog.json 並放入上方快取目錄。替換後重新啟動 OpenBitFun 即可生效;點擊立即更新可能會覆蓋該檔案。", "downloadOriginal": "下載原始目錄", - "openCacheDirectory": "開啟快取目錄" + "openCacheDirectory": "開啟快取目錄", + "fetchCatalog": "取得 models.dev 模型目錄" }, "advancedSettings": { "title": "高級設置", @@ -610,7 +611,61 @@ }, "description": "用於配合服務的特殊請求與回應要求;不確定時保留預設值" }, - "providersDescription": "同一服務可新增多個模型,預設模型在上方依任務單獨選擇", + "providersDescription": "統一管理 API 位址、驗證方式與同一服務下的模型", "editSettingsTitle": "模型參數", - "editProviderSettingsTitle": "服務連線" + "editProviderSettingsTitle": "服務連線", + "sections": { + "acquisition": "新增或匯入模型", + "selectionModes": "模型選用模式", + "pool": "模型池", + "more": "更多設定" + }, + "acquisition": { + "api": "API 模型", + "apiHint": "透過 API Key 或自訂位址新增模型", + "configuredSummary": "已設定 {{providers}} 個服務,共 {{models}} 個模型", + "noneConfigured": "尚未設定 API 服務", + "configureProvider": "設定 {{provider}}", + "subscriptions": "訂閱服務", + "importedSummary": "已匯入 {{providers}} 個服務,共 {{models}} 個模型", + "noneImported": "尚未匯入訂閱服務", + "import": "匯入", + "importTitle": "匯入訂閱服務", + "loadFailed": "無法讀取訂閱服務,請重試。既有模型設定仍會保留。", + "noServices": "目前主機沒有可匯入的訂閱服務" + }, + "selectionModes": { + "smart": "智慧模式", + "smartHint": "基於 OpenBitFun 自適應路由與多模型協同推理技術,提升經濟效益和任務表現。", + "battery": "電池模式", + "batteryHint": "基於多源模型池化與故障轉移技術,額度耗盡或連線失效時自動接力。", + "unavailable": "尚未開發" + }, + "pool": { + "manageConnections": "管理連線", + "search": "搜尋模型、來源或標籤", + "capabilityFilter": "篩選模型能力", + "allCapabilities": "全部能力", + "recommendationsUnavailable": "推薦標籤服務尚未上線,目前尚未接入同步。你可以先手動為模型新增標籤。", + "syncTags": "同步推薦標籤", + "clearFilter": "清除篩選", + "noMatches": "沒有符合條件的模型", + "empty": "先新增 API 模型或匯入訂閱服務,模型會集中在這裡。", + "editTags": "為 {{model}} 新增標籤", + "tagsLabel": "自訂標籤", + "tagsHint": "輸入標籤後按 Enter 新增,最多 {{max}} 個。主力、快速等欄位標籤不占名額。", + "tagsPlaceholder": "新增標籤", + "tagsCount": "{{count}} / {{max}}", + "tagsLimitReached": "最多新增 {{max}} 個自訂標籤,移除一個後可繼續新增。", + "recommendedTags": "既有推薦標籤", + "tagsSaveFailed": "標籤儲存失敗,修改已保留,請重試。", + "addTags": "新增標籤", + "moreActions": "{{model}} 的更多操作", + "details": "詳情", + "enableModel": "啟用 {{model}}", + "editConnection": "編輯 {{provider}} 的連線", + "providerFilter": "篩選供應商", + "allProviders": "全部供應商", + "acknowledge": "知道了" + } } From ba7de0e8132a33861ffbc6afb5a658169a44c0dc Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 7 Oct 2026 09:52:51 +0800 Subject: [PATCH 3/3] test(flow-chat): align typography contracts with shared metric and menu owners --- .../FlowChatTypographyRoles.test.ts | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/FlowChatTypographyRoles.test.ts b/src/web-ui/src/flow_chat/components/FlowChatTypographyRoles.test.ts index f383e79d3b..573e6a1dc9 100644 --- a/src/web-ui/src/flow_chat/components/FlowChatTypographyRoles.test.ts +++ b/src/web-ui/src/flow_chat/components/FlowChatTypographyRoles.test.ts @@ -130,17 +130,30 @@ describe('FlowChat semantic typography roles', () => { expect(thinkingMarkdown).not.toContain('font-size:'); }); - it('keeps frequent composer and menu actions on the control role', () => { + it('keeps composer controls on their flow role and menu labels on the shared action role', () => { const chatInput = readSource('./ChatInput.scss'); const harness = readSource('./HarnessProfileSelector.scss'); const model = readSource('./ModelSelector.scss'); const reasoning = readSource('./ReasoningPresetSelector.scss'); + const menu = readSource('../../../../../design-system/packages/ui/src/components/Menu/Menu.tsx'); + const actionItem = readSource('../../../../../design-system/packages/ui/src/components/ActionItem/ActionItem.module.css'); expectRole(chatInput, '&__target-tab {', 'control'); expectRole(chatInput, '&__slash-command-name {', 'control'); expectRole(harness, '.openbitfun-harness-selector__trigger {', 'control'); expectRole(model, '&__trigger {', 'control'); - expectRole(model, '&__option-name {', 'control'); + // MenuItem now owns the model row's label through ActionItem; the product + // wrapper must inherit that public role rather than recreate its sizing. + expect(menu).toContain(' { expect(inlineCode).toContain('font-weight: var(--openbitfun-type-flow-body-font-weight);'); }); - it('keeps completion metadata as an unlabeled two-value row on public tokens', () => { + it('keeps inline duration and shared completion details accessible on public tokens', () => { const component = readSource('./modern/ModelRoundItem.tsx'); const stylesheet = readSource('./modern/ModelRoundItem.scss'); const meta = extractBlock(stylesheet, '.model-round-item__meta {'); + const metrics = readSource('../../../../../design-system/packages/ui/src/flow-chat/conversation/FlowChatTurnMetrics.tsx'); + const metricStyles = readSource('../../../../../design-system/packages/ui/src/flow-chat/conversation/FlowChatTurnMetrics.module.css'); expect(component).not.toContain('model-round-item__meta-label'); expect(component).not.toContain('model-round-item__meta-value'); - expect(component).toContain('aria-label={`${item.label}: ${item.value}`}'); + expect(component).toContain('description={completionMetaItems.map(item => `${item.label}: ${item.value}`).join(\' · \')}'); + expect(component).toContain('content={}'); + expect(component).toContain('{durationMetaItem.value}'); + expect(metrics).toContain('aria-label={description}'); + expect(extractBlock(metricStyles, '.metric {')).toContain('font: inherit;'); + for (const property of ['font-family', 'font-size', 'font-weight', 'line-height']) { + expect(extractBlock(metricStyles, '.root {')).toContain( + `${property}: var(--openbitfun-type-flow-meta-${property});`, + ); + } expect(meta).toContain('gap: var(--openbitfun-space-2);'); expect(meta).toContain('color: var(--openbitfun-color-content-muted);'); expect(meta).toContain('font-family: var(--openbitfun-type-flow-meta-font-family);');