From a9a4a040705f0c312488e81dbdd96bf0b951feb7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 18 Aug 2026 07:46:37 -0500 Subject: [PATCH] feat(desktop): open the browser with a hotkey instead of only through chat The Browser tab could only be summoned by asking the agent or clicking a link. Adds Cmd+Shift+L and a command-palette row. L is the location-bar chord every browser shares; plain Cmd+L is already the terminal's selection shortcut, hence the shift. Pressing it re-fronts whatever page is loaded rather than resetting it, so it reads as 'show me the browser' and not 'start over'. --- .../desktop/src/app/command-palette/index.tsx | 9 ++++ apps/desktop/src/app/hooks/use-keybinds.ts | 2 + apps/desktop/src/i18n/ar.ts | 2 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/lib/keybinds/actions.ts | 3 ++ .../src/store/preview-open-browser.test.ts | 51 +++++++++++++++++++ apps/desktop/src/store/preview.ts | 9 ++++ 11 files changed, 83 insertions(+) create mode 100644 apps/desktop/src/store/preview-open-browser.test.ts diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index b76862d516..fa0328af4d 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -68,6 +68,7 @@ import { import { $bindings, bindingsFor } from '@/store/keybinds' import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout' import { openPetGenerate } from '@/store/pet-generate' +import { openBrowserTab } from '@/store/preview' import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects' import { $connection } from '@/store/session' import { runGatewayRestart } from '@/store/system-actions' @@ -902,6 +903,14 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { keywords: ['reload', 'window', 'refresh', 'restart', 'ui', 'stuck'], label: cc.reloadWindow, run: () => window.location.reload() + }, + { + action: 'view.showBrowser', + icon: codiconIcon('globe'), + id: 'cc-open-browser', + keywords: ['browser', 'web', 'url', 'address', 'open', 'navigate', 'internet', 'site'], + label: cc.openBrowser, + run: () => openBrowserTab() } ] }, diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 919da805ec..1b7217d477 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -35,6 +35,7 @@ import { togglePanesFlipped, toggleSidebarOpen } from '@/store/layout' +import { openBrowserTab } from '@/store/preview' import { $newChatProfile, cycleProfile, @@ -235,6 +236,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.toggleReview': toggleReview, 'view.toggleStatusbar': toggleStatusbarVisible, 'view.showFiles': showFiles, + 'view.showBrowser': openBrowserTab, 'view.toggleHud': () => toggleHud(hudTargetSessionId()), 'view.showTerminal': () => togglePaneVisible('terminal'), // Create first so the pane's open-effect ensure sees a non-empty set and diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 63c027b942..072b76d337 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -240,6 +240,7 @@ export const ar = defineLocale({ 'view.toggleRightSidebar': 'تبديل متصفح الملفات', 'view.toggleReview': 'تبديل لوحة المراجعة', 'view.showFiles': 'إظهار متصفح الملفات', + 'view.showBrowser': 'فتح المتصفح', 'view.showTerminal': 'إظهار الطرفية', 'view.closeTab': 'إغلاق علامة التبويب', 'view.reopenTab': 'إعادة فتح علامة التبويب المغلقة', @@ -1131,6 +1132,7 @@ export const ar = defineLocale({ gatewayStopped: 'البوابة متوقفة', hermesActiveSessions: (version, count) => `Hermes ${version} لديه ${count} جلسة نشطة`, restartGateway: 'إعادة تشغيل البوابة', + openBrowser: 'فتح المتصفح', gatewayRestartFailed: 'فشل إعادة تشغيل البوابة.', updateHermes: 'تحديث Hermes', reloadWindow: 'إعادة تحميل النافذة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index bb751260f4..11f147b19f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -273,6 +273,7 @@ export const en: Translations = { 'view.toggleReview': 'Toggle review pane', 'view.toggleStatusbar': 'Toggle status bar', 'view.showFiles': 'Show file browser', + 'view.showBrowser': 'Open browser', 'view.toggleHud': 'Toggle HUD mode', 'hud.snapToPointer': 'Move HUD to pointer (global, while HUD is open)', 'view.showTerminal': 'Toggle terminal', @@ -1413,6 +1414,7 @@ export const en: Translations = { gatewayStopped: 'Messaging gateway stopped', hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`, restartGateway: 'Restart gateway', + openBrowser: 'Open browser', gatewayRestartFailed: 'Gateway restart failed.', updateHermes: 'Update Hermes', reloadWindow: 'Reload window', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 18b165d803..60de409fc6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1288,6 +1288,7 @@ export const ja = defineLocale({ gatewayStopped: 'メッセージングゲートウェイが停止中', hermesActiveSessions: (version, count) => `Hermes ${version} · アクティブセッション ${count}`, restartGateway: 'ゲートウェイを再起動', + openBrowser: 'ブラウザを開く', gatewayRestartFailed: 'ゲートウェイの再起動に失敗しました。', updateHermes: 'Hermes を更新', reloadWindow: 'ウィンドウを再読み込み', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 1a660f0dbb..ef6b565fb1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1239,6 +1239,7 @@ export interface Translations { gatewayStopped: string hermesActiveSessions: (version: string, count: number) => string restartGateway: string + openBrowser: string gatewayRestartFailed: string updateHermes: string reloadWindow: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e669365a63..5e7b702ee2 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1250,6 +1250,7 @@ export const zhHant = defineLocale({ gatewayStopped: '訊息閘道已停止', hermesActiveSessions: (version, count) => `Hermes ${version} · 活躍工作階段 ${count}`, restartGateway: '重新啟動閘道', + openBrowser: '開啟瀏覽器', gatewayRestartFailed: '閘道重新啟動失敗。', updateHermes: '更新 Hermes', reloadWindow: '重新載入視窗', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 8c812bea28..897c18684d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -268,6 +268,7 @@ export const zh: Translations = { 'view.toggleReview': '切换审查面板', 'view.toggleStatusbar': '切换状态栏', 'view.showFiles': '显示文件浏览器', + 'view.showBrowser': '打开浏览器', 'view.showTerminal': '显示终端', 'view.terminalSelection': '将终端选区发送到输入框', 'view.terminalCopy': '复制终端选区', @@ -1602,6 +1603,7 @@ export const zh: Translations = { gatewayStopped: '消息网关已停止', hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`, restartGateway: '重启网关', + openBrowser: '打开浏览器', gatewayRestartFailed: '网关重启失败。', updateHermes: '更新 Hermes', reloadWindow: '重新载入窗口', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 768192b5e9..fd9527df19 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -125,6 +125,9 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ⌘G — "g" for git; the review pane is the source-control view. { id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] }, { id: 'view.showFiles', category: 'view', defaults: [] }, + // ⌘⇧L — "L" for location, the address-bar chord every browser shares. Plain + // ⌘L is the terminal's selection shortcut, hence the shift. + { id: 'view.showBrowser', category: 'view', defaults: ['mod+shift+l'] }, // ⌘⇧H — "h" for HUD. Enters/leaves the chrome-free floating chat: the app // window steps aside and a composer + live reply float over whatever the // user is working in. Ships bound because the whole point is leaving the app diff --git a/apps/desktop/src/store/preview-open-browser.test.ts b/apps/desktop/src/store/preview-open-browser.test.ts new file mode 100644 index 0000000000..a2d0a4d6fc --- /dev/null +++ b/apps/desktop/src/store/preview-open-browser.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { $previewTabs, closeRightRail, openBrowserTab, openPreview } from './preview' + +beforeEach(() => { + closeRightRail() +}) + +describe('openBrowserTab', () => { + it('opens a blank browser when there is no browser tab yet', () => { + openBrowserTab() + + const tabs = $previewTabs.get() + + expect(tabs).toHaveLength(1) + // about:blank, so the pane lands on its empty state inviting an address + // rather than a white void. + expect(tabs[0]?.target.url).toBe('about:blank') + }) + + // The hotkey is "show me the browser", not "reset the browser" — pressing it + // while a page is loaded must not throw that page away. + it('re-fronts the existing page instead of blanking it', () => { + openPreview({ kind: 'url', label: 'Example', source: 'https://example.com', url: 'https://example.com' }) + openBrowserTab() + + const tabs = $previewTabs.get() + + expect(tabs).toHaveLength(1) + expect(tabs[0]?.target.url).toBe('https://example.com') + }) + + it('reuses the one Browser tab rather than stacking duplicates', () => { + openBrowserTab() + openBrowserTab() + openBrowserTab() + + expect($previewTabs.get()).toHaveLength(1) + }) + + // A file tab is keyed by identity; only the web surface is a singleton. + it('leaves other preview tabs alone', () => { + openPreview({ kind: 'file', label: 'notes.md', source: '/work/notes.md', url: 'file:///work/notes.md' }) + openBrowserTab() + + const tabs = $previewTabs.get() + + expect(tabs).toHaveLength(2) + expect(tabs.map(tab => tab.target.url)).toContain('file:///work/notes.md') + }) +}) diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 5a0212b32c..6304ee99ea 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -226,6 +226,15 @@ export function openPreview(target: PreviewTarget, source: PreviewRecordSource = selectRightRailTab(id) } +/** Open the Browser tab — the surface, not a page. Keeps whatever it was last + * showing so the hotkey re-fronts your page instead of wiping it; a fresh tab + * lands on `about:blank`, where the pane's empty state invites an address. */ +export function openBrowserTab() { + const existing = $previewTabs.get().find(tab => tab.id === BROWSER_TAB_ID) + + openPreview(existing?.target ?? { kind: 'url', label: 'Browser', source: 'about:blank', url: 'about:blank' }) +} + export function closeRightRailTab(tabId: string) { const current = $previewTabs.get() const index = current.findIndex(tab => tab.id === tabId)