From b42de170b18a39451dd4dcde2d65fd77529d43eb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:23:20 -0700 Subject: [PATCH] Implement responsive browser toolbar overflow with tool folding (#23265) Toolbar buttons fold into a dropdown menu when space is constrained, preserving minimum address bar width. ArtifactPublishButton gains controlled popover state with optional virtual anchoring. Tour-pinned tools stay visible while others fold in priority order. --- .../artifacts/ArtifactPublishButton.test.tsx | 63 +++- .../artifacts/ArtifactPublishButton.tsx | 74 +++-- ...r-cookie-import-google-disclosure.test.tsx | 8 +- .../BrowserImportHintButton.tsx | 11 +- .../assemble-chrome/BrowserToolbarMenu.tsx | 6 +- .../browser-chrome-element-tool-buttons.tsx | 109 ++++++ .../browser-chrome-folded-tools.test.tsx | 107 ++++++ .../browser-chrome-folded-tools.tsx | 106 ++++++ .../browser-chrome-toolbar.test.tsx | 158 +++++++++ .../browser-chrome-toolbar.tsx | 311 +++++++++++------- .../browser-navigation-control-row.tsx | 3 + .../assemble-chrome/browser-page-toolbar.tsx | 38 ++- .../browser-toolbar-menu-dropdown.tsx | 19 +- .../use-browser-chrome-tool-fold.test.tsx | 159 +++++++++ .../use-browser-chrome-tool-fold.ts | 115 +++++++ .../doc-preview-overflow-menu.tsx | 15 +- .../workspace-doc/doc-preview-toolbar.tsx | 8 +- 17 files changed, 1136 insertions(+), 174 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-element-tool-buttons.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx index 77f64b0217c..2b79aa81d31 100644 --- a/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx @@ -1,12 +1,28 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import type { ReactNode } from 'react' +import { createRef, type ReactNode } from 'react' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ +type ArtifactPublishButtonMocks = { + connect: ReturnType + openSettingsPage: ReturnType + openSettingsTarget: ReturnType + getPublishedLink: ReturnType + copyLink: ReturnType + openLink: ReturnType + publish: ReturnType + openPopover: ((open: boolean) => void) | null + closePopover: ((event: Event) => void) | null + state: { + orcaProfileAuthStatus: Record + settings: { artifactSharingEnabled: boolean } + } +} + +const mocks = vi.hoisted(() => ({ connect: vi.fn(), openSettingsPage: vi.fn(), openSettingsTarget: vi.fn(), @@ -14,9 +30,10 @@ const mocks = vi.hoisted(() => ({ copyLink: vi.fn(), openLink: vi.fn(), publish: vi.fn(), - openPopover: null as ((open: boolean) => void) | null, + openPopover: null, + closePopover: null, state: { - orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record, + orcaProfileAuthStatus: { configured: true, state: 'connected' }, settings: { artifactSharingEnabled: true } } })) @@ -42,7 +59,17 @@ vi.mock('@/components/ui/popover', () => ({ mocks.openPopover = onOpenChange ?? null return <>{children} }, - PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverAnchor: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverContent: ({ + children, + onCloseAutoFocus + }: { + children: ReactNode + onCloseAutoFocus?: (event: Event) => void + }) => { + mocks.closePopover = onCloseAutoFocus ?? null + return
{children}
+ }, PopoverTrigger: ({ children }: { children: ReactNode }) => ( mocks.openPopover?.(true)}>{children} ) @@ -81,6 +108,7 @@ describe('ArtifactPublishButton', () => { mocks.getPublishedLink.mockResolvedValue(null) mocks.copyLink.mockResolvedValue(true) mocks.openPopover = null + mocks.closePopover = null mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' } mocks.state.settings = { artifactSharingEnabled: true } }) @@ -101,6 +129,31 @@ describe('ArtifactPublishButton', () => { expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument() }) + it('supports a controlled virtual anchor without rendering a second trigger', async () => { + const anchorRef = createRef() + render( + <> + + + + ) + + expect(screen.queryByRole('button', { name: 'Share as artifact' })).toBeNull() + expect(await screen.findByRole('button', { name: 'Generate link' })).toBeInTheDocument() + + const focus = vi.spyOn(anchorRef.current!, 'focus') + const closeEvent = new Event('close', { cancelable: true }) + mocks.closePopover?.(closeEvent) + expect(closeEvent.defaultPrevented).toBe(true) + expect(focus).toHaveBeenCalledWith({ preventScroll: true }) + }) + it('offers sign-in and blocks confirmation while signed out', async () => { const user = userEvent.setup() mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' } diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx index bf6fb236003..a90c1c037a9 100644 --- a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type RefObject } from 'react' import { ArrowRight, Loader2, Share2 } from 'lucide-react' import type { ArtifactWriteRequest } from '../../../../shared/artifacts' import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -21,14 +21,26 @@ export function ArtifactPublishButton({ sourceKey, createRequest, className, - disabled + disabled, + anchorRef, + open: controlledOpen, + onOpenChange }: { sourceKey: string createRequest: () => Promise className?: string disabled?: boolean -}): React.JSX.Element { - const [open, setOpen] = useState(false) +} & ( + | { anchorRef?: never; open?: never; onOpenChange?: never } + | { + anchorRef?: RefObject + open: boolean + onOpenChange: (open: boolean) => void + } +)): React.JSX.Element { + const [ownOpen, setOwnOpen] = useState(false) + const open = controlledOpen ?? ownOpen + const setOpen = onOpenChange ?? setOwnOpen const [publishing, setPublishing] = useState(false) const [lookupRevision, setLookupRevision] = useState(0) const [linkLookup, setLinkLookup] = useState(null) @@ -111,25 +123,32 @@ export function ArtifactPublishButton({ ) return ( !busy && setOpen(nextOpen)}> - - - - - - - - {label} - - + {anchorRef ? ( + } + /> + ) : ( + + + + + + + + {label} + + + )} { + if (!anchorRef) { + return + } + event.preventDefault() + anchorRef.current?.focus({ preventScroll: true }) + }} >

diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index b7c210c3818..ed8cc04c31f 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -4,7 +4,7 @@ * STA-3811: imports never touch the Google cookie family, so every import menu must disclose it * at the moment of decision. */ -import { act, type ReactNode } from 'react' +import { act, createRef, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import en from '@/i18n/locales/en.json' @@ -67,6 +67,12 @@ describe('cookie-import Google disclosure footer', () => { onImportFromFile={vi.fn()} viewportPresetId={null} onApplyViewportPreset={vi.fn()} + overflow={{ + triggerRef: createRef(), + tools: [], + deferUntilClose: vi.fn(), + onMenuCloseAutoFocus: vi.fn() + }} /> ) ], diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserImportHintButton.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserImportHintButton.tsx index 8e606c8a0e1..701430e1971 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserImportHintButton.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserImportHintButton.tsx @@ -26,10 +26,12 @@ import { translate } from '@/i18n/i18n' type BrowserImportHintButtonProps = { profileId: string | null + compact?: boolean } export function BrowserImportHintButton({ - profileId + profileId, + compact = false }: BrowserImportHintButtonProps): React.JSX.Element | null { const [open, setOpen] = useState(false) const [importMenuOpen, setImportMenuOpen] = useState(false) @@ -178,7 +180,12 @@ export function BrowserImportHintButton({ data-contextual-tour-target="browser-import-hint" > - {translate('auto.components.browser.pane.BrowserImportHintButton.b24fef25be', 'Import')} + {compact + ? null + : translate( + 'auto.components.browser.pane.BrowserImportHintButton.b24fef25be', + 'Import' + )} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx index 026931d2554..c964a3fb6f3 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx @@ -12,6 +12,7 @@ import { import { BrowserToolbarMenuDropdown } from './browser-toolbar-menu-dropdown' import { BrowserToolbarProfileDialogs } from './browser-toolbar-profile-dialogs' import { translate } from '@/i18n/i18n' +import type { BrowserChromeOverflowMenuProps } from './browser-chrome-folded-tools' type BrowserToolbarMenuProps = { currentProfileId: string | null @@ -20,6 +21,7 @@ type BrowserToolbarMenuProps = { viewportPresetId: BrowserViewportPresetId | null onDestroyWebview: () => void isActive: boolean + overflow: BrowserChromeOverflowMenuProps } export function BrowserToolbarMenu({ @@ -28,7 +30,8 @@ export function BrowserToolbarMenu({ browserPageId, viewportPresetId, onDestroyWebview, - isActive + isActive, + overflow }: BrowserToolbarMenuProps): React.JSX.Element { const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) @@ -241,6 +244,7 @@ export function BrowserToolbarMenu({ onImportFromFile={() => void handleImportFromFile()} viewportPresetId={viewportPresetId} onApplyViewportPreset={applyViewportPreset} + overflow={overflow} /> void + disabled: boolean + grabShortcutLabel: string + annotationCount: number +} + +export function BrowserChromeElementToolButtons({ + tools, + showGrab, + showAnnotate, + showTourAnchors +}: { + tools: BrowserChromeElementTools + showGrab: boolean + showAnnotate: boolean + showTourAnchors: boolean +}): React.JSX.Element { + return ( + <> + {showGrab ? ( + + + + + + + + {translate( + 'auto.components.browser.pane.BrowserPane.acbe79fd01', + 'Grab page element ({{value0}})', + { value0: tools.grabShortcutLabel } + )} + + + ) : null} + + {showAnnotate ? ( + + + {/* Why: disabled buttons drop hover events, so the tooltip needs an enabled wrapper. */} + + + + + + {translate( + 'auto.components.browser.pane.BrowserPane.fc9be38f6f', + 'Annotate page element' + )} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx new file mode 100644 index 00000000000..0247f46509b --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Share2 } from 'lucide-react' + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + onSelect, + role, + 'aria-checked': ariaChecked + }: { + children: React.ReactNode + onSelect: () => void + role?: string + 'aria-checked'?: boolean + }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => {children} +})) + +import { BrowserChromeFoldedMenuItems } from './browser-chrome-folded-tools' + +afterEach(cleanup) + +describe('BrowserChromeFoldedMenuItems', () => { + it('runs ordinary actions directly', () => { + const onSelect = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Share' })) + expect(onSelect).toHaveBeenCalledOnce() + }) + + it('defers popover actions until the menu closes', () => { + const onSelect = vi.fn() + const deferUntilClose = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Share' })) + expect(onSelect).not.toHaveBeenCalled() + expect(deferUntilClose).toHaveBeenCalledWith(onSelect) + }) + + it('preserves active state for folded toggle tools', () => { + render( + + ) + + expect( + screen.getByRole('menuitemcheckbox', { name: 'Draw' }).getAttribute('aria-checked') + ).toBe('true') + }) + + it('gives toggle tools their own section, split from the actions by a divider', () => { + const { container } = render( + + ) + + // Rendered order: Draw, divider, Share, closing divider. + expect(Array.from(container.querySelectorAll('button, hr')).map((el) => el.tagName)).toEqual([ + 'BUTTON', + 'HR', + 'BUTTON', + 'HR' + ]) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx new file mode 100644 index 00000000000..3b6c4fd55e0 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx @@ -0,0 +1,106 @@ +import { Check } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut +} from '@/components/ui/dropdown-menu' +import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold' + +export type BrowserChromeFoldedTool = { + stage: BrowserChromeFoldStage + label: string + icon: LucideIcon + onSelect: () => void + disabled?: boolean + shortcut?: string + count?: number + /** Present for toggle tools, including false, so the menu preserves their pressed state. */ + active?: boolean + /** + * Run only after the menu has closed and skip its focus return. A tool that opens its own + * popover needs this: focus landing back on the ⋯ trigger would dismiss that popover at once. + */ + deferUntilMenuClose?: boolean +} + +export type BrowserChromeOverflowMenuProps = { + triggerRef: React.RefObject + tools: readonly BrowserChromeFoldedTool[] + deferUntilClose: (action: () => void) => void + onMenuCloseAutoFocus: (event: Event) => void +} + +function FoldedToolContent({ tool }: { tool: BrowserChromeFoldedTool }): React.JSX.Element { + const showActiveCheck = tool.active === true + const showTrailing = showActiveCheck || Boolean(tool.count) || Boolean(tool.shortcut) + return ( + <> + + {tool.label} + {showTrailing ? ( + // Why: a leading check column would push these icon rows out of line with the plain rows. + + {showActiveCheck ? : null} + {tool.count ? ( + + {tool.count} + + ) : tool.shortcut ? ( + {tool.shortcut} + ) : null} + + ) : null} + + ) +} + +function FoldedToolRow({ + tool, + deferUntilClose +}: { + tool: BrowserChromeFoldedTool + deferUntilClose: (action: () => void) => void +}): React.JSX.Element { + const onSelect = (): void => + tool.deferUntilMenuClose ? deferUntilClose(tool.onSelect) : tool.onSelect() + return ( + + + + ) +} + +/** The toolbar tools that no longer fit, rendered at the top of a surface's ⋯ menu. */ +export function BrowserChromeFoldedMenuItems({ + tools, + deferUntilClose +}: Pick): React.JSX.Element | null { + if (tools.length === 0) { + return null + } + // Why: the element/markup toggles fold first and carry on/off state, so they read as their own + // section ahead of the one-shot actions below rather than as one flat tool list. + const toggleTools = tools.filter((tool) => tool.active !== undefined) + const actionTools = tools.filter((tool) => tool.active === undefined) + return ( + <> + {toggleTools.map((tool) => ( + + ))} + {toggleTools.length > 0 && actionTools.length > 0 ? : null} + {actionTools.map((tool) => ( + + ))} + + + ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx new file mode 100644 index 00000000000..5e014d3a8bf --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold' +import type { BrowserChromeFoldedTool } from './browser-chrome-folded-tools' + +const mocks = vi.hoisted(() => { + const stages: readonly BrowserChromeFoldStage[] = [] + return { folded: new Set(), stages } +}) + +vi.mock('./use-browser-chrome-tool-fold', () => ({ + BROWSER_CHROME_FOLD_ORDER: [ + 'import-label', + 'external', + 'devtools', + 'share', + 'import', + 'draw', + 'grab', + 'annotate' + ], + useBrowserChromeToolFold: (_rowRef: unknown, stages: readonly BrowserChromeFoldStage[]) => { + mocks.stages = stages + return mocks.folded + } +})) + +vi.mock('./browser-navigation-control-row', () => ({ + BrowserNavigationControlRow: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) +})) + +vi.mock('../annotate/MarkupDrawButton', () => ({ + MarkupDrawButton: () => +})) + +vi.mock('./browser-chrome-element-tool-buttons', () => ({ + BrowserChromeElementToolButtons: () => null +})) + +import { BrowserChromeToolbar } from './browser-chrome-toolbar' + +const controls = { + canGoBack: false, + canGoForward: false, + loading: false, + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + navigate: vi.fn() +} +const emptyOverflowMenu = (): null => null + +beforeEach(() => { + mocks.folded = new Set() + mocks.stages = [] +}) + +afterEach(cleanup) + +describe('BrowserChromeToolbar', () => { + it('does not duplicate an action already provided by the surface menu', () => { + mocks.folded = new Set(['devtools', 'external']) + let foldedTools: readonly BrowserChromeFoldedTool[] = [] + + render( + { + foldedTools = overflow.tools + return null + }} + /> + ) + + expect(foldedTools.map((tool) => tool.stage)).toEqual(['external']) + }) + + it('compacts the import hint before removing it', () => { + mocks.folded = new Set(['import-label']) + const importControl = vi.fn(() => null) + + render( + + ) + + expect(importControl).toHaveBeenCalledWith(true) + }) + + it('keeps the active tour control out of the fold sequence', () => { + render( + + ) + + expect(mocks.stages).not.toContain('grab') + expect(mocks.stages).toContain('annotate') + }) + + it('keeps the same share control mounted when it moves into overflow', () => { + function StatefulShare(): React.JSX.Element { + const [clicks, setClicks] = useState(0) + return + } + const props = { + controls, + addressSlot: null, + elementTools: null, + markup: { active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }, + shareControl: () => , + viewSource: null, + openExternal: null, + overflowMenu: emptyOverflowMenu + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Share state 0' })) + + mocks.folded = new Set(['share']) + view.rerender() + + expect(screen.getByRole('button', { name: 'Share state 1' })).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx index a314ed69215..59db797397d 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx @@ -1,24 +1,35 @@ -import { cn } from '@/lib/utils' -import { Crosshair, ExternalLink, MessageSquarePlus, SquareCode } from 'lucide-react' +import { useRef, useState } from 'react' +import { + Crosshair, + ExternalLink, + MessageSquarePlus, + PenTool, + Share2, + SquareCode +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { BrowserNavigationControlRow, type BrowserNavigationControls } from './browser-navigation-control-row' import { MarkupDrawButton } from '../annotate/MarkupDrawButton' -import type { GrabIntent } from '../describe-page/browser-page-types' +import type { + BrowserChromeFoldedTool, + BrowserChromeOverflowMenuProps +} from './browser-chrome-folded-tools' +import { + BROWSER_CHROME_FOLD_ORDER, + useBrowserChromeToolFold, + type BrowserChromeFoldStage +} from './use-browser-chrome-tool-fold' +import { + BrowserChromeElementToolButtons, + type BrowserChromeElementTools +} from './browser-chrome-element-tool-buttons' -/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */ -export type BrowserChromeElementTools = { - /** The intent the picker is armed for right now, or null when it is idle. */ - activeIntent: GrabIntent | null - onStartIntent: (intent: GrabIntent) => void - disabled: boolean - grabShortcutLabel: string - annotationCount: number -} +export type { BrowserChromeElementTools } from './browser-chrome-element-tool-buttons' export type BrowserChromeMarkupTool = { active: boolean @@ -33,10 +44,40 @@ export type BrowserChromeMarkupTool = { canShowDiscoveryHint: boolean } +export type BrowserChromeShareControl = { + open: boolean + onOpenChange: (open: boolean) => void + anchorRef?: React.RefObject +} + export type BrowserChromeToolAction = { onSelect: () => void label: string disabled?: boolean + /** True when the surface's overflow menu already exposes this action. */ + alreadyInOverflowMenu?: boolean +} + +function BrowserChromeActionButton({ + action, + icon: Icon +}: { + action: BrowserChromeToolAction + icon: LucideIcon +}): React.JSX.Element { + return ( + + ) } /** @@ -59,148 +100,170 @@ export function BrowserChromeToolbar({ viewSource, openExternal, overflowMenu, - showTourAnchors = false + showTourAnchors = false, + pinnedStage }: { controls: BrowserNavigationControls addressSlot: React.ReactNode reloadControl?: React.ReactNode reloadLabel?: string /** Cookie import — a browsing session concept; null where there is no session to import into. */ - importControl?: React.ReactNode + importControl?: ((compact: boolean) => React.ReactNode) | null elementTools: BrowserChromeElementTools | null markup: BrowserChromeMarkupTool - shareControl?: React.ReactNode + shareControl?: (control: BrowserChromeShareControl) => React.ReactNode viewSource: BrowserChromeToolAction | null openExternal: BrowserChromeToolAction | null - overflowMenu?: React.ReactNode + overflowMenu: (props: BrowserChromeOverflowMenuProps) => React.ReactNode /** Only the browsing pane anchors the contextual tour; a second anchor would steal its steps. */ showTourAnchors?: boolean + /** Keeps the active contextual-tour control measurable while the remaining tools still fold. */ + pinnedStage?: BrowserChromeFoldStage }): React.JSX.Element { + const rowRef = useRef(null) + const overflowTriggerRef = useRef(null) + const present: Record = { + 'import-label': importControl != null, + import: importControl != null, + external: openExternal !== null, + devtools: viewSource !== null, + share: shareControl != null, + draw: true, + grab: elementTools !== null, + annotate: elementTools !== null + } + const stages = BROWSER_CHROME_FOLD_ORDER.filter( + (stage) => present[stage] && stage !== pinnedStage + ) + const folded = useBrowserChromeToolFold(rowRef, stages) + + const [sharePopoverOpen, setSharePopoverOpen] = useState(false) + const afterMenuCloseRef = useRef<(() => void) | null>(null) + + const foldedTools: BrowserChromeFoldedTool[] = [] + if (elementTools && folded.has('grab')) { + foldedTools.push({ + stage: 'grab', + label: translate('auto.components.browser.pane.BrowserPane.fdfc7fe0ef', 'Grab page element'), + icon: Crosshair, + onSelect: () => elementTools.onStartIntent('copy'), + disabled: elementTools.disabled, + shortcut: elementTools.grabShortcutLabel, + active: elementTools.activeIntent === 'copy' + }) + } + if (elementTools && folded.has('annotate')) { + foldedTools.push({ + stage: 'annotate', + label: translate( + 'auto.components.browser.pane.BrowserPane.fc9be38f6f', + 'Annotate page element' + ), + icon: MessageSquarePlus, + onSelect: () => elementTools.onStartIntent('annotate'), + disabled: elementTools.disabled, + count: elementTools.annotationCount, + active: elementTools.activeIntent === 'annotate' + }) + } + if (folded.has('draw')) { + foldedTools.push({ + stage: 'draw', + label: translate('auto.components.browser-pane.markup.drawButton', 'Draw on screenshot'), + icon: PenTool, + onSelect: markup.onToggle, + disabled: markup.disabled, + active: markup.active + }) + } + if (shareControl && folded.has('share')) { + foldedTools.push({ + stage: 'share', + label: translate( + 'auto.components.artifacts.ArtifactPublishButton.a4a49da6af', + 'Share as artifact' + ), + icon: Share2, + onSelect: () => setSharePopoverOpen(true), + deferUntilMenuClose: true + }) + } + if (viewSource && folded.has('devtools') && !viewSource.alreadyInOverflowMenu) { + foldedTools.push({ stage: 'devtools', icon: SquareCode, ...viewSource }) + } + if (openExternal && folded.has('external') && !openExternal.alreadyInOverflowMenu) { + foldedTools.push({ stage: 'external', icon: ExternalLink, ...openExternal }) + } + + const runAfterMenuClose = (action: () => void): void => { + afterMenuCloseRef.current = action + } + const onMenuCloseAutoFocus = (event: Event): void => { + const action = afterMenuCloseRef.current + if (!action) { + return + } + afterMenuCloseRef.current = null + event.preventDefault() + action() + } + const showFoldedAnnotationDot = folded.has('annotate') && (elementTools?.annotationCount ?? 0) > 0 + return ( - {importControl} + {folded.has('import') ? null : importControl?.(folded.has('import-label'))} {elementTools ? ( - <> - - - - - - - - {translate( - 'auto.components.browser.pane.BrowserPane.acbe79fd01', - 'Grab page element ({{value0}})', - { value0: elementTools.grabShortcutLabel } - )} - - - - - - {/* Why: disabled - - - - {translate( - 'auto.components.browser.pane.BrowserPane.fc9be38f6f', - 'Annotate page element' - )} - - - + ) : null} - + {folded.has('draw') ? null : ( + + )} - {shareControl} + {shareControl?.({ + open: sharePopoverOpen, + onOpenChange: setSharePopoverOpen, + anchorRef: folded.has('share') ? overflowTriggerRef : undefined + })} - {viewSource ? ( - + {viewSource && !folded.has('devtools') ? ( + ) : null} - {openExternal ? ( - + {openExternal && !folded.has('external') ? ( + ) : null} - {overflowMenu} + + {overflowMenu({ + triggerRef: overflowTriggerRef, + tools: foldedTools, + deferUntilClose: runAfterMenuClose, + onMenuCloseAutoFocus + })} + {/* Why: keeps pending annotations visible once the annotate button has folded into ⋯. */} + {showFoldedAnnotationDot ? ( + + ) : null} + ) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx index 236251c64ef..999f890a37f 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx @@ -36,6 +36,7 @@ export function BrowserNavigationControlRow({ reloadControl, reloadLabel, showTourAnchors = true, + rowRef, children }: { controls: BrowserNavigationControls @@ -46,10 +47,12 @@ export function BrowserNavigationControlRow({ reloadLabel?: string /** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */ showTourAnchors?: boolean + rowRef?: React.Ref children?: React.ReactNode }): React.JSX.Element { return (
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx index 616cca726ec..f684e9a1b46 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx @@ -1,6 +1,7 @@ import type { Dispatch, RefObject, SetStateAction } from 'react' import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' import type { BrowserReloadTrigger } from '../navigate/browser-reload-action' import BrowserAddressBar from './BrowserAddressBar' import { BrowserChromeToolbar } from './browser-chrome-toolbar' @@ -102,9 +103,20 @@ export function BrowserPageToolbar({ currentBrowserUrl: string externalUrl: string | null }): React.JSX.Element { + const browserTourStep = useAppStore((state) => + state.activeContextualTourId === 'browser' ? state.activeContextualTourStepIndex : null + ) + const pinnedStage = + browserTourStep === 0 + ? ('grab' as const) + : browserTourStep === 1 + ? ('annotate' as const) + : undefined + return ( runReloadTrigger('hard-reload')} /> } - importControl={} + importControl={(compact) => ( + + )} elementTools={{ activeIntent: grab.state !== 'idle' ? grabIntent : null, onStartIntent: startGrabIntent, @@ -176,13 +190,16 @@ export function BrowserPageToolbar({ canShowDiscoveryHint: isActive }} shareControl={ - shareableArtifactFile ? ( - readBrowserHtmlArtifactRequest(currentBrowserUrl)} - /> - ) : null + shareableArtifactFile + ? (control) => { + const props = { + sourceKey: shareableArtifactFile.filePath, + className: 'h-7 w-7', + createRequest: () => readBrowserHtmlArtifactRequest(currentBrowserUrl) + } + return + } + : undefined } viewSource={{ onSelect: () => void window.api.browser.openDevTools({ browserPageId }), @@ -204,7 +221,7 @@ export function BrowserPageToolbar({ ), disabled: !externalUrl }} - overflowMenu={ + overflowMenu={(overflow) => ( destroyPersistentWebview(browserPageId)} isActive={isActive} + overflow={overflow} /> - } + )} /> ) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx index 0d088a44edd..63098b9b2b4 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx @@ -30,6 +30,10 @@ type DetectedBrowserEntry = { } import { BROWSER_VIEWPORT_PRESETS } from '../../../../../shared/browser-viewport-presets' import { translate } from '@/i18n/i18n' +import { + BrowserChromeFoldedMenuItems, + type BrowserChromeOverflowMenuProps +} from './browser-chrome-folded-tools' type BrowserToolbarMenuDropdownProps = { menuOpen: boolean @@ -45,6 +49,7 @@ type BrowserToolbarMenuDropdownProps = { onImportFromFile: () => void viewportPresetId: BrowserViewportPresetId | null onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void + overflow: BrowserChromeOverflowMenuProps } export function BrowserToolbarMenuDropdown({ @@ -60,12 +65,14 @@ export function BrowserToolbarMenuDropdown({ onImportFromBrowser, onImportFromFile, viewportPresetId, - onApplyViewportPreset + onApplyViewportPreset, + overflow }: BrowserToolbarMenuDropdownProps): React.JSX.Element { return ( - + + {allProfiles.map((profile) => { const isSelectedProfile = profile.id === effectiveProfileId return ( diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx new file mode 100644 index 00000000000..6edb0570a8c --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRef } from 'react' +import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot' +import { + BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX, + BROWSER_CHROME_FOLD_ORDER, + useBrowserChromeToolFold +} from './use-browser-chrome-tool-fold' + +const FIXED_WIDTH = 100 +const TOOL_WIDTH = 30 + +// A flex row where the address takes whatever the fixed controls and visible tools leave over. +const layout = { rowWidth: 600, visibleTools: BROWSER_CHROME_FOLD_ORDER.length } +let resizeCallbacks: (() => void)[] = [] + +function Host({ + onFolded, + stages = BROWSER_CHROME_FOLD_ORDER +}: { + onFolded: (count: number) => void + stages?: readonly (typeof BROWSER_CHROME_FOLD_ORDER)[number][] +}): React.JSX.Element { + const rowRef = useRef(null) + const folded = useBrowserChromeToolFold(rowRef, stages) + layout.visibleTools = stages.length - folded.size + onFolded(folded.size) + return ( +
+
+
+ ) +} + +function contentWidth(): number { + return FIXED_WIDTH + TOOL_WIDTH * layout.visibleTools +} + +beforeEach(() => { + resizeCallbacks = [] + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + resizeCallbacks.push(callback) + } + observe(): void {} + disconnect(): void {} + } + ) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => layout.rowWidth) + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() => + Math.max(layout.rowWidth, contentWidth()) + ) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => + DOMRect.fromRect({ width: Math.max(0, layout.rowWidth - contentWidth()) }) + ) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function resizeTo(width: number): void { + layout.rowWidth = width + act(() => resizeCallbacks.forEach((callback) => callback())) +} + +function expectedFolded(width: number): number { + const room = width - FIXED_WIDTH - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX + const fits = Math.max(0, Math.floor(room / TOOL_WIDTH)) + return Math.max(0, BROWSER_CHROME_FOLD_ORDER.length - fits) +} + +describe('useBrowserChromeToolFold', () => { + it('keeps every tool inline when the address has its minimum width', () => { + let folded = -1 + layout.rowWidth = 600 + render( (folded = count)} />) + expect(folded).toBe(0) + }) + + it('folds just enough tools to give the address its minimum width', () => { + let folded = -1 + layout.rowWidth = 300 + render( (folded = count)} />) + expect(folded).toBe(expectedFolded(300)) + expect(folded).toBeGreaterThan(0) + }) + + it('unfolds as the row widens and refolds as it narrows', () => { + let folded = -1 + layout.rowWidth = 600 + render( (folded = count)} />) + + resizeTo(260) + expect(folded).toBe(expectedFolded(260)) + + resizeTo(400) + expect(folded).toBe(expectedFolded(400)) + + resizeTo(700) + expect(folded).toBe(0) + }) + + it('folds everything but lets the address shrink once no tool is left', () => { + let folded = -1 + layout.rowWidth = 150 + render( (folded = count)} />) + expect(folded).toBe(BROWSER_CHROME_FOLD_ORDER.length) + }) + + it('leaves a hidden (zero-width) row alone', () => { + let folded = -1 + layout.rowWidth = 0 + render( (folded = count)} />) + expect(folded).toBe(0) + }) + + it('measures when a hidden row becomes visible', () => { + let folded = -1 + layout.rowWidth = 0 + render( (folded = count)} />) + + resizeTo(300) + expect(folded).toBe(expectedFolded(300)) + }) + + it('discards measurements when the available tools change', () => { + let folded = -1 + layout.rowWidth = 300 + const view = render( (folded = count)} />) + expect(folded).toBeGreaterThan(0) + + view.rerender( (folded = count)} stages={['grab', 'annotate']} />) + expect(folded).toBe(0) + + resizeTo(150) + expect(folded).toBe(2) + }) + + it('does not restore an obsolete fold level when a tool set returns', () => { + let folded = -1 + layout.rowWidth = 300 + const view = render( (folded = count)} />) + expect(folded).toBeGreaterThan(0) + + layout.rowWidth = 600 + view.rerender( (folded = count)} stages={['grab']} />) + expect(folded).toBe(0) + + view.rerender( (folded = count)} />) + expect(folded).toBe(0) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts new file mode 100644 index 00000000000..02b1fa7e9ec --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts @@ -0,0 +1,115 @@ +import { useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot' + +/** + * One step of toolbar compaction. Steps apply in this order as the row narrows, so the least-used + * tools leave first and the page tools the chrome exists for (grab, annotate) leave last. + */ +export type BrowserChromeFoldStage = + | 'import-label' + | 'external' + | 'devtools' + | 'share' + | 'import' + | 'draw' + | 'grab' + | 'annotate' + +export const BROWSER_CHROME_FOLD_ORDER: readonly BrowserChromeFoldStage[] = [ + 'import-label', + 'external', + 'devtools', + 'share', + 'import', + 'draw', + 'grab', + 'annotate' +] + +/** Below this the URL stops being readable, so tools fold before the address shrinks further. */ +export const BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX = 120 + +type RowMeasure = { address: number; overflow: number } + +function measureRow(row: HTMLElement | null): RowMeasure | null { + const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`) + // Why: a hidden pane measures zero; folding everything there would flash on reveal. + if (!row || !slot || row.clientWidth === 0) { + return null + } + return { + address: slot.getBoundingClientRect().width, + overflow: Math.max(0, row.scrollWidth - row.clientWidth) + } +} + +/** + * Folds `stages` one at a time until the address slot keeps its minimum width and the row stops + * overflowing, and unfolds as room returns. + * + * Why record the width each fold freed instead of re-measuring from zero on every resize: resetting + * would re-render the toolbar once per stage per resize frame. A fold is undone only when the + * address has that much slack, so an unfold can never immediately re-trigger its own fold. + */ +export function useBrowserChromeToolFold( + rowRef: RefObject, + stages: readonly BrowserChromeFoldStage[] +): ReadonlySet { + const stagesKey = stages.join(',') + const [foldedCount, setFoldedCount] = useState(0) + const level = Math.min(foldedCount, stages.length) + const freedRef = useRef<{ stagesKey: string; widths: number[] }>({ stagesKey, widths: [] }) + const pendingRef = useRef<(RowMeasure & { level: number }) | null>(null) + + const checkRef = useRef<() => void>(() => {}) + checkRef.current = () => { + if (freedRef.current.stagesKey !== stagesKey) { + freedRef.current = { stagesKey, widths: [] } + pendingRef.current = null + } + const measure = measureRow(rowRef.current) + if (!measure) { + return + } + const freed = freedRef.current.widths + const pending = pendingRef.current + if (pending && pending.level === level) { + freed[level - 1] = measure.address - pending.address + (pending.overflow - measure.overflow) + pendingRef.current = null + } + const shortfall = + Math.max(0, BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX - measure.address) + measure.overflow + if (shortfall > 0.5 && level < stages.length) { + pendingRef.current = { ...measure, level: level + 1 } + setFoldedCount(level + 1) + return + } + const slack = measure.address - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX + if (shortfall <= 0.5 && level > 0 && slack >= (freed[level - 1] ?? 0)) { + setFoldedCount(level - 1) + } + } + + // Why layout effect: each fold step re-measures before paint, so a squeeze never flashes clipped. + useLayoutEffect(() => { + checkRef.current() + }, [level, stagesKey]) + + useLayoutEffect(() => { + const row = rowRef.current + const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`) + if (!row || typeof ResizeObserver === 'undefined') { + return + } + // Why both: the slot resizes when a sibling tool appears or hides, but stops resizing once it + // hits zero while the row keeps overflowing. + const observer = new ResizeObserver(() => checkRef.current()) + observer.observe(row) + if (slot) { + observer.observe(slot) + } + return () => observer.disconnect() + }, [rowRef]) + + return new Set(stages.slice(0, level)) +} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx index 801d153b28b..29e35d8f87e 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx @@ -8,6 +8,10 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' +import { + BrowserChromeFoldedMenuItems, + type BrowserChromeOverflowMenuProps +} from '../assemble-chrome/browser-chrome-folded-tools' /** * The preview's overflow menu. It carries the document actions rather than the browsing pane's @@ -18,7 +22,8 @@ export function DocPreviewOverflowMenu({ onHardReload, onOpenSource, onCopyPath, - onCopyRelativePath + onCopyRelativePath, + overflow }: { onReload: () => void onHardReload: () => void @@ -26,11 +31,13 @@ export function DocPreviewOverflowMenu({ onCopyPath: () => void /** Why it lives here: the preview hides the editor's path header, which was the only way to copy it. */ onCopyRelativePath: () => void + overflow: BrowserChromeOverflowMenuProps }): React.JSX.Element { return ( - + + {translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx index df8f9116ca2..871323a9aef 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx @@ -102,7 +102,8 @@ export function DocPreviewToolbar({ label: translate( 'auto.components.editor.HtmlDocPreview.openSourceControl', 'Open source file' - ) + ), + alreadyInOverflowMenu: true }} openExternal={{ onSelect: onOpenExternally, @@ -111,15 +112,16 @@ export function DocPreviewToolbar({ 'Open with default app' ) }} - overflowMenu={ + overflowMenu={(overflow) => ( - } + )} /> ) }