diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index a4ea8c69..51d634e9 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -2392,7 +2392,7 @@ describe('Settings > Providers tab', () => { fireEvent.click(addButton) 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() const baseUrlInfo = within(dialog).getByRole('button', { name: 'Base URL help' }) diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx index 1099f8eb..46f6c778 100644 --- a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx @@ -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-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' })) expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull() 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 expect(timeline.value).toBe('1000') diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx index e3f94c94..1d04d376 100644 --- a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx @@ -321,9 +321,12 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { 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)]" > -
-
-
+ {/* A narrow pane wraps the controls onto a row of their own instead of + scrolling the header sideways; the team block and counters stay on + the first row. Nothing in a control wraps, only hint text truncates. */} +
+
+
Agent Teams · {t('agentTeams.sharedTaskList')}
@@ -340,7 +343,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
-
+
{t('agentTeams.stats.phase')} @@ -359,7 +362,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
{followingLive ? ( -
+
) : ( -
+
-
-
- {t('agentTeams.replay.timeline')} - +
+
+ {t('agentTeams.replay.timeline')} + {formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
@@ -472,6 +475,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
} - return
event.preventDefault()}> -
+ return
event.preventDefault()}> +
{searchRow} {searching ? referenceMenu : rootList}
- {flyoutOpen ?
+ {flyoutOpen ?
{path.length > 1 ? } label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null} {drillParent!.label} diff --git a/desktop/src/components/ui/IconButton.test.tsx b/desktop/src/components/ui/IconButton.test.tsx index 97c1e3e0..d586240e 100644 --- a/desktop/src/components/ui/IconButton.test.tsx +++ b/desktop/src/components/ui/IconButton.test.tsx @@ -106,6 +106,15 @@ describe('IconButton', () => { 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(} 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', () => { const { container } = render(} label="Filter" pressed />) const className = container.firstElementChild!.className diff --git a/desktop/src/components/ui/IconButton.tsx b/desktop/src/components/ui/IconButton.tsx index 2de1f792..d46681fb 100644 --- a/desktop/src/components/ui/IconButton.tsx +++ b/desktop/src/components/ui/IconButton.tsx @@ -224,7 +224,10 @@ const SOFT_CLASSES = 'bg-[var(--color-btn-soft-bg)] hover:bg-[var(--color-btn-so const PRESSED_CLASSES: Record = { 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)]', media: 'bg-[var(--color-media-selection)] text-[var(--color-media-fg)]', } @@ -288,9 +291,10 @@ export const IconButton = forwardRef(functio BASE_CLASSES, DISABLED_OPACITY[disabledStyle], SIZE_CLASSES[size], - // `solid` supplies both fill and foreground, so the tone's resting text - // color and hover fill are skipped rather than left to compete. - solid ? SOLID_CLASSES[tone] : (SURFACE_REST_TEXT[surface] ?? REST_TEXT)[tone], + // `solid` and `pressed` supply both fill and foreground, so the tone's + // resting text color is skipped rather than left to compete: two + // `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', // A pressed or soft button carries its own fill and hover; skipping the // tone's hover here keeps two `hover:bg-[…]` values from competing. diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b60d3481..5e42b6e8 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1197,6 +1197,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.activity.messages': 'Messages', 'settings.activity.tools': 'Tools', 'settings.activity.activityInsights': 'Activity insights', + 'settings.activity.modelUsage': 'Usage by model', 'settings.activity.activeRate': 'Active rate', 'settings.activity.mostUsedModel': 'Most used model', '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.regionGlobal': 'Global', 'settings.providers.regionCustom': 'Custom', + 'settings.providers.customPreset': 'Custom API', 'settings.providers.apiKey': 'API Key', 'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)', 'settings.providers.showApiKey': 'Show API Key', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 01dfff53..88c3ffc7 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1198,6 +1198,7 @@ export const jp: Record = { 'settings.activity.messages': 'メッセージ', 'settings.activity.tools': 'ツール', 'settings.activity.activityInsights': 'アクティビティ分析', + 'settings.activity.modelUsage': 'モデル別の使用量', 'settings.activity.activeRate': 'アクティブ率', 'settings.activity.mostUsedModel': '最も使ったモデル', 'settings.activity.exploredSkills': '使用したスキル', @@ -1443,6 +1444,7 @@ export const jp: Record = { 'settings.providers.regionChina': '中国本土', 'settings.providers.regionGlobal': 'グローバル', 'settings.providers.regionCustom': 'カスタム', + 'settings.providers.customPreset': 'カスタム API', 'settings.providers.apiKey': 'API キー', 'settings.providers.apiKeyKeep': 'API キー(空欄のままにすると現在の値を保持)', 'settings.providers.showApiKey': 'API キーを表示', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 1dcc4549..0d14cece 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1200,6 +1200,7 @@ export const kr: Record = { 'settings.activity.messages': '메시지', 'settings.activity.tools': '도구', 'settings.activity.activityInsights': '활동 인사이트', + 'settings.activity.modelUsage': '모델별 사용량', 'settings.activity.activeRate': '활성 비율', 'settings.activity.mostUsedModel': '가장 많이 쓴 모델', 'settings.activity.exploredSkills': '사용한 스킬', @@ -1445,6 +1446,7 @@ export const kr: Record = { 'settings.providers.regionChina': '중국 본토', 'settings.providers.regionGlobal': '글로벌', 'settings.providers.regionCustom': '사용자 지정', + 'settings.providers.customPreset': '사용자 지정 API', 'settings.providers.apiKey': 'API 키', 'settings.providers.apiKeyKeep': 'API 키 (비워 두면 현재 값 유지)', 'settings.providers.showApiKey': 'API 키 표시', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 1bc92abd..4f990439 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1197,6 +1197,7 @@ export const zh: Record = { 'settings.activity.messages': '訊息', 'settings.activity.tools': '工具', 'settings.activity.activityInsights': '活動洞察', + 'settings.activity.modelUsage': '模型用量', 'settings.activity.activeRate': '活躍率', 'settings.activity.mostUsedModel': '最常用模型', 'settings.activity.exploredSkills': '已使用的技能', @@ -1442,6 +1443,7 @@ export const zh: Record = { 'settings.providers.regionChina': '中國大陸', 'settings.providers.regionGlobal': '海外', 'settings.providers.regionCustom': '自訂', + 'settings.providers.customPreset': '自訂模型 API', 'settings.providers.apiKey': 'API 金鑰', 'settings.providers.apiKeyKeep': 'API 金鑰(留空保持不變)', 'settings.providers.showApiKey': '顯示 API 金鑰', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ed9ad7ad..3b1447ca 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1196,6 +1196,7 @@ export const zh: Record = { 'settings.activity.messages': '消息', 'settings.activity.tools': '工具', 'settings.activity.activityInsights': '活动洞察', + 'settings.activity.modelUsage': '模型用量', 'settings.activity.activeRate': '活跃率', 'settings.activity.mostUsedModel': '最常用模型', 'settings.activity.exploredSkills': '已使用的技能', @@ -1441,6 +1442,7 @@ export const zh: Record = { 'settings.providers.regionChina': '中国大陆', 'settings.providers.regionGlobal': '海外', 'settings.providers.regionCustom': '自定义', + 'settings.providers.customPreset': '自定义模型 API', 'settings.providers.apiKey': 'API 密钥', 'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)', 'settings.providers.showApiKey': '显示 API 密钥', diff --git a/desktop/src/pages/ActivitySettings.test.tsx b/desktop/src/pages/ActivitySettings.test.tsx index e3cb31d6..7df91fa3 100644 --- a/desktop/src/pages/ActivitySettings.test.tsx +++ b/desktop/src/pages/ActivitySettings.test.tsx @@ -412,6 +412,22 @@ describe('ActivitySettings', () => { ]) }) + it('orders the page usage, then activity, then the per-model breakdown', async () => { + render() + + 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 () => { useSettingsStore.setState({ locale: 'zh' }) render() diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index 731c8131..01aa2417 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -1001,34 +1001,6 @@ export function ActivitySettings() { )} - {!isLoading && !error && hasUsage && modelRows.length > 0 && ( - -
- {t('settings.agents.model')} - {t('settings.activity.freshTokens')} - {t('settings.activity.cachedTokens')} - {t('settings.activity.estimatedCost')} -
- {modelRows.map((row) => ( -
- {row.model} - {formatTokens(row.fresh)} - {formatTokens(row.cached)} - - {row.unpriced ? — : formatCostUSD(row.costUSD, locale)} - -
- ))} -
- )} - + {modelRows.length > 0 && ( + + +
+ {t('settings.agents.model')} + {t('settings.activity.freshTokens')} + {t('settings.activity.cachedTokens')} + {t('settings.activity.estimatedCost')} +
+ {modelRows.map((row) => ( +
+ {row.model} + {formatTokens(row.fresh)} + {formatTokens(row.cached)} + + {row.unpriced ? — : formatCostUSD(row.costUSD, locale)} + +
+ ))} +
+
+ )} + {topPluginItems.length > 0 && ( diff --git a/desktop/src/pages/settings/ProviderSettings.test.tsx b/desktop/src/pages/settings/ProviderSettings.test.tsx index 3cd01ea0..57529ca1 100644 --- a/desktop/src/pages/settings/ProviderSettings.test.tsx +++ b/desktop/src/pages/settings/ProviderSettings.test.tsx @@ -237,7 +237,19 @@ describe('retired sponsor providers', () => { expect(dialog.queryByRole('button', { name: provider.name })).not.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() + 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) => { diff --git a/desktop/src/pages/settings/ProviderSettings.tsx b/desktop/src/pages/settings/ProviderSettings.tsx index a327e9b7..90971504 100644 --- a/desktop/src/pages/settings/ProviderSettings.tsx +++ b/desktop/src/pages/settings/ProviderSettings.tsx @@ -1173,6 +1173,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) ?? 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 initialImageGeneration = provider ? provider.imageGeneration @@ -1187,7 +1191,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode ) const [selectedPreset, setSelectedPreset] = useState(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) // 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 @@ -1349,7 +1353,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode settingsJsonUserEditedRef.current = false setSelectedPreset(preset) setCompatibility(compatibilityForm()) - setName(preset.name) + setName(presetLabel(preset)) setBaseUrl(preset.baseUrl) setImageGeneration({ enabled: Boolean(preset.defaultImageGeneration), @@ -1687,14 +1691,14 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode const renderPresetButton = (preset: ProviderPreset) => ( handlePresetChange(preset)} > {preset.id === 'aruhub' && } - {preset.name} + {presetLabel(preset)} {preset.id === 'aruhub' && } {preset.isNew && ( diff --git a/desktop/src/preview-agent/editBubble.test.ts b/desktop/src/preview-agent/editBubble.test.ts index 6930558f..e144b91f 100644 --- a/desktop/src/preview-agent/editBubble.test.ts +++ b/desktop/src/preview-agent/editBubble.test.ts @@ -78,6 +78,29 @@ describe('createEditBubble', () => { 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 : 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', () => { const el = document.getElementById('t')! const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() }) diff --git a/desktop/src/preview-agent/editBubble.ts b/desktop/src/preview-agent/editBubble.ts index b9ce84f8..9f0ec155 100644 --- a/desktop/src/preview-agent/editBubble.ts +++ b/desktop/src/preview-agent/editBubble.ts @@ -17,7 +17,10 @@ type Deps = { onCancel: () => void mode?: 'single' | 'batch' copy?: EditBubbleCopy + /** Where the pick was clicked, in viewport coordinates. */ + pointer?: BubblePointer } +type BubblePointer = { x: number; y: number } const DEFAULT_COPY: EditBubbleCopy = { cancel: '取消', @@ -224,7 +227,11 @@ function buildPatch(key: keyof EditableSnapshot, value: string): EditInput { 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 viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2) const desiredHeight = Math.min( @@ -234,21 +241,26 @@ function computeBubbleLayout(rect: DOMRect, contentHeight: number) { const belowTop = rect.bottom + BUBBLE_GAP const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN + const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN) let top: number + let left = rect.left if (spaceBelow >= desiredHeight) { top = belowTop } else if (spaceAbove >= desiredHeight) { top = rect.top - BUBBLE_GAP - desiredHeight - } else if (spaceAbove > spaceBelow) { - top = VIEWPORT_MARGIN } 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 , 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)) - const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN) - const left = Math.max(VIEWPORT_MARGIN, Math.min(Math.round(rect.left), maxLeft)) + left = clamp(Math.round(left), VIEWPORT_MARGIN, maxLeft) const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top) 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) 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('left', `${layout.left}px`) host.style.setProperty('visibility', 'visible') diff --git a/desktop/src/preview-agent/index.test.ts b/desktop/src/preview-agent/index.test.ts index 2eeff689..10f11486 100644 --- a/desktop/src/preview-agent/index.test.ts +++ b/desktop/src/preview-agent/index.test.ts @@ -94,6 +94,33 @@ describe('preview agent picker flow', () => { 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 | 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', () => { const chrome = showZoomControls() const oldCapture = confirmSelection() diff --git a/desktop/src/preview-agent/index.ts b/desktop/src/preview-agent/index.ts index 2077d982..a7f33ca9 100644 --- a/desktop/src/preview-agent/index.ts +++ b/desktop/src/preview-agent/index.ts @@ -4,6 +4,7 @@ import { createPicker } from './picker' import { buildElementMetadata } from './metadata' import { createEditBubble, type EditBubbleCopy } from './editBubble' import { createZoomControls } from './zoomControls' +import { afterNextPaint } from './paint' ;(() => { ;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true @@ -24,7 +25,13 @@ import { createZoomControls } from './zoomControls' previewWindow.__PREVIEW_BRIDGE__ = bridge previewWindow.__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl 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)) window.addEventListener('pagehide', () => zoomControls.destroy()) window.addEventListener('pageshow', () => zoomControls.restore()) @@ -197,6 +204,7 @@ import { createZoomControls } from './zoomControls' }, mode: pickerMode, copy: pickerCopy, + pointer: { x: e.clientX, y: e.clientY }, }) updateChromeSuppression() }, true) diff --git a/desktop/src/preview-agent/paint.test.ts b/desktop/src/preview-agent/paint.test.ts new file mode 100644 index 00000000..30f64a2e --- /dev/null +++ b/desktop/src/preview-agent/paint.test.ts @@ -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) + }) +}) diff --git a/desktop/src/preview-agent/paint.ts b/desktop/src/preview-agent/paint.ts new file mode 100644 index 00000000..7ba29826 --- /dev/null +++ b/desktop/src/preview-agent/paint.ts @@ -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 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 { + return new Promise(resolve => { + const timer = window.setTimeout(resolve, timeoutMs) + window.requestAnimationFrame(() => window.requestAnimationFrame(() => { + window.clearTimeout(timer) + resolve() + })) + }) +}