From 5c101e1adc2bbbce67e788eaaccfbdf613df3cb1 Mon Sep 17 00:00:00 2001 From: GCWing Date: Mon, 31 Aug 2026 21:32:54 +0800 Subject: [PATCH] refactor(ui): align selection menus with shared anatomy --- .../ui/src/components/Menu/Menu.module.css | 14 +- .../NavigationPanel.module.css | 4 + design-system/packages/ui/tests/menu.test.mjs | 3 + .../ui/tests/navigation-panel.test.mjs | 1 + .../src/app/components/NavPanel/NavPanel.scss | 16 +- .../NavPanel/NavPanelLayout.test.ts | 5 + .../sections/sessions/SessionsSection.scss | 7 +- .../sessions/SessionsSectionLayout.test.ts | 4 +- .../workspaces/WorkspaceListSection.scss | 8 +- .../WorkspaceListSectionLayout.test.ts | 2 + .../HarnessProfileSelector.appearance.ts | 1 - .../components/HarnessProfileSelector.scss | 3 +- .../HarnessProfileSelector.test.tsx | 51 +++- .../components/HarnessProfileSelector.tsx | 49 ++-- .../flow_chat/components/ModelSelector.scss | 8 - .../flow_chat/components/ModelSelector.tsx | 259 +++++++++--------- .../ModelSelectorProviderLevels.test.tsx | 13 + src/web-ui/src/locales/en-US/flow-chat.json | 6 +- src/web-ui/src/locales/en-US/panels/git.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 6 +- src/web-ui/src/locales/zh-CN/panels/git.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 6 +- src/web-ui/src/locales/zh-TW/panels/git.json | 1 + .../git/components/BranchQuickSwitch.scss | 29 -- .../git/components/BranchQuickSwitch.test.tsx | 33 ++- .../git/components/BranchQuickSwitch.tsx | 2 +- 26 files changed, 306 insertions(+), 227 deletions(-) diff --git a/design-system/packages/ui/src/components/Menu/Menu.module.css b/design-system/packages/ui/src/components/Menu/Menu.module.css index 9503556c0a..d0d92d439a 100644 --- a/design-system/packages/ui/src/components/Menu/Menu.module.css +++ b/design-system/packages/ui/src/components/Menu/Menu.module.css @@ -43,7 +43,7 @@ display: flex; min-inline-size: 0; flex-direction: column; - gap: var(--bf-overlay-menu-section-gap); + gap: calc(var(--bf-space-1) / 2); /* Keep item focus rings inside the scroll viewport on every edge. */ padding: var(--bf-focus-width); } @@ -56,6 +56,14 @@ flex-direction: column; } + .section { + gap: var(--bf-overlay-menu-section-gap); + } + + .items { + gap: calc(var(--bf-space-1) / 2); + } + .heading { display: flex; align-items: center; @@ -115,6 +123,10 @@ flex: 0 0 auto; inline-size: 100%; block-size: var(--bf-border-width-default); + margin-block: calc( + var(--bf-overlay-menu-section-gap) + - calc(var(--bf-space-1) / 2) + ); background: var(--bf-color-border-subtle); } diff --git a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css index 573581e87d..264a7e2407 100644 --- a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css +++ b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css @@ -68,6 +68,10 @@ gap: var(--bf-layout-navigation-panel-section-gap); } + .items { + gap: calc(var(--bf-space-1) / 2); + } + .heading { display: flex; align-items: center; diff --git a/design-system/packages/ui/tests/menu.test.mjs b/design-system/packages/ui/tests/menu.test.mjs index 4f9254dab6..6a0bf610d4 100644 --- a/design-system/packages/ui/tests/menu.test.mjs +++ b/design-system/packages/ui/tests/menu.test.mjs @@ -74,6 +74,9 @@ test("Menu styling uses only public surface, geometry, action, and scrollbar tok assert.match(styles, /--bf-overlay-menu-inline-size/); assert.match(styles, /--bf-overlay-menu-item-height/); + assert.match(styles, /\.list\s*\{[^}]*gap: calc\(var\(--bf-space-1\) \/ 2\)/); + assert.match(styles, /\.items\s*\{[^}]*gap: calc\(var\(--bf-space-1\) \/ 2\)/); + assert.match(styles, /\.separator\s*\{[^}]*margin-block: calc\(/); assert.match(styles, /--bf-color-surface-panel/); assert.match(styles, /--bf-shadow-menu/); assert.match(styles, /--bf-overlay-menu-scrollbar-gap/); diff --git a/design-system/packages/ui/tests/navigation-panel.test.mjs b/design-system/packages/ui/tests/navigation-panel.test.mjs index 9175d41756..6dfeecea9d 100644 --- a/design-system/packages/ui/tests/navigation-panel.test.mjs +++ b/design-system/packages/ui/tests/navigation-panel.test.mjs @@ -54,6 +54,7 @@ test("NavigationPanel styling reuses shared action and scrollbar contracts", asy assert.match(styles, /--bf-layout-navigation-panel-inline-size/); assert.match(styles, /--bf-layout-navigation-panel-footer-height/); + assert.match(styles, /\.items\s*\{[^}]*gap: calc\(var\(--bf-space-1\) \/ 2\)/); assert.match(styles, /--bf-color-surface-subtle/); assert.match(styles, /--bf-color-action-neutral-surface-pressed/); assert.match(styles, /aria-current/); diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 40e79a2e2f..9fbff55ee3 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1132,7 +1132,7 @@ $_section-header-height: 22px; min-width: 0; max-width: 100%; padding: 2px $size-gap-1; - gap: 2px; + gap: calc(var(--bf-space-1) / 2); // Multiple SessionsSection siblings: match vertical rhythm of __workspace-list (gap 0 + 2px row padding). &--session-blocks { @@ -2373,7 +2373,7 @@ $_section-header-height: 22px; position: relative; display: flex; flex-direction: column; - gap: 1px; + gap: calc(var(--bf-space-1) / 2); padding: $size-gap-2 $size-gap-2 calc(#{$size-gap-4} * 0.75); flex-shrink: 0; @@ -2392,7 +2392,7 @@ $_section-header-height: 22px; .bitfun-nav-panel__top-action-expand { display: flex; flex-direction: column; - gap: 1px; + gap: calc(var(--bf-space-1) / 2); width: 100%; min-width: 0; } @@ -2400,7 +2400,7 @@ $_section-header-height: 22px; .bitfun-nav-panel__top-action-sublist { display: flex; flex-direction: column; - gap: 1px; + gap: calc(var(--bf-space-1) / 2); overflow: hidden; max-height: 0; opacity: 0; @@ -2596,7 +2596,7 @@ $_section-header-height: 22px; } &__top-actions { - gap: 2px; + gap: calc(var(--bf-space-1) / 2); padding: 5px 6px $size-gap-3; margin-bottom: $size-gap-3; } @@ -2648,11 +2648,11 @@ $_section-header-height: 22px; } &__top-action-expand { - gap: 0; + gap: calc(var(--bf-space-1) / 2); } &__top-action-sublist { - gap: 0; + gap: calc(var(--bf-space-1) / 2); transition: none; } @@ -2772,7 +2772,7 @@ $_section-header-height: 22px; } &__items { - gap: 0; + gap: calc(var(--bf-space-1) / 2); padding: 2px 6px; } diff --git a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts index 1740e28233..bbba516365 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts @@ -43,8 +43,13 @@ describe('NavPanel layout styles', () => { const stylesheet = readNavPanelStylesheet(); const sectionHeaderBlock = extractBlock(stylesheet, '&__section-header'); const itemsBlock = extractBlock(stylesheet, '&__items'); + const topActionExpandBlock = extractBlock(stylesheet, '&__top-action-expand'); + const topActionSublistBlock = extractBlock(stylesheet, '&__top-action-sublist'); expect(itemsBlock).toContain('padding: 2px $size-gap-1;'); + expect(itemsBlock).toContain('gap: calc(var(--bf-space-1) / 2);'); + expect(topActionExpandBlock).toContain('gap: calc(var(--bf-space-1) / 2);'); + expect(topActionSublistBlock).toContain('gap: calc(var(--bf-space-1) / 2);'); expect(sectionHeaderBlock).toContain('margin: 0 $size-gap-1;'); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 70184e2e23..8267e0f78c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -22,9 +22,8 @@ min-width: 0; max-width: 100%; padding: 2px $size-gap-1 2px; - gap: 0; - // No vertical margin: spacing between assistant blocks comes from 2px top/bottom padding only - // (see .bitfun-nav-panel__items--session-blocks gap: 0), aligned with __workspace-item padding. + gap: calc(var(--bf-space-1) / 2); + // No vertical margin: the list owns the separation between adjacent row surfaces. margin: 0 $size-gap-1 0 calc(#{$size-gap-1} + 4px); &.is-flat-workspace-view { @@ -147,7 +146,7 @@ } &.is-child { - margin-top: -2px; + margin-top: 0; min-height: 24px; font-size: var(--bf-font-size-xs); padding-left: calc(var(--bf-nav-session-rail) + 14px); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts index 1a757d70ea..0697cb220b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts @@ -42,9 +42,9 @@ describe('SessionsSection layout styles', () => { expect(inlineListBlock).toContain('padding: 2px $size-gap-1 2px;'); expect(inlineListBlock).toContain('margin: 0 $size-gap-1 0 calc(#{$size-gap-1} + 4px);'); - expect(inlineListBlock).toContain('gap: 0;'); + expect(inlineListBlock).toContain('gap: calc(var(--bf-space-1) / 2);'); expect(inlineItemBlock).toContain('height: 26px;'); - expect(stylesheet).toContain('margin-top: -2px;'); + expect(stylesheet).toContain('margin-top: 0;'); }); it('keeps hidden session row actions from reserving title width', () => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 521933a955..9e54618e3a 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -98,7 +98,7 @@ min-width: 0; max-width: 100%; box-sizing: border-box; - gap: 2px; + gap: calc(var(--bf-space-1) / 2); min-width: 0; max-width: 100%; padding: 2px $size-gap-1; @@ -1074,7 +1074,7 @@ min-width: 0; max-width: 100%; box-sizing: border-box; - gap: 2px; + gap: calc(var(--bf-space-1) / 2); min-width: 0; max-width: 100%; padding: $size-gap-1; @@ -1373,7 +1373,7 @@ } &__workspace-group { - gap: 0; + gap: calc(var(--bf-space-1) / 2); } &__workspace-group-title { @@ -1397,7 +1397,7 @@ &__workspace-item, &__assistant-item { - gap: 0; + gap: calc(var(--bf-space-1) / 2); padding: 0; border-radius: $size-radius-base; transition: diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts index 6aa20adc87..438292c267 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts @@ -65,6 +65,7 @@ describe('WorkspaceListSection layout styles', () => { expect(workspaceGroup).toContain('min-width: 0;'); expect(workspaceItem).toContain('min-width: 0;'); expect(workspaceItem).toContain('max-width: 100%;'); + expect(workspaceItem).toContain('gap: calc(var(--bf-space-1) / 2);'); expect(workspaceCard).toContain('max-width: 100%;'); expect(workspaceCard).toContain('overflow: hidden;'); expect(workspaceIcon).toContain('width: 16px;'); @@ -88,6 +89,7 @@ describe('WorkspaceListSection layout styles', () => { expect(assistantItem).toContain('min-width: 0;'); expect(assistantItem).toContain('max-width: 100%;'); + expect(assistantItem).toContain('gap: calc(var(--bf-space-1) / 2);'); expect(assistantCard).toContain('max-width: 100%;'); expect(assistantCard).toContain('min-height: 30px;'); expect(assistantCard).toContain('overflow: hidden;'); diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.appearance.ts b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.appearance.ts index c7f01896b0..3ac7cf2cea 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.appearance.ts +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.appearance.ts @@ -8,7 +8,6 @@ export const harnessProfileSelectorAppearanceDescriptor: AppearanceSurfaceDescri { id: 'menu' }, { id: 'profile' }, { id: 'agent' }, - { id: 'newSessionNotice' }, ], facets: [ { diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.scss b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.scss index aa5e1c6cb0..42056a5412 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.scss +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.scss @@ -85,8 +85,7 @@ color: currentColor; } -// One surface swaps between the fixed-Session summary, Harness profiles, and -// the second-level Agent list. +// One surface swaps between Harness profiles and the second-level Agent list. .bitfun-harness-selector__menu { position: fixed; z-index: tokens.$z-popover; diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx index 67deda4695..9667d91a1f 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx @@ -23,6 +23,12 @@ vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ getAppearanceOverlayHost: () => document.body, })); +const confirmation = vi.hoisted(() => ({ dialog: vi.fn(async () => true) })); + +vi.mock('@/infrastructure/confirm-dialog', () => ({ + confirmDialog: confirmation.dialog, +})); + const notify = vi.hoisted(() => ({ info: vi.fn() })); vi.mock('@/shared/notification-system', () => ({ @@ -369,7 +375,7 @@ describe('HarnessProfileSelector', () => { expect(document.querySelector('.bitfun-harness-selector__menu')).toBeNull(); }); - it('opens new-Session profile choices directly for a started Session', async () => { + it('confirms a new Session after a profile choice in a started Session', async () => { const onSelectProfile = vi.fn(); const onStartNewSession = vi.fn(); await act(async () => { @@ -398,9 +404,7 @@ describe('HarnessProfileSelector', () => { const menu = document.querySelector('.bitfun-harness-selector__menu'); expect(menu).not.toBeNull(); expect(menu?.dataset.bfPage).toBe('profiles'); - expect( - menu?.querySelector('[data-testid="harness-new-session-notice"]')?.textContent, - ).toBe('chatInput.harness.selectionCreatesNewSession'); + expect(menu?.querySelector('[data-testid="harness-new-session-notice"]')).toBeNull(); expect(menu?.querySelector('[data-testid="harness-session-summary"]')).toBeNull(); expect(menu?.querySelector('[data-testid="harness-start-new-session"]')).toBeNull(); expect( @@ -417,6 +421,11 @@ describe('HarnessProfileSelector', () => { ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onSelectProfile).not.toHaveBeenCalled(); + expect(confirmation.dialog).toHaveBeenCalledWith({ + title: 'chatInput.harness.newSessionConfirmation.title', + message: 'chatInput.harness.newSessionConfirmation.message', + confirmText: 'chatInput.harness.newSessionConfirmation.confirm', + }); expect(onStartNewSession).toHaveBeenCalledWith( { kind: 'profile', id: 'minimal' }, ); @@ -464,11 +473,45 @@ describe('HarnessProfileSelector', () => { ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onSelectAgent).not.toHaveBeenCalled(); + expect(confirmation.dialog).toHaveBeenCalledWith({ + title: 'chatInput.harness.newSessionConfirmation.title', + message: 'chatInput.harness.newSessionConfirmation.message', + confirmText: 'chatInput.harness.newSessionConfirmation.confirm', + }); expect(onStartNewSession).toHaveBeenCalledWith( { kind: 'agent', id: 'Cowork' }, ); }); + it('keeps the current Session unchanged when new-Session confirmation is cancelled', async () => { + confirmation.dialog.mockResolvedValueOnce(false); + const onStartNewSession = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + container.querySelector('[data-testid="harness-profile-selector"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await act(async () => { + document.querySelector('[data-testid="harness-profile-ultimate"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(confirmation.dialog).toHaveBeenCalledTimes(1); + expect(onStartNewSession).not.toHaveBeenCalled(); + expect(document.querySelector('.bitfun-harness-selector__menu')).toBeNull(); + }); + it.each(['creative'] as const)( 'presents a persisted %s profile as active', async (profileId) => { diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx index 39127c5a95..4d591edc62 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Icon, Menu, MenuItem, MenuSection, MenuSeparator, type IconName } from '@bitfun/ui'; import { Tooltip } from '@/component-library'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { confirmDialog } from '@/infrastructure/confirm-dialog'; import { notificationService } from '@/shared/notification-system'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { useSideAnchoredPopoverPosition } from '@/shared/utils/useSideAnchoredPopoverPosition'; @@ -128,9 +129,10 @@ function HarnessProfileMark({ /** * Before the first Turn this is the Session execution picker. Afterwards it - * becomes a lightweight Session signature whose menu choices start a new - * Session directly. ChatInput presents the signature as a disclosure row - * inside its add menu; other consumers may keep the standalone trigger. + * becomes a lightweight Session signature whose menu choices ask for + * confirmation before starting a new Session. ChatInput presents the + * signature as a disclosure row inside its add menu; other consumers may keep + * the standalone trigger. */ export const HarnessProfileSelector: React.FC = ({ legacySession = false, @@ -185,6 +187,20 @@ export const HarnessProfileSelector: React.FC = ({ onSelectionComplete?.(); }, [close, onSelectionComplete]); + const confirmNewSession = useCallback(async ( + selection: HarnessNewSessionSelection, + targetName: string, + ) => { + finishSelection(); + const confirmed = await confirmDialog({ + title: t('chatInput.harness.newSessionConfirmation.title', { name: targetName }), + message: t('chatInput.harness.newSessionConfirmation.message', { name: targetName }), + confirmText: t('chatInput.harness.newSessionConfirmation.confirm'), + }); + if (!confirmed) return; + await onStartNewSession?.(selection); + }, [finishSelection, onStartNewSession, t]); + useEffect(() => { if (!open) return; @@ -214,9 +230,13 @@ export const HarnessProfileSelector: React.FC = ({ } if (fixedSession) { if (isSelectableProfile(profileId)) { - void onStartNewSession?.({ kind: 'profile', id: profileId }); + void confirmNewSession( + { kind: 'profile', id: profileId }, + t(`chatInput.harness.profiles.${profileId}.name`), + ); + } else { + finishSelection(); } - finishSelection(); return; } if (profileId === selectedProfile) { @@ -227,7 +247,7 @@ export const HarnessProfileSelector: React.FC = ({ void onSelectProfile(profileId); } finishSelection(); - }, [finishSelection, fixedSession, onSelectProfile, onStartNewSession, selectedProfile]); + }, [confirmNewSession, finishSelection, fixedSession, onSelectProfile, selectedProfile, t]); const handleSelectAgent = useCallback((agent: HarnessAgentOption) => { if (agent.available === false) { @@ -237,8 +257,7 @@ export const HarnessProfileSelector: React.FC = ({ return; } if (fixedSession) { - void onStartNewSession?.({ kind: 'agent', id: agent.id }); - finishSelection(); + void confirmNewSession({ kind: 'agent', id: agent.id }, agent.name); return; } const connected = selectedProfile === 'other' && sameAgent(agent.id, selectedAgentId); @@ -246,7 +265,7 @@ export const HarnessProfileSelector: React.FC = ({ void onSelectAgent?.(agent.id); } finishSelection(); - }, [finishSelection, fixedSession, onSelectAgent, onStartNewSession, selectedAgentId, selectedProfile, t]); + }, [confirmNewSession, finishSelection, fixedSession, onSelectAgent, selectedAgentId, selectedProfile, t]); const knownSelectedProfile = PROFILE_IDS.find(id => id === selectedProfile); const selectedAgent = otherAgents.find(agent => sameAgent(agent.id, selectedAgentId)); @@ -393,17 +412,7 @@ export const HarnessProfileSelector: React.FC = ({ onMouseDown={event => event.stopPropagation()} onKeyDown={handleMenuKeyDown} > - - {t('chatInput.harness.selectionCreatesNewSession')} - - ) : undefined} - > + {page === 'profiles' ? ( <> {PROFILE_IDS.map((id) => { diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index 4cb97c197c..6ddff52bf7 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -266,10 +266,6 @@ } } - &__settings-list { - width: 100%; - } - &__settings-value { display: block; max-width: 112px; @@ -282,10 +278,6 @@ color: var(--bf-color-content-muted); } - &__settings-separator { - margin-block: var(--bf-space-1); - } - &__option-main { display: flex; flex-direction: column; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 21ce2114d5..079575f22b 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -144,6 +144,7 @@ interface ProviderGroupInfo { } type NativeSubmenuKind = 'models' | 'reasoning'; +type ModelSelectorLevelDirection = 'none' | 'forward' | 'back'; const NATIVE_SUBMENU_GAP = 5; const NATIVE_SUBMENU_FALLBACK_WIDTH = 228; @@ -168,6 +169,26 @@ const ModelSelectorTooltipContent: React.FC<{ details: ModelSelectorTooltipDetai ); +const ModelSelectorMenuLevel: React.FC<{ + children: React.ReactNode; + direction: ModelSelectorLevelDirection; +}> = ({ children, direction }) => ( +
+
+ {children} +
+
+); + // Helper: identify special model IDs. const isSpecialModel = (value: string): value is 'primary' | 'fast' => { return value === 'primary' || value === 'fast'; @@ -299,7 +320,7 @@ export const ModelSelector: React.FC = ({ /** Click-open detail menu beside the stable native settings summary. */ const [nativeSubmenu, setNativeSubmenu] = useState(null); /** Which way the provider level stepped inside the model submenu. */ - const [levelDirection, setLevelDirection] = useState<'none' | 'forward' | 'back'>('none'); + const [levelDirection, setLevelDirection] = useState('none'); const [loading, setLoading] = useState(false); const [reasoningLoading, setReasoningLoading] = useState(false); const acpRestoreToastShownRef = useRef(null); @@ -1906,7 +1927,6 @@ export const ModelSelector: React.FC = ({ onKeyDown={handleDropdownKeyDown} > @@ -1952,7 +1972,7 @@ export const ModelSelector: React.FC = ({ )} - + = ({ : t('modelSelector.modelSelection')} onKeyDown={handleNativeSubmenuKeyDown} > -
- {nativeSubmenu === 'reasoning' ? ( -
- handleSelectReasoningPresetFromMenu(null)} - > - {t('reasoningSelector.auto')} - + {nativeSubmenu === 'reasoning' ? ( + <> + handleSelectReasoningPresetFromMenu(null)} + > + {t('reasoningSelector.auto')} + - {orderedReasoningPresets.map((preset, index) => { - const isSelected = selectedReasoningDescriptor?.id === preset.id; - const label = reasoningPresetLabels[index] - ?? presetDisplayLabel(preset, orderedReasoningPresets, t); + {orderedReasoningPresets.map((preset, index) => { + const isSelected = selectedReasoningDescriptor?.id === preset.id; + const label = reasoningPresetLabels[index] + ?? presetDisplayLabel(preset, orderedReasoningPresets, t); - return ( - handleSelectReasoningPresetFromMenu(preset.id)} - > - {label} - - ); - })} -
- ) : activeProviderGroup ? ( - <> - } - onClick={closeProviderLevel} - > - {activeProviderGroup.providerName} - + return ( + handleSelectReasoningPresetFromMenu(preset.id)} + > + {label} + + ); + })} + + ) : activeProviderGroup ? ( + <> + } + onClick={closeProviderLevel} + > + {activeProviderGroup.providerName} + -
{activeProviderGroup.models.map(model => { const isSelected = currentModelId === model.id; @@ -2069,71 +2085,69 @@ export const ModelSelector: React.FC = ({ ); })} -
- - ) : ( - <> - {(() => { - const primaryModel = allModels.find(m => m.id === defaultModels.primary); - const primaryTooltip = primaryModel - ? buildResolvedModelTooltipText(primaryModel.model_name, { - providerName: getProviderDisplayName(primaryModel), - contextWindow: primaryModel.context_window - }, t('modelSelector.primaryModelDesc')) - : t('modelSelector.primaryModelDesc'); - return ( - - : null} - onClick={() => handleSelectModel('primary')} - > - {t('modelSelector.primaryModel')} - - - ); - })()} - - {(() => { - const fastModel = allModels.find(m => m.id === defaultModels.fast); - const fastTooltip = fastModel - ? buildResolvedModelTooltipText(fastModel.model_name, { - providerName: getProviderDisplayName(fastModel), - contextWindow: fastModel.context_window - }, t('modelSelector.fastModelDesc')) - : t('modelSelector.fastModelDesc'); - return ( - - : null} - onClick={() => handleSelectModel('fast')} - > - {t('modelSelector.fastModel')} - - - ); - })()} + + ) : ( + <> + {(() => { + const primaryModel = allModels.find(m => m.id === defaultModels.primary); + const primaryTooltip = primaryModel + ? buildResolvedModelTooltipText(primaryModel.model_name, { + providerName: getProviderDisplayName(primaryModel), + contextWindow: primaryModel.context_window + }, t('modelSelector.primaryModelDesc')) + : t('modelSelector.primaryModelDesc'); + return ( + + : null} + onClick={() => handleSelectModel('primary')} + > + {t('modelSelector.primaryModel')} + + + ); + })()} + + {(() => { + const fastModel = allModels.find(m => m.id === defaultModels.fast); + const fastTooltip = fastModel + ? buildResolvedModelTooltipText(fastModel.model_name, { + providerName: getProviderDisplayName(fastModel), + contextWindow: fastModel.context_window + }, t('modelSelector.fastModelDesc')) + : t('modelSelector.fastModelDesc'); + return ( + + : null} + onClick={() => handleSelectModel('fast')} + > + {t('modelSelector.fastModel')} + + + ); + })()} - + -
{providerGroups.map(group => { const isSelected = selectedProviderKey === group.key; const selectedModel = isSelected @@ -2185,10 +2199,9 @@ export const ModelSelector: React.FC = ({ ); })} -
- - )} -
+ + )} + , getAppearanceOverlayHost() )} 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 bdc049d76b..709f5ba5cb 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx @@ -148,6 +148,10 @@ describe('ModelSelector provider levels', () => { '[data-testid="chat-model-selector-submenu"]', ); + const sharedSubmenuItems = () => nativeSubmenu()?.querySelector( + '[data-bf-part="section-items"]', + ) ?? null; + const renderSelector = async ( models: unknown[] = CATALOG_MODELS, modeModel = 'primary', @@ -310,6 +314,8 @@ describe('ModelSelector provider levels', () => { const options = Array.from(document.body.querySelectorAll( '[data-testid="chat-model-selector-reasoning-option"]', )); + expect(sharedSubmenuItems()).not.toBeNull(); + expect(options.every(option => sharedSubmenuItems()?.contains(option))).toBe(true); expect(options.map(option => option.dataset.presetId)) .toEqual(['auto', 'medium', 'high']); expect(options.every(option => ( @@ -328,6 +334,10 @@ describe('ModelSelector provider levels', () => { '[data-testid="chat-model-selector-settings"]', )).not.toBeNull(); expect(nativeSubmenu()?.dataset.submenuKind).toBe('models'); + expect(sharedSubmenuItems()).not.toBeNull(); + expect(providerRows().every(row => sharedSubmenuItems()?.contains(row))).toBe(true); + expect(sharedSubmenuItems()?.contains(modelOption('primary'))).toBe(true); + expect(sharedSubmenuItems()?.contains(modelOption('fast'))).toBe(true); expect(providerRows().map(row => row.dataset.providerKey)) .toEqual(['provider-acme', 'provider-umbra']); expect(modelOption('primary')).not.toBeNull(); @@ -350,6 +360,9 @@ describe('ModelSelector provider levels', () => { expect(modelOption('acme-fast')).not.toBeNull(); expect(modelOption('acme-deep')).not.toBeNull(); expect(modelOption('umbra-main')).toBeNull(); + expect(sharedSubmenuItems()).not.toBeNull(); + expect(sharedSubmenuItems()?.contains(modelOption('acme-fast'))).toBe(true); + expect(sharedSubmenuItems()?.contains(modelOption('acme-deep'))).toBe(true); // The symbolic selectors belong to the provider level and are not repeated. expect(modelOption('primary')).toBeNull(); 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 3234c6270e..b1dcf49d1a 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -705,7 +705,11 @@ "comingSoonNotice": "The {{name}} Harness execution framework is in development.", "legacySessionNotice": "This legacy session keeps its original Agent execution mode. Create a new session to use the Agent Harness execution framework.", "sessionStartedNotice": "Harness and main Agent are set before the first turn starts. Create a new session to use another selection.", - "selectionCreatesNewSession": "Switching modes creates a new session", + "newSessionConfirmation": { + "title": "Create a new session with {{name}}?", + "message": "The current session will remain unchanged. The new session will use {{name}}.", + "confirm": "Create new session" + }, "unsupportedProfile": "Unsupported Harness ({{id}})", "unsupportedProfileNotice": "This session uses {{id}}, which this version cannot run. Select a supported Harness explicitly.", "profileChangeBusy": "Wait for the current turn to finish before changing Harness.", diff --git a/src/web-ui/src/locales/en-US/panels/git.json b/src/web-ui/src/locales/en-US/panels/git.json index 4c31eb50f0..a6a087eaef 100644 --- a/src/web-ui/src/locales/en-US/panels/git.json +++ b/src/web-ui/src/locales/en-US/panels/git.json @@ -189,6 +189,7 @@ }, "quickSwitch": { "menuLabel": "Switch branch", + "searchLabel": "Search branches", "searchPlaceholder": "Search branches...", "loading": "Loading...", "conflict": { 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 97d25ec710..cda5de0052 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -705,7 +705,11 @@ "comingSoonNotice": "「{{name}}」Harness 执行框架正在开发中。", "legacySessionNotice": "该旧会话继续使用原 Agent 执行模式;请新建会话使用 Agent Harness 执行框架。", "sessionStartedNotice": "Harness 与主 Agent 在首轮开始前确定;如需切换,请新建会话。", - "selectionCreatesNewSession": "切换模式将创建新会话", + "newSessionConfirmation": { + "title": "使用「{{name}}」创建新会话?", + "message": "当前会话不会更改;新会话将使用「{{name}}」。", + "confirm": "创建新会话" + }, "unsupportedProfile": "不受支持的 Harness({{id}})", "unsupportedProfileNotice": "该会话使用当前版本无法运行的 {{id}}。请选择一个受支持的 Harness。", "profileChangeBusy": "请等待当前轮次结束后再切换 Harness。", diff --git a/src/web-ui/src/locales/zh-CN/panels/git.json b/src/web-ui/src/locales/zh-CN/panels/git.json index 1b294f1b3b..320c991f7c 100644 --- a/src/web-ui/src/locales/zh-CN/panels/git.json +++ b/src/web-ui/src/locales/zh-CN/panels/git.json @@ -189,6 +189,7 @@ }, "quickSwitch": { "menuLabel": "切换分支", + "searchLabel": "搜索分支", "searchPlaceholder": "搜索分支...", "loading": "加载中...", "conflict": { 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 9c7883f5a9..45fe7f26ca 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -705,7 +705,11 @@ "comingSoonNotice": "「{{name}}」Harness 執行框架正在開發中。", "legacySessionNotice": "該舊會話繼續使用原 Agent 執行模式;請新增會話使用 Agent Harness 執行框架。", "sessionStartedNotice": "Harness 與主 Agent 在首輪開始前確定;如需切換,請新增會話。", - "selectionCreatesNewSession": "切換模式將建立新工作階段", + "newSessionConfirmation": { + "title": "使用「{{name}}」建立新工作階段?", + "message": "目前工作階段不會變更;新工作階段將使用「{{name}}」。", + "confirm": "建立新工作階段" + }, "unsupportedProfile": "不支援的 Harness({{id}})", "unsupportedProfileNotice": "該工作階段使用目前版本無法執行的 {{id}}。請選擇受支援的 Harness。", "profileChangeBusy": "請等待目前回合結束後再切換 Harness。", diff --git a/src/web-ui/src/locales/zh-TW/panels/git.json b/src/web-ui/src/locales/zh-TW/panels/git.json index 303be2e52a..82f64aa0c7 100644 --- a/src/web-ui/src/locales/zh-TW/panels/git.json +++ b/src/web-ui/src/locales/zh-TW/panels/git.json @@ -189,6 +189,7 @@ }, "quickSwitch": { "menuLabel": "切換分支", + "searchLabel": "搜尋分支", "searchPlaceholder": "搜尋分支...", "loading": "載入中...", "conflict": { diff --git a/src/web-ui/src/tools/git/components/BranchQuickSwitch.scss b/src/web-ui/src/tools/git/components/BranchQuickSwitch.scss index 282bcc769f..9a1660f82c 100644 --- a/src/web-ui/src/tools/git/components/BranchQuickSwitch.scss +++ b/src/web-ui/src/tools/git/components/BranchQuickSwitch.scss @@ -25,31 +25,6 @@ width: 100%; } -.branch-quick-switch__input { - width: 100%; - padding: 6px 10px; - border: 1px solid var(--bf-color-border-subtle); - border-radius: 4px; - outline: none !important; - background: var(--bf-color-surface-panel); - color: var(--bf-color-content-primary); - font-size: var(--bf-font-size-xs); - transition: - border-color 120ms ease, - background 120ms ease; - - &:focus, - &:focus-visible { - border-color: var(--bf-color-accent-default); - outline: none !important; - box-shadow: none !important; - } - - &::placeholder { - color: var(--bf-color-content-muted); - } -} - .branch-quick-switch__list { max-height: min(280px, calc(100vh - 96px)); padding: $size-gap-1; @@ -171,10 +146,6 @@ } @media (prefers-reduced-motion: reduce) { - .branch-quick-switch__input { - transition: none; - } - .branch-quick-switch__spinner { animation: none; } diff --git a/src/web-ui/src/tools/git/components/BranchQuickSwitch.test.tsx b/src/web-ui/src/tools/git/components/BranchQuickSwitch.test.tsx index 24b6ed4a35..23f755745b 100644 --- a/src/web-ui/src/tools/git/components/BranchQuickSwitch.test.tsx +++ b/src/web-ui/src/tools/git/components/BranchQuickSwitch.test.tsx @@ -59,23 +59,6 @@ vi.mock('@bitfun/ui', async importOriginal => ({ ScrollArea: forwardRef>( ({ children, ...props }, ref) =>
{children}
, ), - SearchField: forwardRef void; - placeholder?: string; - } & Omit, 'onChange'>>( - ({ inputClassName, leadingIcon: _leadingIcon, onValueChange, value, ...props }, ref) => ( - onValueChange(event.target.value)} - {...props} - /> - ), - ), })); vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ @@ -99,6 +82,7 @@ vi.mock('@/infrastructure/i18n', () => ({ 'quickSwitch.conflict.retrySwitchAction': 'Retry switch', 'quickSwitch.conflict.title': 'Commit changes to switch branch', 'quickSwitch.menuLabel': 'Switch branch', + 'quickSwitch.searchLabel': 'Search branches', }; return labels[key] ?? key; }, @@ -183,6 +167,21 @@ describe('BranchQuickSwitch', () => { .forEach(node => node.remove()); }); + it('keeps the design-system search field as one labeled visual surface', async () => { + await act(async () => { + root.render(); + }); + + const searchField = document.querySelector('[data-bf-component="search-field"]'); + const fieldSurface = searchField?.querySelector('[data-bf-component="input"]'); + const input = searchField?.querySelector('input[type="search"]'); + + expect(searchField).not.toBeNull(); + expect(fieldSurface).not.toBeNull(); + expect(input?.getAttribute('aria-label')).toBe('Search branches'); + expect(input?.classList.contains('branch-quick-switch__input')).toBe(false); + }); + it('checks out a selected branch and publishes the shared branch-change event', async () => { const onSwitchSuccess = vi.fn(); mocks.checkoutBranch.mockResolvedValue({ success: true }); diff --git a/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx b/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx index 77778f9db1..d5d3770bb4 100644 --- a/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx +++ b/src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx @@ -436,8 +436,8 @@ export const BranchQuickSwitch: React.FC = ({ } + aria-label={t('quickSwitch.searchLabel')} placeholder={t('quickSwitch.searchPlaceholder')} value={searchTerm} onValueChange={setSearchTerm}