From f3e19d14635dcef7d5c5e2cdf2671df6a5f54757 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 8 Jun 2026 13:33:06 +0200 Subject: [PATCH] feat: add keyboard navigation ladder --- .changeset/keyboard-navigation-ladder.md | 5 + .../src/appShell/panelCollapseController.ts | 6 + src/client/src/components/MachineList.ts | 18 ++- src/client/src/components/MachineSwitcher.ts | 122 ++++++++++++++++- src/client/src/components/PiWebApp.ts | 123 +++++++++++++----- src/client/src/components/ProjectList.ts | 24 +++- src/client/src/components/SessionList.ts | 3 +- src/client/src/components/WorkspaceList.ts | 20 ++- .../components/appShell/AppNavigationPanel.ts | 70 +++++++++- src/client/src/components/navigationFocus.ts | 3 + .../src/components/selectableRow.test.ts | 26 +++- src/client/src/components/selectableRow.ts | 79 +++++++++++ src/client/src/keyboardShortcuts.test.ts | 21 +++ src/client/src/keyboardShortcuts.ts | 31 +++-- src/client/src/plugins/core/actions.ts | 4 +- src/client/src/plugins/registry.test.ts | 1 + 16 files changed, 492 insertions(+), 64 deletions(-) create mode 100644 .changeset/keyboard-navigation-ladder.md create mode 100644 src/client/src/components/navigationFocus.ts diff --git a/.changeset/keyboard-navigation-ladder.md b/.changeset/keyboard-navigation-ladder.md new file mode 100644 index 0000000..fa456f0 --- /dev/null +++ b/.changeset/keyboard-navigation-ladder.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add keyboard-first navigation for focusing Machines, Projects, Workspaces, Sessions, and the chat composer. diff --git a/src/client/src/appShell/panelCollapseController.ts b/src/client/src/appShell/panelCollapseController.ts index 378563d..668f97b 100644 --- a/src/client/src/appShell/panelCollapseController.ts +++ b/src/client/src/appShell/panelCollapseController.ts @@ -23,6 +23,12 @@ export class PanelCollapseController implements ReactiveController { this.host.requestUpdate(); } + expandNavigationPanel(): void { + if (!this.navigationPanelCollapsed) return; + this.navigationPanelCollapsed = false; + this.host.requestUpdate(); + } + shellClass(mainView: AppState["mainView"]): string { return [ "shell", diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index 8ac457b..b9aaec1 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -4,11 +4,12 @@ import type { Machine, MachineHealth, WorkspaceActivity } from "../api"; import { machineActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; import { renderActionActivityIndicator } from "./activityBadge"; -import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; +import type { KeyboardNavigableSection } from "./navigationFocus"; +import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @customElement("machine-list") -export class MachineList extends LitElement { +export class MachineList extends LitElement implements KeyboardNavigableSection { @property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) selected?: Machine; @property({ attribute: false }) statuses: Record = {}; @@ -18,6 +19,8 @@ export class MachineList extends LitElement { @property({ attribute: false }) onSelect?: (machine: Machine) => void; @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise; @property({ attribute: false }) onToggleCollapsed?: () => void; + @property({ attribute: false }) onFocusNextSection?: () => void | Promise; + @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; @state() private openMenuMachineId: string | undefined; @state() private menuStyle = ""; @@ -41,6 +44,11 @@ export class MachineList extends LitElement { if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined; } + async focusSelectedOrFirst(): Promise { + await this.updateComplete; + return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" }); + } + override render() { return html`
@@ -132,7 +140,11 @@ export class MachineList extends LitElement { this.openMenuMachineId = undefined; return; } - activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); + handleSelectableRowKeyboard(event, { + activate: () => this.onSelect?.(machine), + nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); }, + cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); }, + }); } static override styles = [ diff --git a/src/client/src/components/MachineSwitcher.ts b/src/client/src/components/MachineSwitcher.ts index dad943a..c32383c 100644 --- a/src/client/src/components/MachineSwitcher.ts +++ b/src/client/src/components/MachineSwitcher.ts @@ -5,15 +5,18 @@ import { machineActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; import { renderActivityIndicator } from "./activityBadge"; import { canRemoveMachine } from "./MachineList"; +import type { KeyboardNavigableSection } from "./navigationFocus"; @customElement("machine-switcher") -export class MachineSwitcher extends LitElement { +export class MachineSwitcher extends LitElement implements KeyboardNavigableSection { @property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) selected?: Machine; @property({ attribute: false }) statuses: Record = {}; @property({ attribute: false }) activities: Record> = {}; @property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise; + @property({ attribute: false }) onFocusNextSection?: () => void | Promise; + @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; @state() private open = false; @state() private menuStyle = ""; @state() private openActionsMachineId: string | undefined; @@ -40,6 +43,12 @@ export class MachineSwitcher extends LitElement { if (changed.has("machines") && this.openActionsMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openActionsMachineId)) this.openActionsMachineId = undefined; } + async focusSelectedOrFirst(): Promise { + const button = this.switcherButton(); + if (button === null) return false; + return await this.openMenuAndFocusOption(button); + } + override render() { const selected = this.selectedMachine(); if (selected === undefined) return null; @@ -54,6 +63,7 @@ export class MachineSwitcher extends LitElement { aria-label=${`Machine: ${label}. Switch machine.`} aria-expanded=${String(this.open)} @click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }} + @keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }} > ${this.renderActivity(selected)} @@ -83,7 +93,9 @@ export class MachineSwitcher extends LitElement { type="button" class="machine-option-main" title=${machineTitle(machine)} + data-machine-id=${machine.id} @click=${() => { this.select(machine); }} + @keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }} > ${this.renderActivity(machine)}${machine.name} ${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} ยท ${machineStatusLabel(status)} @@ -120,12 +132,120 @@ export class MachineSwitcher extends LitElement { return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0]; } + private switcherButton(): HTMLElement | null { + return this.renderRoot.querySelector(".machine-switcher-button"); + } + + private handleSwitcherButtonKeydown(event: KeyboardEvent): void { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + void this.openMenuAndFocusOption(event.currentTarget); + return; + } + if (event.key === "ArrowRight" && this.onFocusNextSection !== undefined) { + event.preventDefault(); + event.stopPropagation(); + void this.onFocusNextSection(); + return; + } + if (event.key === "Escape" && this.onCancelKeyboardNavigation !== undefined) { + event.preventDefault(); + event.stopPropagation(); + void this.onCancelKeyboardNavigation(); + } + } + + private handleMachineOptionKeydown(event: KeyboardEvent): void { + if (event.key === "ArrowUp") { + this.focusRelativeMachineOption(event.currentTarget, -1, event); + return; + } + if (event.key === "ArrowDown") { + this.focusRelativeMachineOption(event.currentTarget, 1, event); + return; + } + if (event.key === "Home") { + this.focusIndexedMachineOption(0, event); + return; + } + if (event.key === "End") { + this.focusIndexedMachineOption(-1, event); + return; + } + if (event.key === "ArrowRight" && this.onFocusNextSection !== undefined) { + event.preventDefault(); + event.stopPropagation(); + this.open = false; + void this.onFocusNextSection(); + return; + } + if (event.key === "ArrowLeft" || event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.open = false; + void this.updateComplete.then(() => { this.focusSwitcherButton(); }); + } + } + private toggleMenu(target: EventTarget | null): void { this.menuStyle = machineSwitcherMenuStyle(target); this.open = !this.open; this.openActionsMachineId = undefined; } + private focusSwitcherButton(): boolean { + const button = this.switcherButton(); + if (button === null) return false; + button.focus(); + return true; + } + + private async openMenuAndFocusOption(target: EventTarget | null): Promise { + this.menuStyle = machineSwitcherMenuStyle(target); + this.open = true; + this.openActionsMachineId = undefined; + await this.updateComplete; + return this.focusSelectedMachineOption(); + } + + private focusSelectedMachineOption(): boolean { + const selected = this.renderRoot.querySelector(".machine-option.selected .machine-option-main"); + const first = this.machineOptionButtons()[0]; + const target = selected ?? first; + if (target === undefined) return false; + target.focus(); + target.scrollIntoView({ block: "nearest" }); + return true; + } + + private focusRelativeMachineOption(target: EventTarget | null, delta: number, event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); + const buttons = this.machineOptionButtons(); + if (buttons.length === 0 || !(target instanceof HTMLElement)) return; + const index = buttons.indexOf(target); + if (index < 0) return; + this.focusMachineOptionAt(index + delta); + } + + private focusIndexedMachineOption(index: number, event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.focusMachineOptionAt(index < 0 ? this.machineOptionButtons().length - 1 : index); + } + + private focusMachineOptionAt(index: number): void { + const buttons = this.machineOptionButtons(); + const target = buttons[Math.min(Math.max(index, 0), buttons.length - 1)]; + target?.focus(); + target?.scrollIntoView({ block: "nearest" }); + } + + private machineOptionButtons(): HTMLElement[] { + return Array.from(this.renderRoot.querySelectorAll(".machine-option-main")); + } + private toggleActionsMenu(machineId: string, target: EventTarget | null): void { if (this.openActionsMachineId === machineId) { this.openActionsMachineId = undefined; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 19646c9..c619965 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -55,7 +55,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; import "./appShell/AppContextBar"; import "./appShell/AppMobileMainTabs"; import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs"; -import "./appShell/AppNavigationPanel"; +import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget } from "./appShell/AppNavigationPanel"; import "./appShell/AppPanelEdgeControl"; import "./appShell/AppRefreshControl"; import { appStyles } from "./shared"; @@ -75,6 +75,7 @@ export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); @query("chat-view") private chatView?: ChatView; @query("prompt-editor") private promptEditor?: PromptEditor; + @query("app-navigation-panel") private navigationPanel?: AppNavigationPanel; private readonly sessions = new SessionController( () => this.state, @@ -765,12 +766,7 @@ export class PiWebApp extends LitElement { `; } - private renderNavigationPanel(autoSwitchToChat: boolean) { - const openChatAfter = (action: () => Promise) => this.withChatScrollTransition(async () => { - await action(); - if (autoSwitchToChat) this.setState({ mainView: "chat" }); - if (autoSwitchToChat) this.updateUrl(); - }); + private renderNavigationPanel() { return html` { this.navigationSections.toggle("machines"); }} - .onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => { - this.navigationSections.advanceAfterSelection("machines"); - await this.selectMachineWithMemory(machine); - })} + .onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))} .onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }} .projects=${this.state.projects} .selectedProject=${this.state.selectedProject} @@ -807,33 +800,20 @@ export class PiWebApp extends LitElement { .onToggleProjects=${() => { this.navigationSections.toggle("projects"); }} .onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }} .onToggleSessions=${() => { this.navigationSections.toggle("sessions"); }} - .onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => { - this.navigationSections.advanceAfterSelection("projects"); - await this.workspaces.selectProject(project); - })} + .onSelectProject=${(project: Project) => this.selectNavigationItem("projects", "workspaces", () => this.workspaces.selectProject(project))} .onCloseProject=${(project: Project) => this.projects.closeProject(project.id)} - .onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => { - this.navigationSections.advanceAfterSelection("workspaces"); - await this.workspaces.selectWorkspace(workspace); - })} + .onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))} .onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} - .onStartSession=${() => openChatAfter(() => { - this.navigationSections.advanceAfterSelection("sessions"); - return this.sessions.startSession(); - })} - .onSelectSession=${(session: SessionInfo) => openChatAfter(() => { - this.navigationSections.advanceAfterSelection("sessions"); - return this.sessions.selectSession(session); - })} + .onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())} + .onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))} .onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} - .onRestoreSession=${(session: SessionInfo) => openChatAfter(() => { - this.navigationSections.advanceAfterSelection("sessions"); - return this.sessions.restoreSession(session); - })} + .onRestoreSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.restoreSession(session))} .onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} + .onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }} + .onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }} > `; } @@ -842,6 +822,42 @@ export class PiWebApp extends LitElement { this.navigationSections.open(section, () => { this.selectMainView("navigation"); }); } + private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise): Promise { + await this.withChatScrollTransition(async () => { + this.navigationSections.advanceAfterSelection(section); + await action(); + }); + await this.focusNavigationTarget(nextTarget); + } + + private async focusNavigationTarget(target: NavigationFocusTarget): Promise { + if (target === "chat") { + await this.focusChatComposer(); + return; + } + await this.focusNavigationSection(target); + } + + private async focusNavigationSection(section: NavigationSection): Promise { + if (section === "machines" && !shouldShowMachinesSection(this.state.machines)) { + await this.focusNavigationSection("projects"); + return; + } + this.panelCollapse.expandNavigationPanel(); + if (this.appShell.isMobileNavigationLayout) this.selectMainView("navigation"); + this.navigationSections.expand(section); + await this.updateComplete; + await nextFrame(); + await this.navigationPanel?.focusSection(section); + } + + private async focusChatComposer(): Promise { + if (this.state.mainView !== "chat") this.selectMainView("chat"); + await this.updateComplete; + await nextFrame(); + this.promptEditor?.focusInput(); + } + private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] { const workspace = this.state.selectedWorkspace; if (workspace === undefined) return []; @@ -978,7 +994,44 @@ export class PiWebApp extends LitElement { } private getActions(): AppAction[] { - return applyShortcutPreferences(this.plugins.getActions(this.createPluginRuntimeContext()), this.shortcutConfig); + return applyShortcutPreferences([...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()], this.shortcutConfig); + } + + private navigationFocusActions(): AppAction[] { + return [ + { + id: "app.navigation.focus-machines", + title: "Focus Machines", + description: "Move keyboard focus to the machine selector", + shortcut: "mod+g m", + group: "Navigation", + run: () => this.focusNavigationSection("machines"), + }, + { + id: "app.navigation.focus-projects", + title: "Focus Projects", + description: "Move keyboard focus to the projects list", + shortcut: "mod+g p", + group: "Navigation", + run: () => this.focusNavigationSection("projects"), + }, + { + id: "app.navigation.focus-workspaces", + title: "Focus Workspaces", + description: "Move keyboard focus to the workspaces list", + shortcut: "mod+g w", + group: "Navigation", + run: () => this.focusNavigationSection("workspaces"), + }, + { + id: "app.navigation.focus-sessions", + title: "Focus Sessions", + description: "Move keyboard focus to the sessions list", + shortcut: "mod+g s", + group: "Navigation", + run: () => this.focusNavigationSection("sessions"), + }, + ]; } private ensureGatewayPluginsLoaded(): Promise { @@ -1036,7 +1089,7 @@ export class PiWebApp extends LitElement { openSettings: (section) => { this.openSettings(section); }, }, openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, - focusPrompt: () => { this.promptEditor?.focusInput(); }, + focusPrompt: () => { void this.focusChatComposer(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, addMachine: () => { this.openMachineDialog(); }, refreshSelectedMachine: () => this.machines.refreshMachineHealth(), @@ -1381,13 +1434,13 @@ export class PiWebApp extends LitElement { const state = this.state; return html`
- + ${this.renderNavigationPanelEdgeControl()}
${this.renderContextBar()} ${this.renderMobileMainTabs()} ${state.error ? html`
${state.error}
` : null} -
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
+
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 6c72c9a..80b492d 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -4,11 +4,12 @@ import type { Project, Workspace, WorkspaceActivity } from "../api"; import { projectActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; import { renderActionActivityIndicator } from "./activityBadge"; -import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; +import type { KeyboardNavigableSection } from "./navigationFocus"; +import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @customElement("project-list") -export class ProjectList extends LitElement { +export class ProjectList extends LitElement implements KeyboardNavigableSection { @property({ attribute: false }) projects: Project[] = []; @property({ attribute: false }) selected?: Project; @property({ attribute: false }) activities: Record = {}; @@ -18,6 +19,9 @@ export class ProjectList extends LitElement { @property({ attribute: false }) onSelect?: (project: Project) => void; @property({ attribute: false }) onClose?: (project: Project) => void; @property({ attribute: false }) onToggleCollapsed?: () => void; + @property({ attribute: false }) onFocusPreviousSection?: () => void | Promise; + @property({ attribute: false }) onFocusNextSection?: () => void | Promise; + @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; @state() private openMenuProjectId: string | undefined; @state() private menuStyle = ""; private readonly onDocumentClick = (event: MouseEvent) => { @@ -40,6 +44,11 @@ export class ProjectList extends LitElement { if (changed.has("collapsed") && this.collapsed) this.openMenuProjectId = undefined; } + async focusSelectedOrFirst(): Promise { + await this.updateComplete; + return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" }); + } + override render() { return html`
@@ -52,7 +61,7 @@ export class ProjectList extends LitElement { tabindex="0" title=${project.path} @click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }} - @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }} + @keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }} >
${project.name}${project.path} @@ -74,6 +83,15 @@ export class ProjectList extends LitElement { `; } + private handleProjectKeydown(event: KeyboardEvent, project: Project): void { + handleSelectableRowKeyboard(event, { + activate: () => this.onSelect?.(project), + previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); }, + nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); }, + cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); }, + }); + } + private renderHeading() { if (!this.collapsible) return "Projects"; const selectedSummary = this.selected?.name ?? "No project selected"; diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index f85a88e..1c62994 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -5,6 +5,7 @@ import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { isSessionActive } from "../../../shared/activity"; import { actionMenuPanelStyle } from "./actionMenu"; import { renderActionActivityIndicator } from "./activityBadge"; +import type { KeyboardNavigableSection } from "./navigationFocus"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -20,7 +21,7 @@ interface SessionRow { } @customElement("session-list") -export class SessionList extends LitElement { +export class SessionList extends LitElement implements KeyboardNavigableSection { @property({ attribute: false }) sessions: SessionInfo[] = []; @property({ attribute: false }) statuses: Record = {}; @property({ attribute: false }) activities: Record = {}; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 9bc2d4e..6844c4d 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -5,12 +5,13 @@ import type { WorkspaceLabelItem } from "../plugins/types"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; import { renderActionActivityIndicator } from "./activityBadge"; -import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; +import type { KeyboardNavigableSection } from "./navigationFocus"; +import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; import { renderWorkspaceLabelInlineItems } from "./workspaceLabel"; @customElement("workspace-list") -export class WorkspaceList extends LitElement { +export class WorkspaceList extends LitElement implements KeyboardNavigableSection { @property({ attribute: false }) workspaces: Workspace[] = []; @property({ attribute: false }) selected?: Workspace; @property({ type: Boolean, reflect: true }) collapsible = false; @@ -21,6 +22,9 @@ export class WorkspaceList extends LitElement { @property({ attribute: false }) onSelect?: (workspace: Workspace) => void; @property({ attribute: false }) onDelete?: (workspace: Workspace) => void; @property({ attribute: false }) onToggleCollapsed?: () => void; + @property({ attribute: false }) onFocusPreviousSection?: () => void | Promise; + @property({ attribute: false }) onFocusNextSection?: () => void | Promise; + @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; @state() private openMenuWorkspaceId: string | undefined; @state() private menuStyle = ""; @@ -45,6 +49,11 @@ export class WorkspaceList extends LitElement { if ((changed.has("selected") || changed.has("workspaces") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView(); } + async focusSelectedOrFirst(): Promise { + await this.updateComplete; + return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" }); + } + override render() { return html`
@@ -182,7 +191,12 @@ export class WorkspaceList extends LitElement { this.openMenuWorkspaceId = undefined; return; } - activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace)); + handleSelectableRowKeyboard(event, { + activate: () => this.onSelect?.(workspace), + previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); }, + nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); }, + cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); }, + }); } private scrollSelectedIntoView(): void { diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index cd5aa2e..57f6728 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -1,13 +1,18 @@ import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, query } from "lit/decorators.js"; import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api"; import type { WorkspaceLabelItem } from "../../plugins/types"; +import type { NavigationSection } from "../../appShell/navigationState"; +import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState"; +import type { KeyboardNavigableSection } from "../navigationFocus"; import "../MachineList"; import "../MachineSwitcher"; import "../ProjectList"; import "../WorkspaceList"; import "../SessionList"; +export type NavigationFocusTarget = NavigationSection | "chat"; + @customElement("app-navigation-panel") export class AppNavigationPanel extends LitElement { @property({ attribute: false }) machines: Machine[] = []; @@ -53,6 +58,24 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; + @property({ attribute: false }) onFocusNavigationTarget?: (target: NavigationFocusTarget) => void | Promise; + @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; + + @query("machine-list") private machineList?: KeyboardNavigableSection; + @query("machine-switcher") private machineSwitcher?: KeyboardNavigableSection; + @query("project-list") private projectList?: KeyboardNavigableSection; + @query("workspace-list") private workspaceList?: KeyboardNavigableSection; + @query("session-list") private sessionList?: KeyboardNavigableSection; + + async focusSection(section: NavigationSection): Promise { + await this.updateComplete; + switch (section) { + case "machines": return await this.focusNavigableSection(this.compact ? this.machineList : this.machineSwitcher); + case "projects": return await this.focusNavigableSection(this.projectList); + case "workspaces": return await this.focusNavigableSection(this.workspaceList); + case "sessions": return await this.focusNavigableSection(this.sessionList); + } + } override render() { return html` @@ -66,6 +89,8 @@ export class AppNavigationPanel extends LitElement { .activities=${this.machineActivities} .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} + .onFocusNextSection=${() => { this.focusNextFrom("machines"); }} + .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} > ` : null}
@@ -84,6 +109,8 @@ export class AppNavigationPanel extends LitElement { .onToggleCollapsed=${() => { this.onToggleMachines?.(); }} .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} + .onFocusNextSection=${() => { this.focusNextFrom("machines"); }} + .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} > ` : null} { this.onToggleProjects?.(); }} .onSelect=${(project: Project) => this.onSelectProject?.(project)} .onClose=${(project: Project) => this.onCloseProject?.(project)} + .onFocusPreviousSection=${() => { this.focusPreviousFrom("projects"); }} + .onFocusNextSection=${() => { this.focusNextFrom("projects"); }} + .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} > { this.onToggleWorkspaces?.(); }} .onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)} .onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)} + .onFocusPreviousSection=${() => { this.focusPreviousFrom("workspaces"); }} + .onFocusNextSection=${() => { this.focusNextFrom("workspaces"); }} + .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} > this.onRestoreSession?.(session)} .onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} + .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} + .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} + .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} > `; } + private async focusNavigableSection(section: KeyboardNavigableSection | undefined): Promise { + if (section === undefined) return false; + return await section.focusSelectedOrFirst(); + } + + private focusPreviousFrom(section: NavigationSection): void { + const target = previousVisibleNavigationTarget(section, this.machines); + if (target !== undefined) void this.onFocusNavigationTarget?.(target); + } + + private focusNextFrom(section: NavigationSection): void { + void this.onFocusNavigationTarget?.(nextVisibleNavigationTarget(section, this.machines)); + } + + private cancelKeyboardNavigation(): void { + void this.onCancelKeyboardNavigation?.(); + } + static override styles = css` :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } :host([compact]) { flex: 1 1 auto; } @@ -159,3 +213,17 @@ export class AppNavigationPanel extends LitElement { export function shouldShowMachinesSection(machines: readonly Machine[]): boolean { return machines.length > 1; } + +function previousVisibleNavigationTarget(section: NavigationSection, machines: readonly Machine[]): NavigationSection | undefined { + const sections = visibleNavigationSections(machines); + return sections[sections.indexOf(section) - 1]; +} + +function nextVisibleNavigationTarget(section: NavigationSection, machines: readonly Machine[]): NavigationFocusTarget { + const sections = visibleNavigationSections(machines); + return sections[sections.indexOf(section) + 1] ?? "chat"; +} + +function visibleNavigationSections(machines: readonly Machine[]): NavigationSection[] { + return NAVIGATION_SECTION_ORDER.filter((section) => section !== "machines" || shouldShowMachinesSection(machines)); +} diff --git a/src/client/src/components/navigationFocus.ts b/src/client/src/components/navigationFocus.ts new file mode 100644 index 0000000..6f77c28 --- /dev/null +++ b/src/client/src/components/navigationFocus.ts @@ -0,0 +1,3 @@ +export interface KeyboardNavigableSection { + focusSelectedOrFirst(): boolean | Promise; +} diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index e2973b8..ce63d26 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; +import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow"; describe("selectable row activation", () => { it("activates rows from non-interactive click targets", () => { @@ -38,10 +38,30 @@ describe("selectable row activation", () => { expect(action).not.toHaveBeenCalled(); expect(event.preventDefault).not.toHaveBeenCalled(); }); + + it("routes row keyboard navigation to adjacent section callbacks", () => { + const nextSection = vi.fn(); + const event = keyboardEventWithPath("ArrowRight", matchTarget(() => false)); + + expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), nextSection })).toBe(true); + + expect(nextSection).toHaveBeenCalledOnce(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); + + it("routes Escape row keyboard navigation to cancel", () => { + const cancel = vi.fn(); + const event = keyboardEventWithPath("Escape", matchTarget(() => false)); + + expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true); + + expect(cancel).toHaveBeenCalledOnce(); + }); }); type EventWithPath = Pick; -type KeyboardEventWithPath = EventWithPath & Pick; +type KeyboardEventWithPath = EventWithPath & Pick; type MatchTarget = EventTarget & Pick; function matchTarget(matches: Element["matches"]): MatchTarget { @@ -53,5 +73,5 @@ function eventWithPath(target: MatchTarget): EventWithPath { } function keyboardEventWithPath(key: string, target: MatchTarget): KeyboardEventWithPath { - return { key, preventDefault: vi.fn<() => void>(), composedPath: () => [target] }; + return { key, preventDefault: vi.fn<() => void>(), stopPropagation: vi.fn<() => void>(), composedPath: () => [target] }; } diff --git a/src/client/src/components/selectableRow.ts b/src/client/src/components/selectableRow.ts index 7847ab8..1bda886 100644 --- a/src/client/src/components/selectableRow.ts +++ b/src/client/src/components/selectableRow.ts @@ -12,6 +12,14 @@ const interactiveSelector = [ type ComposedPathEvent = Pick; type SelectableKeyboardEvent = ComposedPathEvent & Pick; +type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial>; + +export interface SelectableRowKeyboardOptions { + activate: () => void; + previousSection?: (() => void) | undefined; + nextSection?: (() => void) | undefined; + cancel?: (() => void) | undefined; +} export function isFromInteractiveElement(event: ComposedPathEvent): boolean { return event.composedPath().some((target) => targetMatches(target, interactiveSelector)); @@ -35,3 +43,74 @@ export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent event.preventDefault(); action(); } + +export function handleSelectableRowKeyboard(event: SelectableNavigationKeyboardEvent, options: SelectableRowKeyboardOptions): boolean { + if (isFromInteractiveElement(event)) return false; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + options.activate(); + return true; + } + if (event.key === "ArrowUp") return handleRowFocusKey(event, () => { focusRelativeSelectableRow(event.currentTarget, -1); }); + if (event.key === "ArrowDown") return handleRowFocusKey(event, () => { focusRelativeSelectableRow(event.currentTarget, 1); }); + if (event.key === "Home") return handleRowFocusKey(event, () => { focusIndexedSelectableRow(event.currentTarget, 0); }); + if (event.key === "End") return handleRowFocusKey(event, () => { focusIndexedSelectableRow(event.currentTarget, -1); }); + if (event.key === "ArrowLeft" && options.previousSection !== undefined) return handleRowFocusKey(event, options.previousSection); + if (event.key === "ArrowRight" && options.nextSection !== undefined) return handleRowFocusKey(event, options.nextSection); + if (event.key === "Escape" && options.cancel !== undefined) return handleRowFocusKey(event, options.cancel); + return false; +} + +export function focusSelectedOrFirstSelectableRow(root: ParentNode, options: { fallbackSelector?: string | undefined } = {}): boolean { + const target = root.querySelector(".action-row.selected") + ?? root.querySelector(".action-row") + ?? (options.fallbackSelector === undefined ? undefined : root.querySelector(options.fallbackSelector)); + if (target === undefined || target === null) return false; + target.focus(); + target.scrollIntoView({ block: "nearest" }); + return true; +} + +function handleRowFocusKey(event: SelectableNavigationKeyboardEvent, action: () => void): true { + event.preventDefault(); + event.stopPropagation?.(); + action(); + return true; +} + +function focusRelativeSelectableRow(target: EventTarget | null | undefined, delta: number): void { + const rows = selectableRowsForTarget(target); + const current = currentSelectableRow(target); + if (current === undefined || rows.length === 0) return; + const index = rows.indexOf(current); + if (index < 0) return; + focusSelectableRowAt(rows, index + delta); +} + +function focusIndexedSelectableRow(target: EventTarget | null | undefined, index: number): void { + const rows = selectableRowsForTarget(target); + if (rows.length === 0) return; + focusSelectableRowAt(rows, index < 0 ? rows.length - 1 : index); +} + +function focusSelectableRowAt(rows: HTMLElement[], index: number): void { + const target = rows[Math.min(Math.max(index, 0), rows.length - 1)]; + target?.focus(); + target?.scrollIntoView({ block: "nearest" }); +} + +function selectableRowsForTarget(target: EventTarget | null | undefined): HTMLElement[] { + const root = currentSelectableRow(target)?.getRootNode(); + if (root === undefined || !isSelectableRowRoot(root)) return []; + return Array.from(root.querySelectorAll(".action-row")); +} + +function isSelectableRowRoot(root: Node): root is Document | DocumentFragment { + return (typeof Document !== "undefined" && root instanceof Document) + || (typeof DocumentFragment !== "undefined" && root instanceof DocumentFragment); +} + +function currentSelectableRow(target: EventTarget | null | undefined): HTMLElement | undefined { + if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return undefined; + return target.closest(".action-row") ?? undefined; +} diff --git a/src/client/src/keyboardShortcuts.test.ts b/src/client/src/keyboardShortcuts.test.ts index c4c5782..348b543 100644 --- a/src/client/src/keyboardShortcuts.test.ts +++ b/src/client/src/keyboardShortcuts.test.ts @@ -66,4 +66,25 @@ describe("KeyboardShortcutDispatcher", () => { expect(dispatcher.handle(keyEvent("r", { ctrlKey: true, shiftKey: true }), [value])).toBe(true); expect(run).toHaveBeenCalledTimes(1); }); + + it("runs a shortcut sequence that starts with a modified key", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const { value, run } = action("mod+g p"); + + expect(dispatcher.handle(keyEvent("g", { ctrlKey: true }), [value])).toBe(true); + expect(run).not.toHaveBeenCalled(); + expect(dispatcher.handle(keyEvent("p"), [value])).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("falls back to a standalone modified shortcut when a pending sequence misses", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const sequence = action("mod+g p"); + const standalone = action("mod+k"); + + expect(dispatcher.handle(keyEvent("g", { ctrlKey: true }), [sequence.value, standalone.value])).toBe(true); + expect(dispatcher.handle(keyEvent("k", { ctrlKey: true }), [sequence.value, standalone.value])).toBe(true); + expect(sequence.run).not.toHaveBeenCalled(); + expect(standalone.run).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/client/src/keyboardShortcuts.ts b/src/client/src/keyboardShortcuts.ts index 37aedd4..1c28045 100644 --- a/src/client/src/keyboardShortcuts.ts +++ b/src/client/src/keyboardShortcuts.ts @@ -14,20 +14,32 @@ export interface ShortcutKeyEvent { export class KeyboardShortcutDispatcher { private pendingTokens: string[] = []; - private pendingTimer: number | undefined; + private pendingTimer: ReturnType | undefined; handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean { const token = eventToken(event); - if (token === undefined || !isModifiedShortcut(token)) return false; + if (token === undefined) return false; const shortcuts = actions .filter((action) => action.shortcut !== undefined && action.enabled !== false) .map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") })) .filter((entry) => entry.tokens.length > 0); - const sequence = this.pendingTokens.length > 0 && !isModifiedShortcut(token) - ? [...this.pendingTokens, token] - : [token]; + if (this.pendingTokens.length > 0) { + const handledPending = this.handleSequence([...this.pendingTokens, token], shortcuts); + if (handledPending) return true; + this.clearPending(); + if (!isModifiedShortcut(token)) return false; + } else if (!isModifiedShortcut(token)) return false; + + return this.handleSequence([token], shortcuts); + } + + reset(): void { + this.clearPending(); + } + + private handleSequence(sequence: string[], shortcuts: { action: AppAction; tokens: string[] }[]): boolean { const exact = shortcuts.find((entry) => sameTokens(entry.tokens, sequence)); if (exact !== undefined) { this.clearPending(); @@ -41,18 +53,13 @@ export class KeyboardShortcutDispatcher { return true; } - this.clearPending(); return false; } - reset(): void { - this.clearPending(); - } - private setPending(tokens: string[]): void { this.clearPending(); this.pendingTokens = tokens; - this.pendingTimer = window.setTimeout(() => { + this.pendingTimer = globalThis.setTimeout(() => { this.pendingTokens = []; this.pendingTimer = undefined; }, sequenceTimeoutMs); @@ -61,7 +68,7 @@ export class KeyboardShortcutDispatcher { private clearPending(): void { this.pendingTokens = []; if (this.pendingTimer !== undefined) { - window.clearTimeout(this.pendingTimer); + globalThis.clearTimeout(this.pendingTimer); this.pendingTimer = undefined; } } diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 77e7d3a..e46b84f 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -18,8 +18,8 @@ export function createCoreActions(): PluginAction[] { id: "prompt.focus", title: "Focus Prompt", description: "Move keyboard focus to the message composer", + shortcut: "mod+g c", group: "General", - enabled: (context) => context.state.selectedSession !== undefined, run: (context) => { context.focusPrompt(); }, }, { @@ -99,7 +99,7 @@ export function createCoreActions(): PluginAction[] { title: "Go to Chat", shortcut: "mod+1", group: "Navigation", - run: (context) => { context.selectMainView("chat"); }, + run: (context) => { context.focusPrompt(); }, }, { id: "view.files", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 1b5f64b..317c236 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -208,6 +208,7 @@ describe("PluginRegistry", () => { expect(shortcuts).toEqual([ ["core:actions.show", "mod+k"], + ["core:prompt.focus", "mod+g c"], ["core:settings.open", "mod+,"], ["core:view.chat", "mod+1"], ["core:view.files", "mod+2"],