Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/web-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/web-ui/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
数字切换标签、重新打开标签和布局快捷键也只作用于当前画布。
在设置页录入新快捷键时,按键用于录入,不会执行原有命令。

## 打开网页链接

在 **设置 → 工具 → 浏览器与桌面控制 → 网页链接** 中,选择对话中的链接默认使用
**内置浏览器** 还是 **系统默认浏览器**。修改立即生效并保存在当前设备;查看远程
工作区或切换 Peer 设备时沿用此偏好。未设置时继续使用内置浏览器。
Web 部署中,外部选项为「当前浏览器(新标签页)」。共享本机存储的其他已打开页面
也会同步使用新选择。

右键点击链接仍可为单次打开选择浏览器。按住修饰键点击仍使用外部浏览器;支持的
PR/MR 链接保留评审面板入口。此设置不改变智能体浏览器控制所用的浏览器,也不
影响独立的移动端、IM 或无界面派发任务。

## 创建项目

左侧导航的新增按钮展示两张可点击卡片,首层不设底部操作按钮。
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/app/scenes/settings/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} });
Expand Down Expand Up @@ -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<HTMLSelectElement>('select') ?? null;
const render = async () => { await act(async () => root.render(<DeviceControlSettingsPage />)); };

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);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 }));
Expand All @@ -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());
Expand All @@ -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();
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ConfigPageSection title={t('browserLinks.sectionTitle')}>
<ConfigPageRow label={t('browserLinks.defaultTarget')} description={t('browserLinks.description')} align="center">
<Select
size="sm"
aria-label={t('browserLinks.defaultTarget')}
value={target}
options={[
{ value: 'builtin', label: t('browserLinks.builtin') },
{ value: 'external', label: t(isTauriRuntime() ? 'browserLinks.external' : 'browserLinks.browserTab') },
]}
onValueChange={value => {
if (value === 'builtin' || value === 'external') setTarget(value);
}}
/>
</ConfigPageRow>
</ConfigPageSection>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SettingsPage } from '../shared/SettingsPage';
import BrowserLinkSettingsSection from './BrowserLinkSettingsSection';
const log = createLogger('DeviceControlSettingsPage');

const IS_TAURI_DESKTOP = typeof window !== 'undefined' && '__TAURI__' in window;
Expand Down Expand Up @@ -538,6 +539,7 @@ const DeviceControlSettingsPage: React.FC = () => {

return (
<SettingsPage pageId="tools.desktop-control" className="openbitfun-runtime-settings" data-openbitfun-component="runtime-settings" data-openbitfun-part="root" data-openbitfun-view="browser-desktop-control">
<BrowserLinkSettingsSection />
{loadError ? (
<ConfigRetryState message={t('loading.failed')} retryLabel={t('loading.retry')} onRetry={() => void loadPageData()} />
) : isLoading ? (
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/app/scenes/settings/settingsRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,13 +407,19 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [
descriptionKey: 'navigation.pages.browserDesktopControl.description',
keywords: [
'browser', 'cdp', 'chrome', 'edge', 'remote debugging', 'connection',
'links', 'built-in browser', 'external browser',
'desktop', 'computer use', 'accessibility', 'screen capture', 'mouse', 'keyboard',
],
namespaces: ['settings', 'settings/runtime'],
searchPhrases: [
phrase('settings/runtime', 'computerUse.sectionTitle'),
phrase('settings/runtime', 'computerUse.accessibility'),
phrase('settings/runtime', 'computerUse.screenCapture'),
phrase('settings/runtime', 'browserLinks.sectionTitle'),
phrase('settings/runtime', 'browserLinks.defaultTarget'),
phrase('settings/runtime', 'browserLinks.builtin'),
phrase('settings/runtime', 'browserLinks.external'),
phrase('settings/runtime', 'browserLinks.browserTab'),
phrase('settings/runtime', 'browserControl.sectionTitle'),
phrase('settings/runtime', 'browserControl.preferredBrowser'),
phrase('settings/runtime', 'browserControl.autoConnectOnStartup'),
Expand Down
28 changes: 28 additions & 0 deletions src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const copyTextToClipboardMock = vi.hoisted(() => vi.fn());
const controllerInvoke = vi.hoisted(() => vi.fn());
const localVersion = vi.hoisted(() => vi.fn());
const openUrlMock = vi.hoisted(() => vi.fn());
const isTauriRuntimeMock = vi.hoisted(() => vi.fn(() => true));
vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: isTauriRuntimeMock }));
vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: openUrlMock }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: controllerInvoke }));
vi.mock('@tauri-apps/api/app', () => ({ getVersion: localVersion }));
Expand All @@ -30,6 +32,7 @@ describe('SystemAPI', () => {
controllerInvoke.mockReset();
localVersion.mockReset();
openUrlMock.mockReset();
isTauriRuntimeMock.mockReturnValue(true);
});

