fix(desktop): polish provider presets, composer menu, browser picker, token usage and Agent Teams header (#1472)

* fix(desktop): label the custom provider preset in the active locale

The custom preset showed the catalog's bare English "Custom" in every
language and prefilled it as the config name. It now reads "Custom API"
(自定义模型 API, etc.) on the chip and in the prefilled name.

* fix(desktop): open the composer + submenu level with its row

The side panel was bottom-aligned to the root menu, so Skills and
Connectors both opened at the bottom. It now lines its title up with the
row that opened it and only moves to stay inside the window.

* fix(desktop): show pressed sidebar toggles in the theme accent

A pressed sidebar button used the hover fill, so the task-view bell looked
the same on and hovered. It now takes the brand accent. Pressed buttons
also stop emitting the tone's resting text color, which won over the
pressed color by class order.

* fix(desktop): place the browser edit bubble at the click and capture after it closes

- Picking an element that fills the view (such as <body>) squeezed the
  bubble to its 160px minimum at the bottom-left. It now opens at full
  height beside the click.
- The <webview> guest does not repaint before a native capture, so the
  selection screenshot still showed the closed bubble and no annotation.
  Hiding the page chrome now resolves after the next painted frame.

* feat(desktop): order the token usage page usage, activity, then models

The per-model table moves below the activity heatmap and insights under
its own "Usage by model" heading.

* fix(desktop): wrap the Agent Teams header in narrow panes

The header had a fixed 1180px minimum that scrolled sideways and let
flex squeeze button labels into vertical stacks. The controls now wrap
onto their own row, and buttons and timings never wrap.
This commit is contained in:
程序员阿江-Relakkes
2026-10-08 21:55:28 +08:00
committed by GitHub
parent 0a3b549d5e
commit b95f9f7718
22 changed files with 328 additions and 61 deletions
@@ -2392,7 +2392,7 @@ describe('Settings > Providers tab', () => {
fireEvent.click(addButton) fireEvent.click(addButton)
const dialog = screen.getByRole('dialog') const dialog = screen.getByRole('dialog')
expect(within(dialog).getByLabelText(/Name/i)).toHaveValue('Custom') expect(within(dialog).getByLabelText(/Name/i)).toHaveValue('Custom API')
expect(within(dialog).getByRole('textbox', { name: /Base URL/i })).toBeEnabled() expect(within(dialog).getByRole('textbox', { name: /Base URL/i })).toBeEnabled()
const baseUrlInfo = within(dialog).getByRole('button', { name: 'Base URL help' }) const baseUrlInfo = within(dialog).getByRole('button', { name: 'Base URL help' })
@@ -122,9 +122,19 @@ describe('AgentTeamsWorkbench', () => {
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed') expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running') expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
// A narrow pane wraps the controls onto their own row rather than forcing
// a fixed-width header that scrolls sideways and squeezes button labels.
const headerRow = screen.getByTestId('agent-teams-live-controls').parentElement!
expect(headerRow.className).toContain('flex-wrap')
expect(headerRow.className).not.toMatch(/\bmin-w-\[\d+px\]/)
expect(headerRow.parentElement!.className).not.toContain('overflow-x-auto')
fireEvent.click(screen.getByRole('button', { name: 'Review history' })) fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull() expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull()
expect(screen.getByTestId('agent-teams-replay-controls')).toBeTruthy() expect(screen.getByTestId('agent-teams-replay-controls')).toBeTruthy()
for (const name of [/^(Play|Pause|Replay)$/, 'Back to live']) {
expect(screen.getByRole('button', { name }).className).toMatch(/(?=.*\bshrink-0\b)(?=.*\bwhitespace-nowrap\b)/)
}
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
expect(timeline.value).toBe('1000') expect(timeline.value).toBe('1000')
@@ -321,9 +321,12 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
aria-label={t('agentTeams.title')} aria-label={t('agentTeams.title')}
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]" className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]"
> >
<header className="shrink-0 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"> {/* A narrow pane wraps the controls onto a row of their own instead of
<div className="flex min-w-[1180px] items-center gap-5 px-[18px] py-2.5"> scrolling the header sideways; the team block and counters stay on
<div className="min-w-[260px]"> the first row. Nothing in a control wraps, only hint text truncates. */}
<header className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 px-[18px] py-2.5">
<div className="min-w-0 max-w-full">
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]"> <div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
Agent Teams · {t('agentTeams.sharedTaskList')} Agent Teams · {t('agentTeams.sharedTaskList')}
</div> </div>
@@ -340,7 +343,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-[18px] border-x border-[var(--color-border)] px-[18px]"> <div className="flex shrink-0 items-center gap-[18px] border-l border-[var(--color-border)] pl-[18px]">
<div className="min-w-[74px]"> <div className="min-w-[74px]">
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]"> <div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.stats.phase')} {t('agentTeams.stats.phase')}
@@ -359,7 +362,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
</div> </div>
{followingLive ? ( {followingLive ? (
<div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-1 items-center gap-3.5"> <div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-[1_1_480px] items-center gap-3.5">
<div className="flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--color-info-container)] px-2.5 py-1"> <div className="flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--color-info-container)] px-2.5 py-1">
<span className="agent-teams-live-dot h-1.5 w-1.5 rounded-full bg-[var(--color-info)]" aria-hidden="true" /> <span className="agent-teams-live-dot h-1.5 w-1.5 rounded-full bg-[var(--color-info)]" aria-hidden="true" />
<span className="whitespace-nowrap text-xs font-medium text-[var(--color-on-info-container)]"> <span className="whitespace-nowrap text-xs font-medium text-[var(--color-on-info-container)]">
@@ -369,7 +372,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-secondary)]"> <span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-secondary)]">
{liveHint} {liveHint}
</span> </span>
<Button variant="secondary" size="sm" onClick={enterReplay}> <Button variant="secondary" size="sm" className="shrink-0 whitespace-nowrap" onClick={enterReplay}>
{t('agentTeams.reviewHistory')} {t('agentTeams.reviewHistory')}
</Button> </Button>
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5"> <div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
@@ -380,11 +383,11 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
</div> </div>
</div> </div>
) : ( ) : (
<div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-1 items-center gap-3.5"> <div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-[1_1_560px] flex-wrap items-center gap-x-3.5 gap-y-2">
<Button <Button
variant="primary" variant="primary"
size="base" size="base"
className="w-[86px]" className="w-[86px] shrink-0 whitespace-nowrap"
icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />} icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />}
onClick={togglePlayback} onClick={togglePlayback}
> >
@@ -419,10 +422,10 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
) )
})} })}
</div> </div>
<div className="min-w-[200px] flex-1"> <div className="min-w-[180px] flex-1">
<div className="mb-1 flex justify-between text-[11px] font-semibold text-[var(--color-text-tertiary)]"> <div className="mb-1 flex justify-between gap-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.replay.timeline')}</span> <span className="min-w-0 truncate">{t('agentTeams.replay.timeline')}</span>
<span className="font-mono tabular-nums"> <span className="shrink-0 whitespace-nowrap font-mono tabular-nums">
{formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)} {formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
</span> </span>
</div> </div>
@@ -472,6 +475,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="shrink-0 whitespace-nowrap"
icon={<Radio size={12} aria-hidden="true" />} icon={<Radio size={12} aria-hidden="true" />}
onClick={returnToLive} onClick={returnToLive}
> >
@@ -183,6 +183,43 @@ describe('ComposerCapabilityMenu', () => {
expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill }) expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill })
}) })
it('opens the side panel level with its row and keeps it inside the window', () => {
const rowTops: Record<string, number> = { Skills: 160, Connectors: 192 }
let flyoutHeight = 120
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const label = this.getAttribute('role') === 'option' ? Object.keys(rowTops).find(key => this.querySelector(`#${this.id}-label`)?.textContent === key) : undefined
const top = label ? rowTops[label]! : this.className.includes('bottom-full') ? 100 : 0
return { top, height: label ? 32 : 0, bottom: top + (label ? 32 : 0), left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
})
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
return this.dataset.testid === 'capability-flyout' ? flyoutHeight : 0
})
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
try {
renderMenu()
// Row top relative to the menu, title bar centred on the 32px row.
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
expect(screen.getByTestId('capability-flyout').style.top).toBe('56px')
fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ }))
expect(screen.getByTestId('capability-flyout').style.top).toBe('88px')
// Near the window bottom it lifts just enough to stay 8px clear.
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 300 })
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
expect(screen.getByTestId('capability-flyout').style.top).toBe('56px')
fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ }))
expect(screen.getByTestId('capability-flyout').style.top).toBe('72px')
// A panel taller than the window keeps its top edge on screen.
flyoutHeight = 400
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
expect(screen.getByTestId('capability-flyout').style.top).toBe('-92px')
} finally {
vi.restoreAllMocks()
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 768 })
}
})
it('walks into a category with the keyboard and steps back one panel per Escape', () => { it('walks into a category with the keyboard and steps back one panel per Escape', () => {
const { onAction, onClose } = renderMenu() const { onAction, onClose } = renderMenu()
const input = searchInput() const input = searchInput()
@@ -35,6 +35,10 @@ type Props = {
const ROOT_WIDTH = 288 const ROOT_WIDTH = 288
const FLYOUT_WIDTH = 320 const FLYOUT_WIDTH = 320
const PANEL_GAP = 4 const PANEL_GAP = 4
/** The side panel's title bar, centred on the row that opened it. */
const FLYOUT_HEADER_HEIGHT = 40
/** Space the side panel keeps from the window edges. */
const VIEWPORT_MARGIN = 8
/** The full skill list reuses the @ menu's reference browser. */ /** The full skill list reuses the @ menu's reference browser. */
const BROWSE_REFERENCES_KEY = 'skills:all' const BROWSE_REFERENCES_KEY = 'skills:all'
const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]' const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
@@ -98,6 +102,8 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
// categories in place, as the sheet does. // categories in place, as the sheet does.
const [narrow, setNarrow] = useState(false) const [narrow, setNarrow] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const flyoutRef = useRef<HTMLDivElement>(null)
const [flyoutTop, setFlyoutTop] = useState(0)
const referenceRef = useRef<ComposerReferenceMenuHandle>(null) const referenceRef = useRef<ComposerReferenceMenuHandle>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
if (sheet) return if (sheet) return
@@ -136,6 +142,32 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex) ? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex)
: rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex) : rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex)
const controlsId = showReferences ? referencesId : drillParent ? subListId : listId const controlsId = showReferences ? referencesId : drillParent ? subListId : listId
const flyoutAnchorIndex = flyoutOpen ? rootItems.findIndex(item => item.key === path[0]) : -1
// Like a desktop submenu, the side panel opens level with its row and only
// moves off it when it would otherwise run past the window.
useLayoutEffect(() => {
const container = containerRef.current
const flyout = flyoutRef.current
if (!container || !flyout || flyoutAnchorIndex < 0) return
const doc = container.ownerDocument
const place = () => {
const row = doc.getElementById(getCapabilityMenuOptionId(id, flyoutAnchorIndex))
if (!row) return
const containerTop = container.getBoundingClientRect().top
const rowRect = row.getBoundingClientRect()
const viewHeight = doc.defaultView?.innerHeight ?? Infinity
const level = rowRect.top - containerTop + (rowRect.height - FLYOUT_HEADER_HEIGHT) / 2
const lowest = viewHeight - VIEWPORT_MARGIN - flyout.offsetHeight - containerTop
setFlyoutTop(Math.max(VIEWPORT_MARGIN - containerTop, Math.min(level, lowest)))
}
place()
const View = doc.defaultView
if (!View?.ResizeObserver) return
const observer = new View.ResizeObserver(place)
observer.observe(flyout)
return () => observer.disconnect()
}, [id, flyoutAnchorIndex, path.length, browseReferences])
const openCategory = (nextPath: string[]) => { const openCategory = (nextPath: string[]) => {
setPath(nextPath) setPath(nextPath)
@@ -308,12 +340,12 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
</div> </div>
} }
return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 flex items-end" style={{ gap: PANEL_GAP }} onMouseDown={event => event.preventDefault()}> return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2" onMouseDown={event => event.preventDefault()}>
<div className={`${PANEL} shrink-0 ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}> <div className={`${PANEL} ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}>
{searchRow} {searchRow}
{searching ? referenceMenu : rootList} {searching ? referenceMenu : rootList}
</div> </div>
{flyoutOpen ? <div data-testid="capability-flyout" className={`${PANEL} shrink-0`} style={{ width: FLYOUT_WIDTH }}> {flyoutOpen ? <div ref={flyoutRef} data-testid="capability-flyout" className={`${PANEL} absolute`} style={{ left: ROOT_WIDTH + PANEL_GAP, top: flyoutTop, width: FLYOUT_WIDTH }}>
<div className="flex h-10 items-center gap-1.5 border-b border-[var(--color-border)] px-2"> <div className="flex h-10 items-center gap-1.5 border-b border-[var(--color-border)] px-2">
{path.length > 1 ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null} {path.length > 1 ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
<span className="min-w-0 flex-1 truncate px-1 text-[13px] font-semibold text-[var(--color-text-primary)]">{drillParent!.label}</span> <span className="min-w-0 flex-1 truncate px-1 text-[13px] font-semibold text-[var(--color-text-primary)]">{drillParent!.label}</span>
@@ -106,6 +106,15 @@ describe('IconButton', () => {
expect(screen.getByRole('button', { name: 'Refresh' })).not.toHaveAttribute('aria-pressed') expect(screen.getByRole('button', { name: 'Refresh' })).not.toHaveAttribute('aria-pressed')
}) })
it('marks a pressed sidebar toggle with the theme accent, not the hover fill', () => {
const { container } = render(<IconButton icon={<span />} label="Tasks" tone="muted" surface="sidebar" pressed />)
const className = container.firstElementChild!.className
expect(className).toContain('bg-[var(--color-brand-soft)]')
expect(className).toContain('text-[var(--color-brand)]')
expect(className).not.toContain('bg-[var(--color-sidebar-item-hover)]')
expect(className).not.toContain('text-[var(--color-text-tertiary)]')
})
it('drops the tone hover while pressed so two fills cannot compete', () => { it('drops the tone hover while pressed so two fills cannot compete', () => {
const { container } = render(<IconButton icon={<span />} label="Filter" pressed />) const { container } = render(<IconButton icon={<span />} label="Filter" pressed />)
const className = container.firstElementChild!.className const className = container.firstElementChild!.className
+8 -4
View File
@@ -224,7 +224,10 @@ const SOFT_CLASSES = 'bg-[var(--color-btn-soft-bg)] hover:bg-[var(--color-btn-so
const PRESSED_CLASSES: Record<IconButtonSurface, string> = { const PRESSED_CLASSES: Record<IconButtonSurface, string> = {
default: 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]', default: 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]',
sidebar: 'bg-[var(--color-sidebar-item-hover)] text-[var(--color-text-primary)]', // The sidebar's hover fill is the faintest step on its ground; a toggle that
// is on must not look like one the pointer is merely over, so it takes the
// theme accent.
sidebar: 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]',
terminal: 'bg-[var(--color-terminal-selection)] text-[var(--color-terminal-fg)]', terminal: 'bg-[var(--color-terminal-selection)] text-[var(--color-terminal-fg)]',
media: 'bg-[var(--color-media-selection)] text-[var(--color-media-fg)]', media: 'bg-[var(--color-media-selection)] text-[var(--color-media-fg)]',
} }
@@ -288,9 +291,10 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
BASE_CLASSES, BASE_CLASSES,
DISABLED_OPACITY[disabledStyle], DISABLED_OPACITY[disabledStyle],
SIZE_CLASSES[size], SIZE_CLASSES[size],
// `solid` supplies both fill and foreground, so the tone's resting text // `solid` and `pressed` supply both fill and foreground, so the tone's
// color and hover fill are skipped rather than left to compete. // resting text color is skipped rather than left to compete: two
solid ? SOLID_CLASSES[tone] : (SURFACE_REST_TEXT[surface] ?? REST_TEXT)[tone], // `text-[…]` values resolve by alphabetical order, not by intent.
solid ? SOLID_CLASSES[tone] : !pressed && (SURFACE_REST_TEXT[surface] ?? REST_TEXT)[tone],
solid && 'hover:brightness-110', solid && 'hover:brightness-110',
// A pressed or soft button carries its own fill and hover; skipping the // A pressed or soft button carries its own fill and hover; skipping the
// tone's hover here keeps two `hover:bg-[…]` values from competing. // tone's hover here keeps two `hover:bg-[…]` values from competing.
+2
View File
@@ -1197,6 +1197,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.activity.messages': 'Messages', 'settings.activity.messages': 'Messages',
'settings.activity.tools': 'Tools', 'settings.activity.tools': 'Tools',
'settings.activity.activityInsights': 'Activity insights', 'settings.activity.activityInsights': 'Activity insights',
'settings.activity.modelUsage': 'Usage by model',
'settings.activity.activeRate': 'Active rate', 'settings.activity.activeRate': 'Active rate',
'settings.activity.mostUsedModel': 'Most used model', 'settings.activity.mostUsedModel': 'Most used model',
'settings.activity.exploredSkills': 'Skills explored', 'settings.activity.exploredSkills': 'Skills explored',
@@ -1442,6 +1443,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.providers.regionChina': 'China mainland', 'settings.providers.regionChina': 'China mainland',
'settings.providers.regionGlobal': 'Global', 'settings.providers.regionGlobal': 'Global',
'settings.providers.regionCustom': 'Custom', 'settings.providers.regionCustom': 'Custom',
'settings.providers.customPreset': 'Custom API',
'settings.providers.apiKey': 'API Key', 'settings.providers.apiKey': 'API Key',
'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)', 'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)',
'settings.providers.showApiKey': 'Show API Key', 'settings.providers.showApiKey': 'Show API Key',
+2
View File
@@ -1198,6 +1198,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.activity.messages': 'メッセージ', 'settings.activity.messages': 'メッセージ',
'settings.activity.tools': 'ツール', 'settings.activity.tools': 'ツール',
'settings.activity.activityInsights': 'アクティビティ分析', 'settings.activity.activityInsights': 'アクティビティ分析',
'settings.activity.modelUsage': 'モデル別の使用量',
'settings.activity.activeRate': 'アクティブ率', 'settings.activity.activeRate': 'アクティブ率',
'settings.activity.mostUsedModel': '最も使ったモデル', 'settings.activity.mostUsedModel': '最も使ったモデル',
'settings.activity.exploredSkills': '使用したスキル', 'settings.activity.exploredSkills': '使用したスキル',
@@ -1443,6 +1444,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.regionChina': '中国本土', 'settings.providers.regionChina': '中国本土',
'settings.providers.regionGlobal': 'グローバル', 'settings.providers.regionGlobal': 'グローバル',
'settings.providers.regionCustom': 'カスタム', 'settings.providers.regionCustom': 'カスタム',
'settings.providers.customPreset': 'カスタム API',
'settings.providers.apiKey': 'API キー', 'settings.providers.apiKey': 'API キー',
'settings.providers.apiKeyKeep': 'API キー(空欄のままにすると現在の値を保持)', 'settings.providers.apiKeyKeep': 'API キー(空欄のままにすると現在の値を保持)',
'settings.providers.showApiKey': 'API キーを表示', 'settings.providers.showApiKey': 'API キーを表示',
+2
View File
@@ -1200,6 +1200,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.activity.messages': '메시지', 'settings.activity.messages': '메시지',
'settings.activity.tools': '도구', 'settings.activity.tools': '도구',
'settings.activity.activityInsights': '활동 인사이트', 'settings.activity.activityInsights': '활동 인사이트',
'settings.activity.modelUsage': '모델별 사용량',
'settings.activity.activeRate': '활성 비율', 'settings.activity.activeRate': '활성 비율',
'settings.activity.mostUsedModel': '가장 많이 쓴 모델', 'settings.activity.mostUsedModel': '가장 많이 쓴 모델',
'settings.activity.exploredSkills': '사용한 스킬', 'settings.activity.exploredSkills': '사용한 스킬',
@@ -1445,6 +1446,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.regionChina': '중국 본토', 'settings.providers.regionChina': '중국 본토',
'settings.providers.regionGlobal': '글로벌', 'settings.providers.regionGlobal': '글로벌',
'settings.providers.regionCustom': '사용자 지정', 'settings.providers.regionCustom': '사용자 지정',
'settings.providers.customPreset': '사용자 지정 API',
'settings.providers.apiKey': 'API 키', 'settings.providers.apiKey': 'API 키',
'settings.providers.apiKeyKeep': 'API 키 (비워 두면 현재 값 유지)', 'settings.providers.apiKeyKeep': 'API 키 (비워 두면 현재 값 유지)',
'settings.providers.showApiKey': 'API 키 표시', 'settings.providers.showApiKey': 'API 키 표시',
+2
View File
@@ -1197,6 +1197,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.messages': '訊息', 'settings.activity.messages': '訊息',
'settings.activity.tools': '工具', 'settings.activity.tools': '工具',
'settings.activity.activityInsights': '活動洞察', 'settings.activity.activityInsights': '活動洞察',
'settings.activity.modelUsage': '模型用量',
'settings.activity.activeRate': '活躍率', 'settings.activity.activeRate': '活躍率',
'settings.activity.mostUsedModel': '最常用模型', 'settings.activity.mostUsedModel': '最常用模型',
'settings.activity.exploredSkills': '已使用的技能', 'settings.activity.exploredSkills': '已使用的技能',
@@ -1442,6 +1443,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.regionChina': '中國大陸', 'settings.providers.regionChina': '中國大陸',
'settings.providers.regionGlobal': '海外', 'settings.providers.regionGlobal': '海外',
'settings.providers.regionCustom': '自訂', 'settings.providers.regionCustom': '自訂',
'settings.providers.customPreset': '自訂模型 API',
'settings.providers.apiKey': 'API 金鑰', 'settings.providers.apiKey': 'API 金鑰',
'settings.providers.apiKeyKeep': 'API 金鑰(留空保持不變)', 'settings.providers.apiKeyKeep': 'API 金鑰(留空保持不變)',
'settings.providers.showApiKey': '顯示 API 金鑰', 'settings.providers.showApiKey': '顯示 API 金鑰',
+2
View File
@@ -1196,6 +1196,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.messages': '消息', 'settings.activity.messages': '消息',
'settings.activity.tools': '工具', 'settings.activity.tools': '工具',
'settings.activity.activityInsights': '活动洞察', 'settings.activity.activityInsights': '活动洞察',
'settings.activity.modelUsage': '模型用量',
'settings.activity.activeRate': '活跃率', 'settings.activity.activeRate': '活跃率',
'settings.activity.mostUsedModel': '最常用模型', 'settings.activity.mostUsedModel': '最常用模型',
'settings.activity.exploredSkills': '已使用的技能', 'settings.activity.exploredSkills': '已使用的技能',
@@ -1441,6 +1442,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.regionChina': '中国大陆', 'settings.providers.regionChina': '中国大陆',
'settings.providers.regionGlobal': '海外', 'settings.providers.regionGlobal': '海外',
'settings.providers.regionCustom': '自定义', 'settings.providers.regionCustom': '自定义',
'settings.providers.customPreset': '自定义模型 API',
'settings.providers.apiKey': 'API 密钥', 'settings.providers.apiKey': 'API 密钥',
'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)', 'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)',
'settings.providers.showApiKey': '显示 API 密钥', 'settings.providers.showApiKey': '显示 API 密钥',
@@ -412,6 +412,22 @@ describe('ActivitySettings', () => {
]) ])
}) })
it('orders the page usage, then activity, then the per-model breakdown', async () => {
render(<ActivitySettings />)
await flushActivityLoad()
const usage = screen.getByRole('list', { name: 'Token usage by day' })
const activity = screen.getByRole('heading', { name: 'Token Activity' })
const insights = screen.getByRole('heading', { name: 'Activity insights' })
const models = screen.getByRole('heading', { name: 'Usage by model' })
const follows = (a: Node, b: Node) => Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
expect(follows(usage, activity)).toBe(true)
expect(follows(activity, insights)).toBe(true)
expect(follows(insights, models)).toBe(true)
expect(follows(models, screen.getByRole('table'))).toBe(true)
})
it('supports localized heatmap mode switches and persisted display name edits', async () => { it('supports localized heatmap mode switches and persisted display name edits', async () => {
useSettingsStore.setState({ locale: 'zh' }) useSettingsStore.setState({ locale: 'zh' })
render(<ActivitySettings />) render(<ActivitySettings />)
+30 -28
View File
@@ -1001,34 +1001,6 @@ export function ActivitySettings() {
</Card> </Card>
)} )}
{!isLoading && !error && hasUsage && modelRows.length > 0 && (
<Card surface="lowest" padding="none" role="table" className="mt-3 overflow-hidden">
<div
role="row"
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] gap-3 bg-[var(--color-surface-container)] px-4 py-2.5 text-xs font-semibold text-[var(--color-text-tertiary)]"
>
<span role="columnheader" className="min-w-0 truncate">{t('settings.agents.model')}</span>
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.freshTokens')}</span>
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.cachedTokens')}</span>
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.estimatedCost')}</span>
</div>
{modelRows.map((row) => (
<div
key={row.model}
role="row"
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] items-center gap-3 border-t border-[var(--color-border)] px-4 py-2.5 text-[13px] tabular-nums text-[var(--color-text-primary)]"
>
<span role="cell" className="min-w-0 truncate font-mono text-xs" title={row.model}>{row.model}</span>
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.fresh)}</span>
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.cached)}</span>
<span role="cell" className="min-w-0 truncate text-right">
{row.unpriced ? <span className="text-[var(--color-text-tertiary)]">—</span> : formatCostUSD(row.costUSD, locale)}
</span>
</div>
))}
</Card>
)}
<Modal <Modal
open={isEditingProfile} open={isEditingProfile}
onClose={cancelProfileEdit} onClose={cancelProfileEdit}
@@ -1265,6 +1237,36 @@ export function ActivitySettings() {
</Card> </Card>
</SettingsSection> </SettingsSection>
{modelRows.length > 0 && (
<SettingsSection title={t('settings.activity.modelUsage')}>
<Card surface="lowest" padding="none" role="table" className="overflow-hidden">
<div
role="row"
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] gap-3 bg-[var(--color-surface-container)] px-4 py-2.5 text-xs font-semibold text-[var(--color-text-tertiary)]"
>
<span role="columnheader" className="min-w-0 truncate">{t('settings.agents.model')}</span>
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.freshTokens')}</span>
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.cachedTokens')}</span>
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.estimatedCost')}</span>
</div>
{modelRows.map((row) => (
<div
key={row.model}
role="row"
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] items-center gap-3 border-t border-[var(--color-border)] px-4 py-2.5 text-[13px] tabular-nums text-[var(--color-text-primary)]"
>
<span role="cell" className="min-w-0 truncate font-mono text-xs" title={row.model}>{row.model}</span>
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.fresh)}</span>
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.cached)}</span>
<span role="cell" className="min-w-0 truncate text-right">
{row.unpriced ? <span className="text-[var(--color-text-tertiary)]">—</span> : formatCostUSD(row.costUSD, locale)}
</span>
</div>
))}
</Card>
</SettingsSection>
)}
{topPluginItems.length > 0 && ( {topPluginItems.length > 0 && (
<SettingsSection title={t('settings.activity.mostUsedPluginsAndSkills')}> <SettingsSection title={t('settings.activity.mostUsedPluginsAndSkills')}>
<Card surface="lowest" padding="none" className="overflow-hidden"> <Card surface="lowest" padding="none" className="overflow-hidden">
@@ -237,7 +237,19 @@ describe('retired sponsor providers', () => {
expect(dialog.queryByRole('button', { name: provider.name })).not.toBeInTheDocument() expect(dialog.queryByRole('button', { name: provider.name })).not.toBeInTheDocument()
} }
expect(dialog.getByRole('button', { name: 'Atlas Cloud' })).toBeInTheDocument() expect(dialog.getByRole('button', { name: 'Atlas Cloud' })).toBeInTheDocument()
expect(dialog.getByRole('button', { name: 'Custom' })).toBeInTheDocument() expect(dialog.getByRole('button', { name: 'Custom API' })).toBeInTheDocument()
})
it('labels the custom preset in the active locale and prefills that label as the name', async () => {
useSettingsStore.setState({ locale: 'zh' })
render(<ProviderSettings />)
await screen.findByTestId(`provider-${savedProviders[0]!.id}`)
fireEvent.click(screen.getByRole('button', { name: /添加模型/ }))
const dialog = within(screen.getByRole('dialog'))
expect(dialog.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument()
fireEvent.click(dialog.getByRole('button', { name: '自定义模型 API' }))
expect(dialog.getByLabelText(/配置名称/)).toHaveValue('自定义模型 API')
}) })
it.each(savedProviders)('edits and saves an existing $presetId provider without losing its connection', async (provider) => { it.each(savedProviders)('edits and saves an existing $presetId provider without losing its connection', async (provider) => {
@@ -1173,6 +1173,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
const initialPreset = provider const initialPreset = provider
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset ? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
: selectablePresets[0] ?? fallbackPreset : selectablePresets[0] ?? fallbackPreset
// The custom preset's catalog name is a bare English "Custom"; show a localized
// label that says what it is for.
const presetLabel = (preset: ProviderPreset) =>
preset.id === 'custom' ? t('settings.providers.customPreset') : preset.name
const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels) const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels)
const initialImageGeneration = provider const initialImageGeneration = provider
? provider.imageGeneration ? provider.imageGeneration
@@ -1187,7 +1191,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
) )
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset) const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
const [name, setName] = useState(provider?.name ?? initialPreset.name) const [name, setName] = useState(provider?.name ?? presetLabel(initialPreset))
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
// A preset that decides the protocol per model owns this field: the picked value // A preset that decides the protocol per model owns this field: the picked value
// is only the fallback for models no rule matches, so a record carrying a stale // is only the fallback for models no rule matches, so a record carrying a stale
@@ -1349,7 +1353,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
settingsJsonUserEditedRef.current = false settingsJsonUserEditedRef.current = false
setSelectedPreset(preset) setSelectedPreset(preset)
setCompatibility(compatibilityForm()) setCompatibility(compatibilityForm())
setName(preset.name) setName(presetLabel(preset))
setBaseUrl(preset.baseUrl) setBaseUrl(preset.baseUrl)
setImageGeneration({ setImageGeneration({
enabled: Boolean(preset.defaultImageGeneration), enabled: Boolean(preset.defaultImageGeneration),
@@ -1687,14 +1691,14 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
const renderPresetButton = (preset: ProviderPreset) => ( const renderPresetButton = (preset: ProviderPreset) => (
<SettingsPill <SettingsPill
key={preset.id} key={preset.id}
aria-label={preset.name} aria-label={presetLabel(preset)}
className="relative" className="relative"
tone="terracotta" tone="terracotta"
selected={selectedPreset.id === preset.id} selected={selectedPreset.id === preset.id}
onClick={() => handlePresetChange(preset)} onClick={() => handlePresetChange(preset)}
> >
{preset.id === 'aruhub' && <img src={aruhubLogo} alt="" className="size-4 rounded-[var(--radius-sm)] object-contain" />} {preset.id === 'aruhub' && <img src={aruhubLogo} alt="" className="size-4 rounded-[var(--radius-sm)] object-contain" />}
{preset.name} {presetLabel(preset)}
{preset.id === 'aruhub' && <Star size={12} className="fill-[var(--color-warning)] text-[var(--color-warning)]" aria-label={t('settings.providers.sponsor')} />} {preset.id === 'aruhub' && <Star size={12} className="fill-[var(--color-warning)] text-[var(--color-warning)]" aria-label={t('settings.providers.sponsor')} />}
{preset.isNew && ( {preset.isNew && (
<Badge tone="warning" size="xs" className="absolute -right-1 -top-2"> <Badge tone="warning" size="xs" className="absolute -right-1 -top-2">
@@ -78,6 +78,29 @@ describe('createEditBubble', () => {
bubble.destroy() bubble.destroy()
}) })
it('opens at full height beside the click when the picked element fills the view', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 720 })
const el = document.getElementById('t')!
// A picked <body>: taller than the viewport, no room above or below.
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, top: 0, right: 1024, bottom: 1400, left: 0, width: 1024, height: 1400, toJSON: () => ({}),
})
const atClick = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn(), pointer: { x: 300, y: 200 } })
expect(atClick.host.style.top).toBe('208px')
expect(atClick.host.style.left).toBe('308px')
// Full estimated height (380), not squeezed to the 160px minimum.
expect((atClick.host.shadowRoot!.querySelector('.bubble') as HTMLElement).style.maxHeight).toBe('504px')
atClick.destroy()
// A click near the bottom right lifts and shifts it just enough to fit.
const nearEdge = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn(), pointer: { x: 1000, y: 700 } })
expect(nearEdge.host.style.top).toBe('332px')
expect(nearEdge.host.style.left).toBe('676px')
nearEdge.destroy()
})
it('prefills the text field and live-applies edits to the element', () => { it('prefills the text field and live-applies edits to the element', () => {
const el = document.getElementById('t')! const el = document.getElementById('t')!
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() }) const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
+19 -7
View File
@@ -17,7 +17,10 @@ type Deps = {
onCancel: () => void onCancel: () => void
mode?: 'single' | 'batch' mode?: 'single' | 'batch'
copy?: EditBubbleCopy copy?: EditBubbleCopy
/** Where the pick was clicked, in viewport coordinates. */
pointer?: BubblePointer
} }
type BubblePointer = { x: number; y: number }
const DEFAULT_COPY: EditBubbleCopy = { const DEFAULT_COPY: EditBubbleCopy = {
cancel: '取消', cancel: '取消',
@@ -224,7 +227,11 @@ function buildPatch(key: keyof EditableSnapshot, value: string): EditInput {
return patch return patch
} }
function computeBubbleLayout(rect: DOMRect, contentHeight: number) { function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max))
}
function computeBubbleLayout(rect: DOMRect, contentHeight: number, pointer?: BubblePointer) {
const viewportWidth = Math.max(window.innerWidth || 0, BUBBLE_WIDTH + VIEWPORT_MARGIN * 2) const viewportWidth = Math.max(window.innerWidth || 0, BUBBLE_WIDTH + VIEWPORT_MARGIN * 2)
const viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2) const viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2)
const desiredHeight = Math.min( const desiredHeight = Math.min(
@@ -234,21 +241,26 @@ function computeBubbleLayout(rect: DOMRect, contentHeight: number) {
const belowTop = rect.bottom + BUBBLE_GAP const belowTop = rect.bottom + BUBBLE_GAP
const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop
const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN
const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN)
let top: number let top: number
let left = rect.left
if (spaceBelow >= desiredHeight) { if (spaceBelow >= desiredHeight) {
top = belowTop top = belowTop
} else if (spaceAbove >= desiredHeight) { } else if (spaceAbove >= desiredHeight) {
top = rect.top - BUBBLE_GAP - desiredHeight top = rect.top - BUBBLE_GAP - desiredHeight
} else if (spaceAbove > spaceBelow) {
top = VIEWPORT_MARGIN
} else { } else {
top = Math.min(Math.max(belowTop, VIEWPORT_MARGIN), viewportHeight - VIEWPORT_MARGIN - BUBBLE_MIN_HEIGHT) // Neither side has room: the target fills the view (a picked <body>, a
// full-bleed section). The bubble has to cover part of it anyway, so it
// opens at full height where the pick was clicked rather than squeezing
// into a sliver at whichever edge had a little more space.
const anchor = pointer ?? { x: rect.left, y: rect.top }
top = clamp(anchor.y + BUBBLE_GAP, VIEWPORT_MARGIN, viewportHeight - VIEWPORT_MARGIN - desiredHeight)
left = anchor.x + BUBBLE_GAP
} }
top = Math.max(VIEWPORT_MARGIN, Math.round(top)) top = Math.max(VIEWPORT_MARGIN, Math.round(top))
const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN) left = clamp(Math.round(left), VIEWPORT_MARGIN, maxLeft)
const left = Math.max(VIEWPORT_MARGIN, Math.min(Math.round(rect.left), maxLeft))
const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top) const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top)
return { top, left, maxHeight } return { top, left, maxHeight }
} }
@@ -523,7 +535,7 @@ export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLE
if (textArea) autoGrow(textArea, TEXT_AREA_MAX_HEIGHT) if (textArea) autoGrow(textArea, TEXT_AREA_MAX_HEIGHT)
const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT
const layout = computeBubbleLayout(rect, measuredHeight) const layout = computeBubbleLayout(rect, measuredHeight, deps.pointer)
host.style.setProperty('top', `${layout.top}px`) host.style.setProperty('top', `${layout.top}px`)
host.style.setProperty('left', `${layout.left}px`) host.style.setProperty('left', `${layout.left}px`)
host.style.setProperty('visibility', 'visible') host.style.setProperty('visibility', 'visible')
+27
View File
@@ -94,6 +94,33 @@ describe('preview agent picker flow', () => {
if (mode === 'bubble') expect(bubbleButton('confirm').isConnected).toBe(true) if (mode === 'bubble') expect(bubbleButton('confirm').isConnected).toBe(true)
}) })
it('holds the native capture until the page has painted without the bubble', async () => {
const frames: FrameRequestCallback[] = []
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => frames.push(callback))
try {
const captureId = confirmSelection()
// By the time the host captures, the bubble is gone and the annotation is up…
expect(document.querySelector('[data-preview-selection-annotation]')).not.toBeNull()
expect(() => bubbleButton('confirm')).toThrow()
// …but the frame showing that only exists after the page paints again.
const hide = (window as unknown as { __PREVIEW_AGENT_SET_CHROME_HIDDEN__: (hidden: boolean) => Promise<void> | undefined })
.__PREVIEW_AGENT_SET_CHROME_HIDDEN__
let painted = false
const ready = hide(true)!.then(() => { painted = true })
await Promise.resolve()
expect(painted).toBe(false)
frames.shift()!(0)
frames.shift()!(16)
await ready
expect(painted).toBe(true)
expect(hide(false)).toBeUndefined()
finishNativeCapture(captureId)
} finally {
raf.mockRestore()
}
})
it('does not let an older capture remove the newest annotation overlay', () => { it('does not let an older capture remove the newest annotation overlay', () => {
const chrome = showZoomControls() const chrome = showZoomControls()
const oldCapture = confirmSelection() const oldCapture = confirmSelection()
+9 -1
View File
@@ -4,6 +4,7 @@ import { createPicker } from './picker'
import { buildElementMetadata } from './metadata' import { buildElementMetadata } from './metadata'
import { createEditBubble, type EditBubbleCopy } from './editBubble' import { createEditBubble, type EditBubbleCopy } from './editBubble'
import { createZoomControls } from './zoomControls' import { createZoomControls } from './zoomControls'
import { afterNextPaint } from './paint'
;(() => { ;(() => {
;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true ;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true
@@ -24,7 +25,13 @@ import { createZoomControls } from './zoomControls'
previewWindow.__PREVIEW_BRIDGE__ = bridge previewWindow.__PREVIEW_BRIDGE__ = bridge
previewWindow.__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl previewWindow.__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl
const zoomControls = createZoomControls(action => bridge.send({ type: 'browser-zoom', action })) const zoomControls = createZoomControls(action => bridge.send({ type: 'browser-zoom', action }))
previewWindow.__PREVIEW_AGENT_SET_CHROME_HIDDEN__ = (hidden: boolean) => zoomControls.setCaptureSuppressed(hidden) // The host awaits this before a native capture, so hiding resolves only once
// the page has painted without its chrome, the closed edit bubble and with
// the selection annotation in place.
previewWindow.__PREVIEW_AGENT_SET_CHROME_HIDDEN__ = (hidden: boolean) => {
zoomControls.setCaptureSuppressed(hidden)
return hidden ? afterNextPaint() : undefined
}
bridge.on('browser-controls', message => zoomControls.update(message)) bridge.on('browser-controls', message => zoomControls.update(message))
window.addEventListener('pagehide', () => zoomControls.destroy()) window.addEventListener('pagehide', () => zoomControls.destroy())
window.addEventListener('pageshow', () => zoomControls.restore()) window.addEventListener('pageshow', () => zoomControls.restore())
@@ -197,6 +204,7 @@ import { createZoomControls } from './zoomControls'
}, },
mode: pickerMode, mode: pickerMode,
copy: pickerCopy, copy: pickerCopy,
pointer: { x: e.clientX, y: e.clientY },
}) })
updateChromeSuppression() updateChromeSuppression()
}, true) }, true)
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterNextPaint } from './paint'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('afterNextPaint', () => {
it('resolves only after a second animation frame, once the first has been drawn', async () => {
const frames: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => frames.push(callback))
let settled = false
const done = afterNextPaint().then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
frames.shift()!(0)
await Promise.resolve()
expect(settled).toBe(false)
frames.shift()!(16)
await done
expect(settled).toBe(true)
})
it('gives up after the timeout when the page never paints', async () => {
vi.useFakeTimers()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0)
let settled = false
const done = afterNextPaint(250).then(() => { settled = true })
await vi.advanceTimersByTimeAsync(249)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await done
expect(settled).toBe(true)
})
})
+19
View File
@@ -0,0 +1,19 @@
/** Bounds the wait: a guest that is not on screen never runs animation frames. */
const PAINT_TIMEOUT_MS = 250
/**
* Resolves once the page has drawn a frame with its current DOM. The second
* animation frame only runs after the first has been produced, so a native
* `capturePage` that copies the guest's latest frame sees every change made
* before this call — a <webview> guest does not repaint on its own before
* being captured, and the copy would otherwise still show a closed edit bubble.
*/
export function afterNextPaint(timeoutMs = PAINT_TIMEOUT_MS): Promise<void> {
return new Promise(resolve => {
const timer = window.setTimeout(resolve, timeoutMs)
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
window.clearTimeout(timer)
resolve()
}))
})
}