diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index ba67de65d..68142e52b 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -442,6 +442,8 @@ export function setupGuestShortcutForwarding(args: { renderer.send('ui:openQuickOpen') } else if (action?.type === 'openNewWorkspace') { renderer.send('ui:openNewWorkspace') + } else if (action?.type === 'openWorkspaceBoard') { + renderer.send('ui:openWorkspaceBoard') } else if (action?.type === 'openTasks') { renderer.send('ui:openTasks') } else if (action?.type === 'openSettings') { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 87cec7b36..cf380b0e0 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -832,6 +832,11 @@ export function createMainWindow( return } + if (action.type === 'openWorkspaceBoard') { + mainWindow.webContents.send('ui:openWorkspaceBoard') + return + } + if (action.type === 'openTasks') { mainWindow.webContents.send('ui:openTasks') return diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index bc5566ca3..bafab21ed 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2305,6 +2305,7 @@ export type PreloadApi = { onOpenQuickOpen: (callback: () => void) => () => void onOpenNewWorkspace: (callback: () => void) => () => void onDeleteCurrentWorkspace: (callback: () => void) => () => void + onOpenWorkspaceBoard: (callback: () => void) => () => void onOpenTasks: (callback: () => void) => () => void onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onJumpToTabIndex: (callback: (index: number) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index d975ec528..ec22788f6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2744,6 +2744,11 @@ const api = { ipcRenderer.on('ui:deleteCurrentWorkspace', listener) return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) }, + onOpenWorkspaceBoard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openWorkspaceBoard', listener) + return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) + }, onOpenTasks: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openTasks', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e57f67452..fb73d8b11 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -64,6 +64,7 @@ import { } from '@/lib/floating-workspace-terminal-actions' import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' +import { OPEN_WORKSPACE_BOARD_EVENT } from './components/sidebar/useWorkspaceBoardPanel' import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' import { CrashReportDialog } from './components/crash-report/CrashReportDialog' import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal' @@ -1531,6 +1532,15 @@ function App(): React.JSX.Element { return } + if (matchShortcut('workspace.openBoard') && activeView !== 'settings') { + e.preventDefault() + notifyTerminalCapture('workspace.openBoard') + const store = useAppStore.getState() + store.setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) + return + } + // Why: Cmd/Ctrl+N is handled via the main-process before-input-event // allowlist (see window-shortcut-policy.ts / useIpcEvents.ts) so it works // globally — including when focus lives inside the markdown rich editor diff --git a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx index 8dc7e6db9..000180007 100644 --- a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx +++ b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx @@ -3,7 +3,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useWorkspaceBoardPanel, type WorkspaceBoardPanelState } from './useWorkspaceBoardPanel' +import { + OPEN_WORKSPACE_BOARD_EVENT, + useWorkspaceBoardPanel, + type WorkspaceBoardPanelState +} from './useWorkspaceBoardPanel' const mocks = vi.hoisted(() => ({ recordFeatureInteraction: vi.fn() @@ -84,6 +88,18 @@ describe('useWorkspaceBoardPanel', () => { expect(mocks.recordFeatureInteraction).toHaveBeenCalledOnce() }) + it('opens the board from the shortcut bridge event', async () => { + await renderHookProbe() + + await act(async () => { + window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) + }) + + expect(panelState().workspaceBoardOpen).toBe(true) + expect(panelState().workspaceBoardRenderedOpen).toBe(true) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledExactlyOnceWith('workspace-board') + }) + it('renders a drag preview without recording an open interaction', async () => { await renderHookProbe() diff --git a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts index 26efafadb..8d99d225e 100644 --- a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts +++ b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts @@ -11,6 +11,8 @@ const WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [ '[role="listbox"][data-state="open"]' ].join(', ') +export const OPEN_WORKSPACE_BOARD_EVENT = 'orca:open-workspace-board' + export type WorkspaceBoardPanelState = { workspaceBoardOpen: boolean workspaceBoardRenderedOpen: boolean @@ -137,6 +139,11 @@ export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState { return () => document.removeEventListener('keydown', handleKeyDown, true) }, [closeWorkspaceBoard, workspaceBoardMenuOpen, workspaceBoardOpen]) + useEffect(() => { + window.addEventListener(OPEN_WORKSPACE_BOARD_EVENT, openWorkspaceBoard) + return () => window.removeEventListener(OPEN_WORKSPACE_BOARD_EVENT, openWorkspaceBoard) + }, [openWorkspaceBoard]) + return { workspaceBoardOpen, workspaceBoardRenderedOpen: workspaceBoardOpen || workspaceBoardDragPreviewOpen, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 88eba37e4..8514fbac0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -8,6 +8,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, SPLIT_TERMINAL_PANE_EVENT, @@ -1156,6 +1157,19 @@ export function useIpcEvents(): void { ) } + if (window.api.ui.onOpenWorkspaceBoard) { + unsubs.push( + window.api.ui.onOpenWorkspaceBoard(() => { + const store = useAppStore.getState() + if (store.activeView === 'settings') { + return + } + store.setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) + }) + ) + } + unsubs.push( window.api.ui.onOpenTasks(() => { const store = useAppStore.getState() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 158fd537f..964d01008 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1988,6 +1988,7 @@ function createWebUiApi(): NonNullable['ui']> { onOpenTasks: () => noopUnsubscribe, onOpenNewWorkspace: () => noopUnsubscribe, onDeleteCurrentWorkspace: () => noopUnsubscribe, + onOpenWorkspaceBoard: () => noopUnsubscribe, onJumpToWorktreeIndex: () => noopUnsubscribe, onJumpToTabIndex: () => noopUnsubscribe, onWorktreeHistoryNavigate: () => noopUnsubscribe, diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index c9cf926be..74e7543b9 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -381,6 +381,31 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps workspace board unassigned until users customize it', () => { + const binding = { + key: 'k', + code: 'KeyK', + control: true, + meta: false, + alt: true, + shift: false + } + + expect(getEffectiveKeybindingsForAction('workspace.openBoard', 'linux')).toEqual([]) + expect(keybindingMatchesAction('workspace.openBoard', binding, 'linux')).toBe(false) + expect( + keybindingMatchesAction('workspace.openBoard', binding, 'linux', { + 'workspace.openBoard': ['Mod+Alt+K'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('workspace.openBoard') + expect(definition?.title).toBe('Open Workspace Board') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['workspace', 'board', 'kanban']) + ) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 83430262f..35be144a8 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -37,6 +37,7 @@ export type KeybindingActionId = | 'workspace.create' | 'workspace.rename' | 'workspace.delete' + | 'workspace.openBoard' | 'voice.dictation' | 'view.tasks' | 'sidebar.left.toggle' @@ -273,6 +274,17 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings([]), allowInTerminal: true }, + { + id: 'workspace.openBoard', + title: 'Open Workspace Board', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'workspace', 'board', 'kanban', 'worktree'], + // Why: make the command configurable without taking a global chord from + // terminal/browser/editor users by default. + defaultBindings: platformBindings([]), + allowInTerminal: true + }, { id: 'voice.dictation', title: 'Dictation', diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 580684037..d6f00352f 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -239,6 +239,7 @@ describe('resolveWindowShortcutAction', () => { it('applies custom keybinding overrides to main-process shortcuts', () => { const overrides: KeybindingOverrides = { 'worktree.quickOpen': ['Mod+Shift+O'], + 'workspace.openBoard': ['Mod+Alt+B'], 'view.tasks': ['Mod+Alt+K'] } @@ -256,6 +257,13 @@ describe('resolveWindowShortcutAction', () => { overrides ) ).toEqual({ type: 'openQuickOpen' }) + expect( + resolveWindowShortcutAction( + { code: 'KeyB', key: 'b', meta: false, control: true, alt: true, shift: false }, + 'linux', + overrides + ) + ).toEqual({ type: 'openWorkspaceBoard' }) expect( resolveWindowShortcutAction( { code: 'KeyK', key: 'k', meta: false, control: true, alt: true, shift: false }, diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index 6dbc9cbca..7aa288e14 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -34,6 +34,7 @@ export type WindowShortcutAction = | { type: 'openQuickOpen' } | { type: 'openNewWorkspace' } | { type: 'deleteCurrentWorkspace' } + | { type: 'openWorkspaceBoard' } | { type: 'openTasks' } | { type: 'switchRecentTab' } | { type: 'jumpToWorktreeIndex'; index: number } @@ -223,6 +224,10 @@ export function resolveWindowShortcutAction( return { type: 'deleteCurrentWorkspace' } } + if (actionMatches('workspace.openBoard', input, platform, keybindings, options)) { + return { type: 'openWorkspaceBoard' } + } + if (actionMatches('voice.dictation', input, platform, keybindings, options)) { return { type: 'dictationKeyDown' } } @@ -291,6 +296,8 @@ export function getWindowShortcutActionId(action: WindowShortcutAction): Keybind return 'workspace.create' case 'deleteCurrentWorkspace': return 'workspace.delete' + case 'openWorkspaceBoard': + return 'workspace.openBoard' case 'openTasks': return 'view.tasks' case 'switchRecentTab':