afterEach(() => {
Expand All @@ -45,6 +48,31 @@ describe('SystemAPI', () => {
expect(controllerInvoke).not.toHaveBeenCalled();
});

it('opens web-client links synchronously in a controller tab without desktop or peer calls', async () => {
isTauriRuntimeMock.mockReturnValue(false);
const open = vi.fn();
vi.stubGlobal('window', { open });
const url = 'https://example.com/docs';
const opened = systemAPI.openExternal(url);
expect(open).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
await opened;
expect(openUrlMock).not.toHaveBeenCalled();
expect(invokeMock).not.toHaveBeenCalled();
expect(controllerInvoke).not.toHaveBeenCalled();
});

it.each(['javascript:alert(1)', 'data:text/html,test', 'file:///private/file', '/relative-path'])(
'rejects unsupported web-client URL %s', async url => {
isTauriRuntimeMock.mockReturnValue(false);
const open = vi.fn();
vi.stubGlobal('window', { open });
await expect(systemAPI.openExternal(url)).rejects.toThrow('Failed to open external URL');
expect(open).not.toHaveBeenCalled();
expect(openUrlMock).not.toHaveBeenCalled();
expect(invokeMock).not.toHaveBeenCalled();
},
);

it.each([true, false])('allows explicit update checks with DEV=%s', async development => {
vi.stubEnv('DEV', development);
const response = {
Expand Down
12 changes: 11 additions & 1 deletion src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api } from './ApiClient';
import { createTauriCommandError } from '../errors/TauriCommandError';
import { copyTextToClipboard } from '@/shared/utils/textSelection';
import { openUrl } from '@tauri-apps/plugin-opener';
import { isTauriRuntime } from '@/infrastructure/runtime';
import { createLogger } from '@/shared/utils/logger';
import { productControlAPI } from './ProductControlAPI';
import {
Expand Down Expand Up @@ -158,7 +159,16 @@ export class SystemAPI {

async openExternal(url: string): Promise<void> {
try {
await openUrl(url);
if (isTauriRuntime()) {
await openUrl(url);
} else {
const protocol = new URL(url).protocol;
if (!['http:', 'https:', 'mailto:', 'tel:'].includes(protocol) || typeof window === 'undefined') {
throw new Error('This URL cannot be opened in the web client');
}
// Open synchronously within the click gesture, on the controller browser.
window.open(url, '_blank', 'noopener,noreferrer');
}
} catch (error) {
log.error('Failed to open external URL', { url, error });
throw new Error(`Failed to open external URL: ${error}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BROWSER_LINK_TARGET_STORAGE_KEY, browserLinkPreferenceStore, createBrowserLinkPreferenceStore } from './browserLinkPreference';

function storage(value: string | null = null) {
let saved = value;
return { getItem: () => saved, setItem: vi.fn((_key: string, next: string) => { saved = next; }) };
}

describe('controller browser link preference', () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.removeItem(BROWSER_LINK_TARGET_STORAGE_KEY);
browserLinkPreferenceStore.setState({ target: 'builtin' });
});

it.each([null, '', 'builtin', 'future-target'])('reads an older or unknown record %j without rewriting it', value => {
const persisted = storage(value);
expect(createBrowserLinkPreferenceStore(persisted).getState().target).toBe('builtin');
expect(persisted.getItem()).toBe(value);
expect(persisted.setItem).not.toHaveBeenCalled();
});

it('round-trips both choices across window restarts', () => {
const persisted = storage();
createBrowserLinkPreferenceStore(persisted).getState().setTarget('external');
expect(persisted.setItem).toHaveBeenCalledWith(BROWSER_LINK_TARGET_STORAGE_KEY, 'external');
const reopened = createBrowserLinkPreferenceStore(persisted);
expect(reopened.getState().target).toBe('external');
reopened.getState().setTarget('builtin');
expect(createBrowserLinkPreferenceStore(persisted).getState().target).toBe('builtin');
});

it('keeps links usable when storage cannot be read or written', () => {
const store = createBrowserLinkPreferenceStore({
getItem: () => { throw Error('unavailable'); },
setItem: () => { throw Error('unavailable'); },
});
expect(store.getState().target).toBe('builtin');
store.getState().setTarget('external');
expect(store.getState().target).toBe('external');
});

it('follows the latest choice from another window without writing it back', () => {
localStorage.setItem(BROWSER_LINK_TARGET_STORAGE_KEY, 'external');
const write = vi.spyOn(Storage.prototype, 'setItem');
window.dispatchEvent(new StorageEvent('storage', {
storageArea: localStorage, key: BROWSER_LINK_TARGET_STORAGE_KEY, newValue: 'builtin',
}));
expect(browserLinkPreferenceStore.getState().target).toBe('external');
expect(write).not.toHaveBeenCalled();

localStorage.removeItem(BROWSER_LINK_TARGET_STORAGE_KEY);
window.dispatchEvent(new StorageEvent('storage', { storageArea: localStorage, key: null }));
expect(browserLinkPreferenceStore.getState().target).toBe('builtin');
expect(write).not.toHaveBeenCalled();
});

it('ignores unrelated keys and session storage events', () => {
browserLinkPreferenceStore.setState({ target: 'external' });
window.dispatchEvent(new StorageEvent('storage', { storageArea: localStorage, key: 'other-preference' }));
window.dispatchEvent(new StorageEvent('storage', { storageArea: sessionStorage, key: BROWSER_LINK_TARGET_STORAGE_KEY }));
expect(browserLinkPreferenceStore.getState().target).toBe('external');
});

it('preserves the current choice when a cross-window refresh cannot read storage', () => {
browserLinkPreferenceStore.setState({ target: 'external' });
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw Error('unavailable'); });
window.dispatchEvent(new StorageEvent('storage', { storageArea: localStorage, key: BROWSER_LINK_TARGET_STORAGE_KEY }));
expect(browserLinkPreferenceStore.getState().target).toBe('external');
});
});
Loading
Loading