diff --git a/src/web-ui/README.md b/src/web-ui/README.md index a44744cf6c..e7add3f996 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -24,6 +24,21 @@ Numbered tab selection, reopening a closed tab and layout shortcuts also stay with their owning canvas. Recording a new binding in Settings captures the key without executing the existing command. +## Opening web links + +In **Settings → Tools → Browser & desktop control → Web links**, choose whether +conversation links open in the **Built-in browser** or the **System default +browser**. The choice applies immediately and is saved on this device, including +while viewing remote workspaces or switching peers. Existing installs keep the +built-in default until you change it. +In a Web deployment, the external option opens a new tab in the current browser. +Changes also synchronize to other open pages sharing this device's local storage. + +Right-click a link to choose either browser for that click. Modifier-clicks +continue to open the external browser; supported PR/MR links keep their review +panel behavior. This preference does not change the browser used by agent +browser control, or the separate mobile/IM and headless dispatch surfaces. + ## Creating a project The sidebar's add button opens two clickable cards without footer actions. diff --git a/src/web-ui/README.zh-CN.md b/src/web-ui/README.zh-CN.md index dc2a3c6c29..34b4f558e0 100644 --- a/src/web-ui/README.zh-CN.md +++ b/src/web-ui/README.zh-CN.md @@ -21,6 +21,18 @@ 数字切换标签、重新打开标签和布局快捷键也只作用于当前画布。 在设置页录入新快捷键时,按键用于录入,不会执行原有命令。 +## 打开网页链接 + +在 **设置 → 工具 → 浏览器与桌面控制 → 网页链接** 中,选择对话中的链接默认使用 +**内置浏览器** 还是 **系统默认浏览器**。修改立即生效并保存在当前设备;查看远程 +工作区或切换 Peer 设备时沿用此偏好。未设置时继续使用内置浏览器。 +Web 部署中,外部选项为「当前浏览器(新标签页)」。共享本机存储的其他已打开页面 +也会同步使用新选择。 + +右键点击链接仍可为单次打开选择浏览器。按住修饰键点击仍使用外部浏览器;支持的 +PR/MR 链接保留评审面板入口。此设置不改变智能体浏览器控制所用的浏览器,也不 +影响独立的移动端、IM 或无界面派发任务。 + ## 创建项目 左侧导航的新增按钮展示两张可点击卡片,首层不设底部操作按钮。 diff --git a/src/web-ui/src/app/scenes/settings/AGENTS.md b/src/web-ui/src/app/scenes/settings/AGENTS.md index 3d989950bd..09f0eb7083 100644 --- a/src/web-ui/src/app/scenes/settings/AGENTS.md +++ b/src/web-ui/src/app/scenes/settings/AGENTS.md @@ -44,6 +44,16 @@ pnpm --dir src/web-ui run test:run src/infrastructure/config/components/common/S Follow the parent guide for `check:web`, i18n, and Appearance registration checks. +For the controller-local web link preference, persistence and explicit browser actions: + +```bash +pnpm --dir src/web-ui run test:run src/infrastructure/config/services/browserLinkPreference.test.ts src/infrastructure/markdown/MarkdownRenderer.test.tsx src/infrastructure/api/service-api/SystemAPI.test.ts src/app/scenes/settings/settingsRegistry.test.ts src/app/scenes/settings/pages/shared/RuntimeSettings.test.tsx src/infrastructure/config/components/common/SettingsControlSizing.test.ts +``` + +The link preference remains editable independently of host control status. These +storage/DOM fixtures cover cross-window preference updates, remote workspace metadata and simulated Peer switches; +they do not establish native browser opening, live remote scenarios, or visual fidelity. + For keyboard shortcut recording, command suppression, IME and capture lifetime: ```bash diff --git a/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.test.tsx b/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.test.tsx index ae4b5a2306..0adcbe110e 100644 --- a/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/shared/RuntimeSettings.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import DeviceControlSettingsPage from '../tools/DeviceControlSettingsPage'; import ExecutionSettingsPage from '../ai/ExecutionSettingsPage'; import WorkspaceSearchSection from '../development/WorkspaceSearchSection'; +import { BROWSER_LINK_TARGET_STORAGE_KEY, browserLinkPreferenceStore } from '@/infrastructure/config/services/browserLinkPreference'; const mocks = vi.hoisted(() => { Object.defineProperty(window, '__TAURI__', { configurable: true, value: {} }); @@ -55,10 +56,15 @@ let status = { ...readyStatus }; let container: HTMLDivElement; let root: Root; const button = (label: string) => Array.from(container.querySelectorAll('button')).find(el => el.textContent === label || el.getAttribute('aria-label') === label)!; +const selectFor = (label: string) => Array.from(container.querySelectorAll('[data-openbitfun-part="row"]')) + .find(row => row.querySelector('[data-openbitfun-part="rowLabel"]')?.textContent === label) + ?.querySelector('select') ?? null; const render = async () => { await act(async () => root.render()); }; beforeEach(() => { mocks.workspace = null; + browserLinkPreferenceStore.setState({ target: 'builtin' }); + localStorage.removeItem(BROWSER_LINK_TARGET_STORAGE_KEY); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; status = { ...readyStatus }; mocks.getConfig.mockReset().mockResolvedValue(null); @@ -122,7 +128,12 @@ describe('Tool execution concurrency settings', () => { expect(mocks.error).toHaveBeenCalledWith('messages.saveFailed: Host rejected the concurrency setting'); }); }); -afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + browserLinkPreferenceStore.setState({ target: 'builtin' }); + localStorage.removeItem(BROWSER_LINK_TARGET_STORAGE_KEY); +}); describe('Browser and desktop control settings', () => { it('keeps detailed host permissions in the standard compact settings rows', async () => { @@ -171,7 +182,7 @@ describe('Browser and desktop control settings', () => { }); it('localizes the browser choice and puts the single connect action next to connection status', async () => { await render(); - expect(container.querySelector('option')?.textContent).toBe('browserControl.defaultBrowser'); + expect(selectFor('browserControl.preferredBrowser')?.querySelector('option')?.textContent).toBe('browserControl.defaultBrowser'); const connect = button('browserControl.connect'); expect(connect.closest('[data-openbitfun-part="row"]')?.textContent).toContain('browserControl.status'); expect(container.textContent).toContain('browserControl.readyNotConnected'); @@ -220,7 +231,7 @@ describe('Browser and desktop control settings', () => { }); mocks.setConfig.mockImplementation(async () => { order.push('save'); }); await render(); - const select = container.querySelector('select')!; + const select = selectFor('browserControl.preferredBrowser')!; await act(async () => { select.value = 'edge'; select.dispatchEvent(new Event('change', { bubbles: true })); @@ -237,7 +248,7 @@ describe('Browser and desktop control settings', () => { it('keeps the selected browser visible while connected and disconnects through the existing command', async () => { status.cdpAvailable = true; await render(); - expect(container.querySelector('select')?.disabled).toBe(true); + expect(selectFor('browserControl.preferredBrowser')?.disabled).toBe(true); expect(container.textContent).toContain('browserControl.connected'); expect(container.textContent).toContain('Chrome · 3'); await act(async () => button('browserControl.disconnect').click()); @@ -261,7 +272,27 @@ describe('Browser and desktop control settings', () => { await render(); expect(container.textContent).toContain('computerUse.peerUnsupported'); expect(container.textContent).toContain('browserControl.peerUnsupported'); - expect(container.querySelector('select')).toBeNull(); + expect(selectFor('browserControl.preferredBrowser')).toBeNull(); + expect(selectFor('browserLinks.defaultTarget')?.disabled).toBe(false); + }); + + it('saves the controller link preference while host configuration is unavailable', async () => { + mocks.getConfig.mockRejectedValue(new Error('Host is offline')); + await render(); + expect(container.textContent).toContain('loading.failed'); + const select = selectFor('browserLinks.defaultTarget')!; + expect(select.disabled).toBe(false); + expect(select.value).toBe('builtin'); + mocks.invoke.mockClear(); + + await act(async () => { + select.value = 'external'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(browserLinkPreferenceStore.getState().target).toBe('external'); + expect(localStorage.getItem(BROWSER_LINK_TARGET_STORAGE_KEY)).toBe('external'); + expect(mocks.setConfig).not.toHaveBeenCalled(); + expect(mocks.invoke).not.toHaveBeenCalled(); }); }); diff --git a/src/web-ui/src/app/scenes/settings/pages/tools/BrowserLinkSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/tools/BrowserLinkSettingsSection.tsx new file mode 100644 index 0000000000..639b1e3700 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/tools/BrowserLinkSettingsSection.tsx @@ -0,0 +1,31 @@ +import { useStore } from 'zustand'; +import { Select } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { isTauriRuntime } from '@/infrastructure/runtime'; +import { ConfigPageRow, ConfigPageSection } from '@/infrastructure/config/components/common'; +import { browserLinkPreferenceStore } from '@/infrastructure/config/services/browserLinkPreference'; + +export default function BrowserLinkSettingsSection() { + const { t } = useI18n('settings/runtime'); + const target = useStore(browserLinkPreferenceStore, state => state.target); + const setTarget = useStore(browserLinkPreferenceStore, state => state.setTarget); + + return ( + + +