-
-
{{ t('desktop.context.browserStartBrowsing') }}
+
+
+
+
{{ t('desktop.context.browserStartBrowsing') }}
+
-
+
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue
index 93752fec..fbd607a9 100644
--- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserToolbar.vue
@@ -15,11 +15,13 @@ import {
ArrowLeft16Regular,
ArrowRight16Regular,
Camera20Regular,
+ Code20Regular,
FolderOpen20Regular,
Globe16Regular,
LockClosed16Regular,
MoreHorizontal20Regular,
Open20Regular,
+ PhoneDesktop20Regular,
Stop16Regular,
TabInPrivate20Regular,
Warning16Regular,
@@ -32,6 +34,8 @@ import { getBrowserToolbarMenuActions } from './browserToolbarMenu'
import DesktopBrowserZoomControls from './DesktopBrowserZoomControls.vue'
const props = defineProps<{
+ responsive?: boolean
+ responsiveBusy?: boolean
busyAction: BrowserToolbarBusyAction | null
language: BuddyLocale
state: DesktopBrowserState | null
@@ -78,6 +82,8 @@ const menuIconByAction: Record
= {
'capture-screenshot': Camera20Regular,
'enter-incognito': TabInPrivate20Regular,
'exit-incognito': TabInPrivate20Regular,
+ 'responsive-viewport': PhoneDesktop20Regular,
+ 'open-devtools': Code20Regular,
'open-external': Open20Regular,
'show-file-in-folder': FolderOpen20Regular,
}
@@ -86,6 +92,8 @@ const menuOptions = computed(() => {
busyAction: props.busyAction,
controller: props.state?.controller ?? 'human',
profileMode: props.state?.profileMode ?? 'default',
+ responsive: props.responsive,
+ responsiveBusy: props.responsiveBusy,
url: props.state?.url ?? 'about:blank',
})
return [{
@@ -115,6 +123,8 @@ function handleMenuAction(value: string | number): void {
busyAction: props.busyAction,
controller: props.state?.controller ?? 'human',
profileMode: props.state?.profileMode ?? 'default',
+ responsive: props.responsive,
+ responsiveBusy: props.responsiveBusy,
url: props.state?.url ?? 'about:blank',
}).find(action => action.key === value)
if (action && !action.disabled)
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserViewportControls.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserViewportControls.vue
new file mode 100644
index 00000000..ebb1cecb
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopBrowserViewportControls.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ ×
+
+
+ {{ t('desktop.context.browserViewportRange', { max: invalid === 'width' ? '3840' : '2160' }) }}
+
+
+
+
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue
index d88cf0d6..58b43f1d 100644
--- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue
@@ -2,7 +2,7 @@
import type { ContextPanelTab } from '../../model/context-panel/taskContextPanel'
import type { BuddyLocale } from '@/i18n/buddyI18n'
import { Code16Regular, Folder20Regular, Globe16Regular } from '@vicons/fluent'
-import { computed } from 'vue'
+import { computed, shallowRef } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'
import { FileIcon, FolderIcon } from '@/shared/ui/file-icon'
import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue'
@@ -22,6 +22,10 @@ const emit = defineEmits<{
}>()
defineSlots<{ default?: () => unknown, toolbar?: () => unknown }>()
const { t } = useBuddyI18n(() => props.language)
+const failedFavicons = shallowRef>(new Set())
+function faviconFailed(url: string): void {
+ failedFavicons.value = new Set([...failedFavicons.value, url].slice(-64))
+}
const actions = computed(() => [
...(props.canAddChanges ? [{ id: 'changes' as const, label: t('desktop.context.changes'), icon: Code16Regular }] : []),
...(props.canAddFiles ? [{ id: 'files' as const, label: t('desktop.context.files'), icon: Folder20Regular }] : []),
@@ -39,6 +43,7 @@ function add(id: string) {
+
@@ -49,5 +54,6 @@ function add(id: string) {
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue
index b3516d15..b5146dff 100644
--- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskResourcePanel.vue
@@ -36,7 +36,7 @@ const tabs = computed(() => props.panel.tabs.value.map((tab)
const title = !state || state.url === 'about:blank'
? t('desktop.context.browserNewTab')
: state.title.trim() || t('desktop.context.browser')
- return { id: tab.id, title, icon: 'browser' }
+ return { id: tab.id, title, icon: 'browser', favicon: state?.favicon ?? null }
}))
function add(kind: 'changes' | 'files' | 'browser') {
if (kind === 'files') {
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserAddress.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserAddress.spec.ts
new file mode 100644
index 00000000..949ef972
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserAddress.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeBrowserAddress } from '../browserAddress'
+
+describe('browser address normalization', () => {
+ it.each([
+ ['localhost:5173', 'http://localhost:5173/'],
+ ['127.0.0.1:3000/app', 'http://127.0.0.1:3000/app'],
+ ['[::1]:8080', 'http://[::1]:8080/'],
+ ['192.168.1.10', 'http://192.168.1.10/'],
+ ['10.0.0.4:8000', 'http://10.0.0.4:8000/'],
+ ['printer.local', 'http://printer.local/'],
+ ['example.com', 'https://example.com/'],
+ ['example.com/docs?q=1', 'https://example.com/docs?q=1'],
+ ['https://example.com/secure', 'https://example.com/secure'],
+ ])('resolves %s to %s', (input, expected) => {
+ expect(normalizeBrowserAddress(input)).toBe(expected)
+ })
+
+ it.each([
+ [''],
+ [' '],
+ ['ftp://example.com/file'],
+ ['javascript:alert(1)'],
+ ['hello world'],
+ ['not a url at all / spaces'],
+ ])('rejects %j instead of guessing a search request', (input) => {
+ expect(normalizeBrowserAddress(input)).toBeNull()
+ })
+})
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserNotice.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserNotice.spec.ts
new file mode 100644
index 00000000..952f28bf
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserNotice.spec.ts
@@ -0,0 +1,41 @@
+import type { BuddyTranslate } from '@/i18n/buddyI18n'
+import { describe, expect, it } from 'vitest'
+import { blockedActionNotice, downloadNotice, pageErrorNotice } from '../browserNotice'
+
+const t = ((key: string, params?: Record) => (
+ params ? `${key}:${JSON.stringify(params)}` : key
+)) as unknown as BuddyTranslate
+
+describe('browser notices', () => {
+ it.each([
+ ['popup', 'desktop.context.browserBlockedPopup'],
+ ['file-chooser', 'desktop.context.browserBlockedUpload'],
+ ['download', 'desktop.context.browserBlockedDownload'],
+ ['permission', 'desktop.context.browserBlockedPermission'],
+ ] as const)('explains a blocked %s without leaking diagnostics', (action, key) => {
+ expect(blockedActionNotice(action, t)).toBe(key)
+ })
+
+ it.each([
+ ['BROWSER_CERTIFICATE_ERROR', 'desktop.context.browserCertificateError'],
+ ['BROWSER_PAGE_CRASHED', 'desktop.context.browserPageCrashed'],
+ ['BROWSER_PAGE_FAILED', 'desktop.context.browserPageFailed'],
+ ['BROWSER_PAGE_UNRESPONSIVE', 'desktop.context.browserPageUnresponsive'],
+ ] as const)('names the page failure %s', (code, key) => {
+ expect(pageErrorNotice(code, t)).toBe(key)
+ })
+
+ it('falls back to the generic load failure for unclassified codes', () => {
+ expect(pageErrorNotice('BROWSER_SESSION_NOT_FOUND', t)).toBe('desktop.context.browserPageFailed')
+ })
+
+ it.each([
+ ['started', 'desktop.context.browserDownloadStarted'],
+ ['completed', 'desktop.context.browserDownloadCompleted'],
+ ['canceled', 'desktop.context.browserDownloadCanceled'],
+ ['failed', 'desktop.context.browserDownloadFailed'],
+ ] as const)('reports a %s download with its file name', (state, key) => {
+ expect(downloadNotice({ fileName: 'report.csv', path: null, state }, t))
+ .toBe(`${key}:{"name":"report.csv"}`)
+ })
+})
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserToolbarMenu.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserToolbarMenu.spec.ts
new file mode 100644
index 00000000..a1ba0b7b
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserToolbarMenu.spec.ts
@@ -0,0 +1,56 @@
+import type { DesktopBrowserProfileMode } from '@buddy-electron/shared/desktopApi'
+import type { BrowserToolbarBusyAction } from '../browserToolbarMenu'
+import { describe, expect, it } from 'vitest'
+import { getBrowserToolbarMenuActions } from '../browserToolbarMenu'
+
+interface MenuContext {
+ busyAction: BrowserToolbarBusyAction | null
+ controller: 'agent' | 'human'
+ profileMode: DesktopBrowserProfileMode
+ responsive?: boolean
+ responsiveBusy?: boolean
+ url: string
+}
+
+const page: MenuContext = {
+ busyAction: null,
+ controller: 'human',
+ profileMode: 'default',
+ url: 'https://example.com/',
+}
+
+function action(key: string, overrides: Partial = {}) {
+ return getBrowserToolbarMenuActions({ ...page, ...overrides }).find(candidate => candidate.key === key)
+}
+
+describe('browser toolbar menu', () => {
+ it('offers page developer tools for a human-controlled page', () => {
+ expect(action('open-devtools')).toMatchObject({
+ disabled: false,
+ labelKey: 'desktop.context.browserOpenDevTools',
+ })
+ })
+
+ it.each<[Partial, string]>([
+ [{ url: 'about:blank' }, 'an empty tab'],
+ [{ controller: 'agent' }, 'a page the agent controls'],
+ [{ busyAction: 'screenshot' }, 'a busy toolbar'],
+ ])('disables page developer tools for %s', (overrides) => {
+ expect(action('open-devtools', overrides)?.disabled).toBe(true)
+ })
+
+ it('offers responsive preview in More and labels the active mode as Exit', () => {
+ expect(action('responsive-viewport')).toMatchObject({ disabled: false, labelKey: 'desktop.context.browserResponsive' })
+ expect(action('responsive-viewport', { responsive: true })).toMatchObject({ disabled: false, labelKey: 'desktop.context.browserViewportExit' })
+ })
+
+ it('allows exiting an active preview on a blank page but preserves busy and agent guards', () => {
+ expect(action('responsive-viewport', { responsive: true, url: 'about:blank' })?.disabled).toBe(false)
+ expect(action('responsive-viewport', { responsive: true, responsiveBusy: true })?.disabled).toBe(true)
+ expect(action('responsive-viewport', { responsive: true, controller: 'agent' })?.disabled).toBe(true)
+ })
+
+ it('keeps developer tools available in private browsing', () => {
+ expect(action('open-devtools', { profileMode: 'incognito' })?.disabled).toBe(false)
+ })
+})
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserViewport.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserViewport.spec.ts
new file mode 100644
index 00000000..fc4e7f15
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/browserViewport.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import { BROWSER_VIEWPORT_DEVICES, browserPreviewScale, browserViewportDeviceForSize, clampViewportSize, viewportDimensionValid } from '../browserViewport'
+
+const size = { width: 393, height: 852 }
+describe('responsive browser preview', () => {
+ it('fits inside the padded canvas without changing the logical viewport', () => {
+ expect(browserPreviewScale(size, { width: 800, height: 458 }, 'fit')).toBe(0.5)
+ expect(size).toEqual({ width: 393, height: 852 })
+ expect(browserPreviewScale(size, { width: 4000, height: 4000 }, 'fit')).toBe(1)
+ })
+ it('handles hidden or tiny canvases without zero or invalid scale', () => {
+ expect(browserPreviewScale(size, { width: 0, height: 0 }, 'fit')).toBe(1)
+ expect(browserPreviewScale(size, { width: 33, height: 33 }, 'fit')).toBe(0.1)
+ })
+ it.each([['25', 0.25], ['50', 0.5], ['100', 1], ['200', 2]] as const)('keeps fixed %s%% independent of panel size', (zoom, scale) => {
+ expect(browserPreviewScale(size, { width: 100, height: 100 }, zoom)).toBe(scale)
+ })
+ it('rejects invalid dimensions instead of silently applying a clamped draft', () => {
+ expect(viewportDimensionValid('width', 239)).toBe(false)
+ expect(viewportDimensionValid('width', 3841)).toBe(false)
+ expect(viewportDimensionValid('height', 2161)).toBe(false)
+ expect(viewportDimensionValid('width', 393.5)).toBe(false)
+ expect(viewportDimensionValid('height', Number.NaN)).toBe(false)
+ expect(viewportDimensionValid('width', 3840)).toBe(true)
+ expect(viewportDimensionValid('height', 2160)).toBe(true)
+ })
+ it('matches named device sizes and keeps arbitrary dimensions custom', () => {
+ for (const device of BROWSER_VIEWPORT_DEVICES) {
+ expect(viewportDimensionValid('width', device.width)).toBe(true)
+ expect(viewportDimensionValid('height', device.height)).toBe(true)
+ expect(browserViewportDeviceForSize(device)).toBe(device.id)
+ }
+ expect(browserViewportDeviceForSize({ width: 431, height: 701 })).toBe('custom')
+ })
+
+ it('bounds drag dimensions and rounds only the committed logical size', () => {
+ expect(clampViewportSize({ width: 50, height: 5000 })).toEqual({ width: 240, height: 2160 })
+ expect(clampViewportSize({ width: 393.7, height: 851.2 })).toEqual({ width: 394, height: 851 })
+ })
+})
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useBrowserResponsiveViewport.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useBrowserResponsiveViewport.spec.ts
new file mode 100644
index 00000000..46154fe0
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useBrowserResponsiveViewport.spec.ts
@@ -0,0 +1,78 @@
+// @vitest-environment jsdom
+import type { DesktopBrowserState, DesktopBrowserViewport } from '@buddy-shared/browser/browserDesktopApi'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createApp, nextTick, shallowRef } from 'vue'
+import { useBrowserResponsiveViewport } from '../useBrowserResponsiveViewport'
+
+const cleanups: (() => void)[] = []
+afterEach(() => cleanups.splice(0).forEach(cleanup => cleanup()))
+function fixture() {
+ const state = shallowRef({ sessionId: 'session-a', pageId: 'page-a', conversationId: null, viewport: null, controller: 'human', controlEpoch: 0, visible: true, canGoBack: false, canGoForward: false, error: null, profileMode: 'default', security: { kind: 'blank', origin: null }, status: 'ready', title: 'Fixture', url: 'about:blank', zoomFactor: 1 })
+ const visible = shallowRef(true)
+ const canvas = shallowRef({ width: 800, height: 458 })
+ const flights: { sessionId: string, viewport: DesktopBrowserViewport | null, resolve: (state: DesktopBrowserState) => void, reject: (error: Error) => void }[] = []
+ const setViewport = vi.fn((sessionId: string, viewport: DesktopBrowserViewport | null) => new Promise((resolve, reject) => flights.push({ sessionId, viewport, resolve, reject })))
+ const updateState = vi.fn((value: DesktopBrowserState) => state.value = value)
+ const onError = vi.fn()
+ let controls!: ReturnType
+ const app = createApp({ setup() {
+ controls = useBrowserResponsiveViewport({ api: { setViewport }, state, visible, canvas, updateState, onError })
+ return () => null
+ } })
+ const root = document.createElement('div')
+ app.mount(root)
+ cleanups.push(() => app.unmount())
+ function complete(index = 0) {
+ flights[index].resolve({ ...state.value, sessionId: flights[index].sessionId, viewport: flights[index].viewport })
+ }
+ return { controls, state, canvas, visible, flights, setViewport, updateState, onError, complete }
+}
+
+describe('responsive viewport request ownership', () => {
+ it('enters with Fit and remembers dimensions when exiting', async () => {
+ const f = fixture()
+ f.controls.toggle()
+ expect(f.flights[0].viewport).toEqual({ width: 393, height: 852, scale: 0.5 })
+ f.complete()
+ await vi.waitFor(() => expect(f.controls.busy.value).toBe(false))
+ f.controls.toggle()
+ expect(f.flights[1].viewport).toBeNull()
+ f.complete(1)
+ await vi.waitFor(() => expect(f.controls.busy.value).toBe(false))
+ f.controls.toggle()
+ expect(f.flights[2].viewport?.width).toBe(393)
+ })
+ it('serializes and coalesces rapid drag changes; an old response cannot replace the newest size', async () => {
+ const f = fixture()
+ f.controls.resize({ width: 400, height: 600 })
+ f.controls.resize({ width: 420, height: 600 })
+ f.controls.resize({ width: 440, height: 600 })
+ expect(f.setViewport).toHaveBeenCalledTimes(1)
+ f.complete()
+ await vi.waitFor(() => expect(f.setViewport).toHaveBeenCalledTimes(2))
+ expect(f.updateState).not.toHaveBeenCalled()
+ expect(f.flights[1].viewport?.width).toBe(440)
+ f.complete(1)
+ await vi.waitFor(() => expect(f.controls.busy.value).toBe(false))
+ expect(f.state.value.viewport?.width).toBe(440)
+ })
+ it('discards a response after tab or control ownership changes', async () => {
+ const f = fixture()
+ f.controls.toggle()
+ f.state.value = { ...f.state.value, sessionId: 'session-b', controller: 'agent' }
+ await nextTick()
+ f.complete()
+ await vi.waitFor(() => expect(f.controls.busy.value).toBe(false))
+ expect(f.updateState).not.toHaveBeenCalled()
+ f.controls.toggle()
+ expect(f.setViewport).toHaveBeenCalledTimes(1)
+ })
+ it('rolls back a failed preview change and reports failure once', async () => {
+ const f = fixture()
+ f.controls.toggle()
+ f.flights[0].reject(new Error('Unavailable'))
+ await vi.waitFor(() => expect(f.controls.busy.value).toBe(false))
+ expect(f.controls.active.value).toBe(false)
+ expect(f.onError).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/browserNotice.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/browserNotice.ts
new file mode 100644
index 00000000..deec58ab
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/browserNotice.ts
@@ -0,0 +1,63 @@
+import type { DesktopBrowserBlockedAction, DesktopBrowserDownload, DesktopBrowserError, DesktopBrowserErrorCode } from '@buddy-electron/shared/desktopApi'
+import type { BuddyI18nKey, BuddyTranslate } from '@/i18n/buddyI18n'
+
+/**
+ * Copy for host-side browser interceptions and page failures. The host reports codes and the
+ * blocked action, so the interface owns the wording and never renders raw diagnostics.
+ */
+const BLOCKED_ACTION_KEYS = {
+ 'download': 'desktop.context.browserBlockedDownload',
+ 'file-chooser': 'desktop.context.browserBlockedUpload',
+ 'permission': 'desktop.context.browserBlockedPermission',
+ 'popup': 'desktop.context.browserBlockedPopup',
+} as const satisfies Record
+
+const PAGE_ERROR_KEYS = {
+ BROWSER_CERTIFICATE_ERROR: 'desktop.context.browserCertificateError',
+ BROWSER_NAVIGATION_BLOCKED: 'desktop.context.browserPageFailed',
+ BROWSER_PAGE_CRASHED: 'desktop.context.browserPageCrashed',
+ BROWSER_PAGE_FAILED: 'desktop.context.browserPageFailed',
+ BROWSER_PAGE_UNRESPONSIVE: 'desktop.context.browserPageUnresponsive',
+} as const satisfies Partial>
+
+const DOWNLOAD_STATE_KEYS = {
+ canceled: 'desktop.context.browserDownloadCanceled',
+ completed: 'desktop.context.browserDownloadCompleted',
+ failed: 'desktop.context.browserDownloadFailed',
+ started: 'desktop.context.browserDownloadStarted',
+} as const satisfies Record
+
+export function blockedActionNotice(action: DesktopBrowserBlockedAction, t: BuddyTranslate): string {
+ return t(BLOCKED_ACTION_KEYS[action])
+}
+
+const DETAIL_KEYS = {
+ 'unavailable': 'desktop.context.browserActionUnavailable',
+ 'target-changed': 'desktop.context.browserUploadTargetChanged',
+ 'selection-failed': 'desktop.context.browserUploadFailed',
+ 'invalid-target': 'desktop.context.browserPopupInvalid',
+ 'dialog-suppressed': 'desktop.context.browserDialogSuppressed',
+} as const satisfies Partial, BuddyI18nKey>>
+
+export function browserErrorNotice(error: DesktopBrowserError, t: BuddyTranslate): string {
+ const detail = error.detail && (DETAIL_KEYS as Partial, BuddyI18nKey>>)[error.detail]
+ const text = detail ? t(detail) : error.action ? blockedActionNotice(error.action, t) : pageErrorNotice(error.code, t)
+ return error.origin ? `${error.origin} — ${text}` : text
+}
+
+export function browserNoticeKey(sessionId: string, error: DesktopBrowserError | null | undefined, download: DesktopBrowserDownload | null | undefined): string | null {
+ if (error)
+ return `${sessionId}:error:${error.noticeId ?? `${error.action ?? ''}:${error.code}:${error.message}`}`
+ if (download)
+ return `${sessionId}:download:${download.id ?? download.fileName}:${download.state}`
+ return null
+}
+
+export function pageErrorNotice(code: DesktopBrowserErrorCode, t: BuddyTranslate): string {
+ const key = (PAGE_ERROR_KEYS as Partial>)[code]
+ return t(key ?? 'desktop.context.browserPageFailed')
+}
+
+export function downloadNotice(download: DesktopBrowserDownload, t: BuddyTranslate): string {
+ return t(DOWNLOAD_STATE_KEYS[download.state], { name: download.fileName })
+}
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts
index eff3397c..93049d7b 100644
--- a/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/browserToolbarMenu.ts
@@ -7,6 +7,8 @@ export type BrowserToolbarMenuActionKey
= | 'capture-screenshot'
| 'enter-incognito'
| 'exit-incognito'
+ | 'responsive-viewport'
+ | 'open-devtools'
| 'open-external'
| 'show-file-in-folder'
@@ -20,6 +22,8 @@ interface BrowserToolbarMenuContext {
busyAction: BrowserToolbarBusyAction | null
controller: 'agent' | 'human'
profileMode: DesktopBrowserProfileMode
+ responsive?: boolean
+ responsiveBusy?: boolean
url: string
}
@@ -34,6 +38,14 @@ export function getBrowserToolbarMenuActions(
labelKey: context.profileMode === 'incognito'
? 'desktop.context.browserExitIncognito'
: 'desktop.context.browserEnterIncognito',
+ }, {
+ disabled: isBusy || !hasPage || context.controller === 'agent',
+ key: 'open-devtools',
+ labelKey: 'desktop.context.browserOpenDevTools',
+ }, {
+ disabled: isBusy || Boolean(context.responsiveBusy) || (!hasPage && !context.responsive) || context.controller === 'agent',
+ key: 'responsive-viewport',
+ labelKey: context.responsive ? 'desktop.context.browserViewportExit' : 'desktop.context.browserResponsive',
}, {
disabled: isBusy || !hasPage,
key: 'open-external',
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/browserViewport.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/browserViewport.ts
new file mode 100644
index 00000000..84a67bb1
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/browserViewport.ts
@@ -0,0 +1,39 @@
+import type { DesktopBrowserViewport } from '@buddy-shared/browser/browserDesktopApi'
+
+export const DEFAULT_RESPONSIVE_VIEWPORT = { width: 393, height: 852, scale: 1 } as const
+export const BROWSER_PREVIEW_ZOOMS = ['fit', '25', '50', '75', '100', '125', '150', '200'] as const
+export type BrowserPreviewZoom = typeof BROWSER_PREVIEW_ZOOMS[number]
+export type BrowserViewportSize = Pick
+
+// CSS viewport sizes, not UA, touch, DPR or operating-system emulation.
+export const BROWSER_VIEWPORT_DEVICES = [
+ { id: 'iphone-se', name: 'iPhone SE', width: 375, height: 667 },
+ { id: 'iphone-13', name: 'iPhone 12 / 13 / 14', width: 390, height: 844 },
+ { id: 'iphone-14-pro', name: 'iPhone 14 Pro', width: 393, height: 852 },
+ { id: 'pixel-7', name: 'Pixel 7', width: 412, height: 915 },
+ { id: 'ipad-air', name: 'iPad Air', width: 820, height: 1180 },
+ { id: 'desktop', name: 'Desktop', width: 1280, height: 800 },
+] as const
+
+export function browserViewportDeviceForSize(size: BrowserViewportSize): string {
+ return BROWSER_VIEWPORT_DEVICES.find(device => device.width === size.width && device.height === size.height)?.id ?? 'custom'
+}
+
+export function viewportDimensionValid(dimension: keyof BrowserViewportSize, value: number): boolean {
+ return Number.isInteger(value) && value >= 240 && value <= (dimension === 'width' ? 3_840 : 2_160)
+}
+
+export function clampViewportSize(size: BrowserViewportSize): BrowserViewportSize {
+ return {
+ width: Math.round(Math.max(240, Math.min(3_840, size.width))),
+ height: Math.round(Math.max(240, Math.min(2_160, size.height))),
+ }
+}
+
+export function browserPreviewScale(size: BrowserViewportSize, canvas: BrowserViewportSize, zoom: BrowserPreviewZoom): number {
+ if (zoom !== 'fit')
+ return Number(zoom) / 100
+ if (canvas.width <= 32 || canvas.height <= 32)
+ return 1
+ return Math.max(0.1, Math.min(1, (canvas.width - 32) / size.width, (canvas.height - 32) / size.height))
+}
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts
index f2720adc..ae640ddf 100644
--- a/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserContextSurface.ts
@@ -143,6 +143,18 @@ export function useBrowserContextSurface(options: UseBrowserContextSurfaceOption
}
}
+ async function openDevTools(): Promise {
+ const sessionId = state.value?.sessionId
+ if (!sessionId)
+ return false
+ try {
+ return await options.api.openDevTools(sessionId)
+ }
+ catch {
+ return false
+ }
+ }
+
async function openExternal(): Promise {
const sessionId = state.value?.sessionId
if (!sessionId || isOpeningExternal.value)
@@ -204,6 +216,18 @@ export function useBrowserContextSurface(options: UseBrowserContextSurfaceOption
}
}
+ async function revealDownload(): Promise {
+ const sessionId = state.value?.sessionId
+ if (!sessionId || !state.value?.download?.path)
+ return false
+ try {
+ return await options.api.revealDownload(sessionId)
+ }
+ catch {
+ return false
+ }
+ }
+
function goBack(): Promise {
return runSessionCommand(sessionId => options.api.goBack(sessionId))
}
@@ -283,8 +307,10 @@ export function useBrowserContextSurface(options: UseBrowserContextSurfaceOption
isSwitchingProfile: readonly(isSwitchingProfile),
isTakingControl: readonly(isTakingControl),
navigate,
+ openDevTools,
openExternal,
reload,
+ revealDownload,
setProfileMode,
setZoomFactor,
showFileInFolder,
diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserResponsiveViewport.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserResponsiveViewport.ts
new file mode 100644
index 00000000..8109f094
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useBrowserResponsiveViewport.ts
@@ -0,0 +1,127 @@
+import type { DesktopBrowserApi, DesktopBrowserState, DesktopBrowserViewport } from '@buddy-shared/browser/browserDesktopApi'
+import type { Ref } from 'vue'
+import type { BrowserPreviewZoom, BrowserViewportSize } from './browserViewport'
+import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
+import { browserPreviewScale, DEFAULT_RESPONSIVE_VIEWPORT } from './browserViewport'
+
+export function useBrowserResponsiveViewport(options: {
+ api: Pick
+ state: Readonly[>
+ visible: Readonly][>
+ canvas: Readonly][>
+ updateState: (state: DesktopBrowserState) => void
+ onError: () => void
+}) {
+ const zoom = shallowRef('fit')
+ const busy = shallowRef(false)
+ const requested = shallowRef()
+ const viewport = computed(() => requested.value === undefined ? options.state.value?.viewport ?? null : requested.value)
+ const active = computed(() => Boolean(viewport.value))
+ const disabled = computed(() => options.state.value?.controller !== 'human')
+ let remembered: DesktopBrowserViewport = { ...DEFAULT_RESPONSIVE_VIEWPORT }
+ let pending: { sessionId: string, version: number, viewport: DesktopBrowserViewport | null } | null = null
+ let version = 0
+ let disposed = false
+
+ watch(() => options.state.value?.sessionId, () => {
+ version += 1
+ pending = null
+ requested.value = undefined
+ remembered = { ...options.state.value?.viewport ?? DEFAULT_RESPONSIVE_VIEWPORT }
+ zoom.value = 'fit'
+ }, { immediate: true })
+ watch(disabled, (value) => {
+ if (value) {
+ version += 1
+ pending = null
+ requested.value = undefined
+ }
+ })
+ onBeforeUnmount(() => {
+ disposed = true
+ version += 1
+ pending = null
+ })
+
+ function request(value: DesktopBrowserViewport | null): void {
+ const state = options.state.value
+ if (disposed || !state || disabled.value || !options.visible.value)
+ return
+ if (value)
+ remembered = { ...value }
+ if (JSON.stringify(viewport.value) === JSON.stringify(value))
+ return
+ requested.value = value
+ pending = { sessionId: state.sessionId, viewport: value, version: ++version }
+ void drain()
+ }
+
+ async function drain(): Promise {
+ if (busy.value)
+ return
+ busy.value = true
+ try {
+ while (pending) {
+ if (disposed)
+ break
+ const next = pending
+ pending = null
+ if (disabled.value || !options.visible.value || options.state.value?.sessionId !== next.sessionId) {
+ requested.value = undefined
+ continue
+ }
+ try {
+ const state = await options.api.setViewport(next.sessionId, next.viewport)
+ // A slow response must not overwrite a newer drag, tab, or control owner.
+ if (!disposed && next.version === version && options.state.value?.sessionId === next.sessionId) {
+ options.updateState(state)
+ requested.value = undefined
+ }
+ }
+ catch {
+ if (!disposed && next.version === version && options.state.value?.sessionId === next.sessionId) {
+ requested.value = undefined
+ options.onError()
+ }
+ }
+ }
+ }
+ finally { busy.value = false }
+ }
+
+ function resize(size: BrowserViewportSize): void {
+ request({ ...size, scale: browserPreviewScale(size, options.canvas.value, zoom.value) })
+ }
+
+ function toggle(): void {
+ if (active.value) {
+ request(null)
+ }
+ else {
+ zoom.value = 'fit'
+ resize(remembered)
+ }
+ }
+
+ function selectDevice(size: BrowserViewportSize): void {
+ zoom.value = 'fit'
+ resize(size)
+ }
+
+ function setZoom(value: BrowserPreviewZoom): void {
+ zoom.value = value
+ if (viewport.value)
+ resize(viewport.value)
+ }
+
+ watch(options.canvas, () => {
+ if (zoom.value === 'fit' && viewport.value && options.visible.value && !disabled.value)
+ resize(viewport.value)
+ })
+ watch(options.visible, (visible) => {
+ if (visible && zoom.value === 'fit' && viewport.value && !disabled.value)
+ resize(viewport.value)
+ })
+
+ return { active, busy, disabled, viewport, zoom, resize, selectDevice, setZoom, toggle }
+}
diff --git a/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue b/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue
index e36f88b7..1fdf3aef 100644
--- a/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue
+++ b/apps/buddy/src/platform/browser/DesktopBrowserGuestHost.vue
@@ -22,6 +22,7 @@ let stopGuestsChanged: (() => void) | null = null
interface BrowserGuestEntry {
descriptor: DesktopBrowserGuestDescriptor
element: WebviewTag
+ frame: HTMLElement
layout: SurfaceLayoutLease
onDestroyed: () => void
onReady: () => void
@@ -97,10 +98,15 @@ function createGuest(descriptor: DesktopBrowserGuestDescriptor): void {
return
const element = document.createElement('webview') as WebviewTag
+ const frame = document.createElement('div')
+ frame.className = 'desktop-browser-guest-host__frame'
+ Object.assign(frame.style, { overflow: 'hidden', pointerEvents: 'none' })
+ Object.assign(element.style, { position: 'absolute', inset: '0', pointerEvents: 'auto', transformOrigin: 'top left' })
const entry: BrowserGuestEntry = {
descriptor,
element,
- layout: props.layout.attach(element, { anchor: null, visible: false, interactive: true, layer: 'content' }),
+ frame,
+ layout: props.layout.attach(frame, { anchor: null, visible: false, interactive: true, childrenOnly: true, layer: 'content' }),
onReady: () => {
if (guests.get(descriptor.sessionId)?.element !== element)
return
@@ -127,24 +133,44 @@ function createGuest(descriptor: DesktopBrowserGuestDescriptor): void {
element.className = 'desktop-browser-guest-host__guest'
element.dataset.browserSessionId = descriptor.sessionId
element.setAttribute('partition', descriptor.partition)
+ element.setAttribute('allowpopups', 'true')
element.setAttribute('src', 'about:blank')
element.addEventListener('destroyed', entry.onDestroyed)
element.addEventListener('dom-ready', entry.onReady)
guests.set(descriptor.sessionId, entry)
- host.append(element)
+ frame.append(element)
+ host.append(frame)
}
function removeGuest(entry: BrowserGuestEntry): void {
entry.layout.dispose()
entry.element.removeEventListener('destroyed', entry.onDestroyed)
entry.element.removeEventListener('dom-ready', entry.onReady)
- entry.element.remove()
+ entry.frame.remove()
}
function scheduleLayout(): void {
for (const [sessionId, entry] of guests) {
const surface = surfaces.get(sessionId)
- entry.layout.update({ anchor: surface?.element ?? null, visible: !!surface, interactive: true, layer: 'content' })
+ entry.layout.update({
+ anchor: surface?.element ?? null,
+ visible: !!surface,
+ interactive: true,
+ childrenOnly: true,
+ layer: 'content',
+ onLayout: (geometry) => {
+ const width = Number(surface?.element.dataset.browserViewportWidth)
+ const height = Number(surface?.element.dataset.browserViewportHeight)
+ const responsive = geometry.visible && width >= 240 && width <= 3_840 && height >= 240 && height <= 2_160
+ // Keep the guest's logical bounds independent of the preview frame. Scaling only
+ // the composed surface preserves CSS breakpoints, input coordinates and screenshots.
+ Object.assign(entry.element.style, {
+ width: responsive ? `${width}px` : '100%',
+ height: responsive ? `${height}px` : '100%',
+ transform: responsive ? `scale(${geometry.width / width}, ${geometry.height / height})` : 'none',
+ })
+ },
+ })
}
props.layout.invalidate()
}
]