Add configurable workspace board shortcut (#5638)

This commit is contained in:
Neil 2026-06-17 16:08:46 -07:00 committed by GitHub
parent 0ec3882cb8
commit 8eb96c4ec5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 114 additions and 1 deletions

View File

@ -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') {

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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()

View File

@ -1988,6 +1988,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onOpenTasks: () => noopUnsubscribe,
onOpenNewWorkspace: () => noopUnsubscribe,
onDeleteCurrentWorkspace: () => noopUnsubscribe,
onOpenWorkspaceBoard: () => noopUnsubscribe,
onJumpToWorktreeIndex: () => noopUnsubscribe,
onJumpToTabIndex: () => noopUnsubscribe,
onWorktreeHistoryNavigate: () => noopUnsubscribe,

View File

@ -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([])

View File

@ -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',

View File

@ -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 },

View File

@ -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':