feat: add keyboard navigation ladder

This commit is contained in:
Federico Jaramillo Martinez
2026-06-08 13:33:39 +02:00
parent f501f9d757
commit f3e19d1463
16 changed files with 492 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add keyboard-first navigation for focusing Machines, Projects, Workspaces, Sessions, and the chat composer.
@@ -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",
+15 -3
View File
@@ -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<string, MachineHealth> = {};
@@ -18,6 +19,8 @@ export class MachineList extends LitElement {
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@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<boolean> {
await this.updateComplete;
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
}
override render() {
return html`
<section>
@@ -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 = [
+121 -1
View File
@@ -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<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@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<boolean> {
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)}
<span class="machine-switcher-text">
@@ -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); }}
>
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
@@ -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<HTMLElement>(".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<boolean> {
this.menuStyle = machineSwitcherMenuStyle(target);
this.open = true;
this.openActionsMachineId = undefined;
await this.updateComplete;
return this.focusSelectedMachineOption();
}
private focusSelectedMachineOption(): boolean {
const selected = this.renderRoot.querySelector<HTMLElement>(".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<HTMLElement>(".machine-option-main"));
}
private toggleActionsMenu(machineId: string, target: EventTarget | null): void {
if (this.openActionsMachineId === machineId) {
this.openActionsMachineId = undefined;
+88 -35
View File
@@ -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<void>) => this.withChatScrollTransition(async () => {
await action();
if (autoSwitchToChat) this.setState({ mainView: "chat" });
if (autoSwitchToChat) this.updateUrl();
});
private renderNavigationPanel() {
return html`
<app-navigation-panel
.machines=${this.state.machines}
@@ -779,10 +775,7 @@ export class PiWebApp extends LitElement {
.machineActivities=${this.state.machineActivities}
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
.onToggleMachines=${() => { 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(); }}
></app-navigation-panel>
`;
}
@@ -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<void>): Promise<void> {
await this.withChatScrollTransition(async () => {
this.navigationSections.advanceAfterSelection(section);
await action();
});
await this.focusNavigationTarget(nextTarget);
}
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
if (target === "chat") {
await this.focusChatComposer();
return;
}
await this.focusNavigationSection(target);
}
private async focusNavigationSection(section: NavigationSection): Promise<void> {
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<void> {
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<void> {
@@ -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`
<div class=${this.panelCollapse.shellClass(state.mainView)}>
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel()}</aside>
${this.renderNavigationPanelEdgeControl()}
<main class=${mainViewClass(state.mainView)}>
${this.renderContextBar()}
${this.renderMobileMainTabs()}
${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
+21 -3
View File
@@ -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<string, WorkspaceActivity> = {};
@@ -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<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@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<boolean> {
await this.updateComplete;
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
}
override render() {
return html`
<section>
@@ -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); }}
>
<div class="action-main">
<span class="action-name">${project.name}</span><small>${project.path}</small>
@@ -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";
+2 -1
View File
@@ -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<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
+17 -3
View File
@@ -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<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@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<boolean> {
await this.updateComplete;
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
}
override render() {
return html`
<section>
@@ -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 {
@@ -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<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onFocusNavigationTarget?: (target: NavigationFocusTarget) => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@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<boolean> {
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(); }}
></machine-switcher>
` : null}
<div class="header-actions">
@@ -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(); }}
></machine-list>
` : null}
<project-list
@@ -96,6 +123,9 @@ export class AppNavigationPanel extends LitElement {
.onToggleCollapsed=${() => { 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(); }}
></project-list>
<workspace-list
.workspaces=${this.workspaces}
@@ -108,6 +138,9 @@ export class AppNavigationPanel extends LitElement {
.onToggleCollapsed=${() => { 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(); }}
></workspace-list>
<session-list
.sessions=${this.sessions}
@@ -126,10 +159,31 @@ export class AppNavigationPanel extends LitElement {
.onRestore=${(session: SessionInfo) => 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(); }}
></session-list>
`;
}
private async focusNavigableSection(section: KeyboardNavigableSection | undefined): Promise<boolean> {
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));
}
@@ -0,0 +1,3 @@
export interface KeyboardNavigableSection {
focusSelectedOrFirst(): boolean | Promise<boolean>;
}
@@ -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<Event, "composedPath">;
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault">;
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault" | "stopPropagation">;
type MatchTarget = EventTarget & Pick<Element, "matches">;
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] };
}
@@ -12,6 +12,14 @@ const interactiveSelector = [
type ComposedPathEvent = Pick<Event, "composedPath">;
type SelectableKeyboardEvent = ComposedPathEvent & Pick<KeyboardEvent, "key" | "preventDefault">;
type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial<Pick<KeyboardEvent, "currentTarget" | "stopPropagation">>;
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<HTMLElement>(".action-row.selected")
?? root.querySelector<HTMLElement>(".action-row")
?? (options.fallbackSelector === undefined ? undefined : root.querySelector<HTMLElement>(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<HTMLElement>(".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<HTMLElement>(".action-row") ?? undefined;
}
+21
View File
@@ -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);
});
});
+19 -12
View File
@@ -14,20 +14,32 @@ export interface ShortcutKeyEvent {
export class KeyboardShortcutDispatcher {
private pendingTokens: string[] = [];
private pendingTimer: number | undefined;
private pendingTimer: ReturnType<typeof setTimeout> | 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;
}
}
+2 -2
View File
@@ -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",
+1
View File
@@ -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"],