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(); this.host.requestUpdate();
} }
expandNavigationPanel(): void {
if (!this.navigationPanelCollapsed) return;
this.navigationPanelCollapsed = false;
this.host.requestUpdate();
}
shellClass(mainView: AppState["mainView"]): string { shellClass(mainView: AppState["mainView"]): string {
return [ return [
"shell", "shell",
+15 -3
View File
@@ -4,11 +4,12 @@ import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
import { machineActivityIndicator } from "../workspaceActivity"; import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; 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 { listStyles } from "./shared";
@customElement("machine-list") @customElement("machine-list")
export class MachineList extends LitElement { export class MachineList extends LitElement implements KeyboardNavigableSection {
@property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selected?: Machine; @property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {}; @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 }) onSelect?: (machine: Machine) => void;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onToggleCollapsed?: () => 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 openMenuMachineId: string | undefined;
@state() private menuStyle = ""; @state() private menuStyle = "";
@@ -41,6 +44,11 @@ export class MachineList extends LitElement {
if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined; 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() { override render() {
return html` return html`
<section> <section>
@@ -132,7 +140,11 @@ export class MachineList extends LitElement {
this.openMenuMachineId = undefined; this.openMenuMachineId = undefined;
return; 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 = [ static override styles = [
+121 -1
View File
@@ -5,15 +5,18 @@ import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActivityIndicator } from "./activityBadge"; import { renderActivityIndicator } from "./activityBadge";
import { canRemoveMachine } from "./MachineList"; import { canRemoveMachine } from "./MachineList";
import type { KeyboardNavigableSection } from "./navigationFocus";
@customElement("machine-switcher") @customElement("machine-switcher")
export class MachineSwitcher extends LitElement { export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
@property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selected?: Machine; @property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {}; @property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {}; @property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (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 open = false;
@state() private menuStyle = ""; @state() private menuStyle = "";
@state() private openActionsMachineId: string | undefined; @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; 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() { override render() {
const selected = this.selectedMachine(); const selected = this.selectedMachine();
if (selected === undefined) return null; if (selected === undefined) return null;
@@ -54,6 +63,7 @@ export class MachineSwitcher extends LitElement {
aria-label=${`Machine: ${label}. Switch machine.`} aria-label=${`Machine: ${label}. Switch machine.`}
aria-expanded=${String(this.open)} aria-expanded=${String(this.open)}
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }} @click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
> >
${this.renderActivity(selected)} ${this.renderActivity(selected)}
<span class="machine-switcher-text"> <span class="machine-switcher-text">
@@ -83,7 +93,9 @@ export class MachineSwitcher extends LitElement {
type="button" type="button"
class="machine-option-main" class="machine-option-main"
title=${machineTitle(machine)} title=${machineTitle(machine)}
data-machine-id=${machine.id}
@click=${() => { this.select(machine); }} @click=${() => { this.select(machine); }}
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
> >
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span> <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> <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]; 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 { private toggleMenu(target: EventTarget | null): void {
this.menuStyle = machineSwitcherMenuStyle(target); this.menuStyle = machineSwitcherMenuStyle(target);
this.open = !this.open; this.open = !this.open;
this.openActionsMachineId = undefined; 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 { private toggleActionsMenu(machineId: string, target: EventTarget | null): void {
if (this.openActionsMachineId === machineId) { if (this.openActionsMachineId === machineId) {
this.openActionsMachineId = undefined; this.openActionsMachineId = undefined;
+88 -35
View File
@@ -55,7 +55,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import "./appShell/AppContextBar"; import "./appShell/AppContextBar";
import "./appShell/AppMobileMainTabs"; import "./appShell/AppMobileMainTabs";
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./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/AppPanelEdgeControl";
import "./appShell/AppRefreshControl"; import "./appShell/AppRefreshControl";
import { appStyles } from "./shared"; import { appStyles } from "./shared";
@@ -75,6 +75,7 @@ export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView; @query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor; @query("prompt-editor") private promptEditor?: PromptEditor;
@query("app-navigation-panel") private navigationPanel?: AppNavigationPanel;
private readonly sessions = new SessionController( private readonly sessions = new SessionController(
() => this.state, () => this.state,
@@ -765,12 +766,7 @@ export class PiWebApp extends LitElement {
`; `;
} }
private renderNavigationPanel(autoSwitchToChat: boolean) { private renderNavigationPanel() {
const openChatAfter = (action: () => Promise<void>) => this.withChatScrollTransition(async () => {
await action();
if (autoSwitchToChat) this.setState({ mainView: "chat" });
if (autoSwitchToChat) this.updateUrl();
});
return html` return html`
<app-navigation-panel <app-navigation-panel
.machines=${this.state.machines} .machines=${this.state.machines}
@@ -779,10 +775,7 @@ export class PiWebApp extends LitElement {
.machineActivities=${this.state.machineActivities} .machineActivities=${this.state.machineActivities}
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")} .machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }} .onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => { .onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
this.navigationSections.advanceAfterSelection("machines");
await this.selectMachineWithMemory(machine);
})}
.onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }} .onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }}
.projects=${this.state.projects} .projects=${this.state.projects}
.selectedProject=${this.state.selectedProject} .selectedProject=${this.state.selectedProject}
@@ -807,33 +800,20 @@ export class PiWebApp extends LitElement {
.onToggleProjects=${() => { this.navigationSections.toggle("projects"); }} .onToggleProjects=${() => { this.navigationSections.toggle("projects"); }}
.onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }} .onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }}
.onToggleSessions=${() => { this.navigationSections.toggle("sessions"); }} .onToggleSessions=${() => { this.navigationSections.toggle("sessions"); }}
.onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => { .onSelectProject=${(project: Project) => this.selectNavigationItem("projects", "workspaces", () => this.workspaces.selectProject(project))}
this.navigationSections.advanceAfterSelection("projects");
await this.workspaces.selectProject(project);
})}
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)} .onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => { .onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
this.navigationSections.advanceAfterSelection("workspaces");
await this.workspaces.selectWorkspace(workspace);
})}
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} .onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
.onStartSession=${() => openChatAfter(() => { .onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())}
this.navigationSections.advanceAfterSelection("sessions"); .onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
return this.sessions.startSession();
})}
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => {
this.navigationSections.advanceAfterSelection("sessions");
return this.sessions.selectSession(session);
})}
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} .onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => { .onRestoreSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.restoreSession(session))}
this.navigationSections.advanceAfterSelection("sessions");
return this.sessions.restoreSession(session);
})}
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} .onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
></app-navigation-panel> ></app-navigation-panel>
`; `;
} }
@@ -842,6 +822,42 @@ export class PiWebApp extends LitElement {
this.navigationSections.open(section, () => { this.selectMainView("navigation"); }); 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[] { private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace; const workspace = this.state.selectedWorkspace;
if (workspace === undefined) return []; if (workspace === undefined) return [];
@@ -978,7 +994,44 @@ export class PiWebApp extends LitElement {
} }
private getActions(): AppAction[] { 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> { private ensureGatewayPluginsLoaded(): Promise<void> {
@@ -1036,7 +1089,7 @@ export class PiWebApp extends LitElement {
openSettings: (section) => { this.openSettings(section); }, openSettings: (section) => { this.openSettings(section); },
}, },
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); }, focusPrompt: () => { void this.focusChatComposer(); },
addProject: () => { this.setState({ projectDialogOpen: true }); }, addProject: () => { this.setState({ projectDialogOpen: true }); },
addMachine: () => { this.openMachineDialog(); }, addMachine: () => { this.openMachineDialog(); },
refreshSelectedMachine: () => this.machines.refreshMachineHealth(), refreshSelectedMachine: () => this.machines.refreshMachineHealth(),
@@ -1381,13 +1434,13 @@ export class PiWebApp extends LitElement {
const state = this.state; const state = this.state;
return html` return html`
<div class=${this.panelCollapse.shellClass(state.mainView)}> <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()} ${this.renderNavigationPanelEdgeControl()}
<main class=${mainViewClass(state.mainView)}> <main class=${mainViewClass(state.mainView)}>
${this.renderContextBar()} ${this.renderContextBar()}
${this.renderMobileMainTabs()} ${this.renderMobileMainTabs()}
${state.error ? html`<div class="error">${state.error}</div>` : null} ${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` ${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> <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> <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 { projectActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; 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 { listStyles } from "./shared";
@customElement("project-list") @customElement("project-list")
export class ProjectList extends LitElement { export class ProjectList extends LitElement implements KeyboardNavigableSection {
@property({ attribute: false }) projects: Project[] = []; @property({ attribute: false }) projects: Project[] = [];
@property({ attribute: false }) selected?: Project; @property({ attribute: false }) selected?: Project;
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {}; @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 }) onSelect?: (project: Project) => void;
@property({ attribute: false }) onClose?: (project: Project) => void; @property({ attribute: false }) onClose?: (project: Project) => void;
@property({ attribute: false }) onToggleCollapsed?: () => 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 openMenuProjectId: string | undefined;
@state() private menuStyle = ""; @state() private menuStyle = "";
private readonly onDocumentClick = (event: MouseEvent) => { private readonly onDocumentClick = (event: MouseEvent) => {
@@ -40,6 +44,11 @@ export class ProjectList extends LitElement {
if (changed.has("collapsed") && this.collapsed) this.openMenuProjectId = undefined; 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() { override render() {
return html` return html`
<section> <section>
@@ -52,7 +61,7 @@ export class ProjectList extends LitElement {
tabindex="0" tabindex="0"
title=${project.path} title=${project.path}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }} @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"> <div class="action-main">
<span class="action-name">${project.name}</span><small>${project.path}</small> <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() { private renderHeading() {
if (!this.collapsible) return "Projects"; if (!this.collapsible) return "Projects";
const selectedSummary = this.selected?.name ?? "No project selected"; 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 { isSessionActive } from "../../../shared/activity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@@ -20,7 +21,7 @@ interface SessionRow {
} }
@customElement("session-list") @customElement("session-list")
export class SessionList extends LitElement { export class SessionList extends LitElement implements KeyboardNavigableSection {
@property({ attribute: false }) sessions: SessionInfo[] = []; @property({ attribute: false }) sessions: SessionInfo[] = [];
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {}; @property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {}; @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 { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; 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 { listStyles } from "./shared";
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel"; import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
@customElement("workspace-list") @customElement("workspace-list")
export class WorkspaceList extends LitElement { export class WorkspaceList extends LitElement implements KeyboardNavigableSection {
@property({ attribute: false }) workspaces: Workspace[] = []; @property({ attribute: false }) workspaces: Workspace[] = [];
@property({ attribute: false }) selected?: Workspace; @property({ attribute: false }) selected?: Workspace;
@property({ type: Boolean, reflect: true }) collapsible = false; @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 }) onSelect?: (workspace: Workspace) => void;
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void; @property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => 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 openMenuWorkspaceId: string | undefined;
@state() private menuStyle = ""; @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(); 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() { override render() {
return html` return html`
<section> <section>
@@ -182,7 +191,12 @@ export class WorkspaceList extends LitElement {
this.openMenuWorkspaceId = undefined; this.openMenuWorkspaceId = undefined;
return; 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 { private scrollSelectedIntoView(): void {
@@ -1,13 +1,18 @@
import { LitElement, css, html } from "lit"; 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 { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
import type { WorkspaceLabelItem } from "../../plugins/types"; 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 "../MachineList";
import "../MachineSwitcher"; import "../MachineSwitcher";
import "../ProjectList"; import "../ProjectList";
import "../WorkspaceList"; import "../WorkspaceList";
import "../SessionList"; import "../SessionList";
export type NavigationFocusTarget = NavigationSection | "chat";
@customElement("app-navigation-panel") @customElement("app-navigation-panel")
export class AppNavigationPanel extends LitElement { export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) machines: Machine[] = [];
@@ -53,6 +58,24 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemoveMachine?: (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() { override render() {
return html` return html`
@@ -66,6 +89,8 @@ export class AppNavigationPanel extends LitElement {
.activities=${this.machineActivities} .activities=${this.machineActivities}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
></machine-switcher> ></machine-switcher>
` : null} ` : null}
<div class="header-actions"> <div class="header-actions">
@@ -84,6 +109,8 @@ export class AppNavigationPanel extends LitElement {
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }} .onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
></machine-list> ></machine-list>
` : null} ` : null}
<project-list <project-list
@@ -96,6 +123,9 @@ export class AppNavigationPanel extends LitElement {
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }} .onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
.onSelect=${(project: Project) => this.onSelectProject?.(project)} .onSelect=${(project: Project) => this.onSelectProject?.(project)}
.onClose=${(project: Project) => this.onCloseProject?.(project)} .onClose=${(project: Project) => this.onCloseProject?.(project)}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("projects"); }}
.onFocusNextSection=${() => { this.focusNextFrom("projects"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
></project-list> ></project-list>
<workspace-list <workspace-list
.workspaces=${this.workspaces} .workspaces=${this.workspaces}
@@ -108,6 +138,9 @@ export class AppNavigationPanel extends LitElement {
.onToggleCollapsed=${() => { this.onToggleWorkspaces?.(); }} .onToggleCollapsed=${() => { this.onToggleWorkspaces?.(); }}
.onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)} .onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)}
.onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)} .onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("workspaces"); }}
.onFocusNextSection=${() => { this.focusNextFrom("workspaces"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
></workspace-list> ></workspace-list>
<session-list <session-list
.sessions=${this.sessions} .sessions=${this.sessions}
@@ -126,10 +159,31 @@ export class AppNavigationPanel extends LitElement {
.onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)} .onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)}
.onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)} .onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)}
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
></session-list> ></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` static override styles = css`
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
:host([compact]) { flex: 1 1 auto; } :host([compact]) { flex: 1 1 auto; }
@@ -159,3 +213,17 @@ export class AppNavigationPanel extends LitElement {
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean { export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
return machines.length > 1; 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 { describe, expect, it, vi } from "vitest";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow";
describe("selectable row activation", () => { describe("selectable row activation", () => {
it("activates rows from non-interactive click targets", () => { it("activates rows from non-interactive click targets", () => {
@@ -38,10 +38,30 @@ describe("selectable row activation", () => {
expect(action).not.toHaveBeenCalled(); expect(action).not.toHaveBeenCalled();
expect(event.preventDefault).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 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">; type MatchTarget = EventTarget & Pick<Element, "matches">;
function matchTarget(matches: Element["matches"]): MatchTarget { function matchTarget(matches: Element["matches"]): MatchTarget {
@@ -53,5 +73,5 @@ function eventWithPath(target: MatchTarget): EventWithPath {
} }
function keyboardEventWithPath(key: string, target: MatchTarget): KeyboardEventWithPath { 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 ComposedPathEvent = Pick<Event, "composedPath">;
type SelectableKeyboardEvent = ComposedPathEvent & Pick<KeyboardEvent, "key" | "preventDefault">; 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 { export function isFromInteractiveElement(event: ComposedPathEvent): boolean {
return event.composedPath().some((target) => targetMatches(target, interactiveSelector)); return event.composedPath().some((target) => targetMatches(target, interactiveSelector));
@@ -35,3 +43,74 @@ export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent
event.preventDefault(); event.preventDefault();
action(); 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(dispatcher.handle(keyEvent("r", { ctrlKey: true, shiftKey: true }), [value])).toBe(true);
expect(run).toHaveBeenCalledTimes(1); 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 { export class KeyboardShortcutDispatcher {
private pendingTokens: string[] = []; private pendingTokens: string[] = [];
private pendingTimer: number | undefined; private pendingTimer: ReturnType<typeof setTimeout> | undefined;
handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean { handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean {
const token = eventToken(event); const token = eventToken(event);
if (token === undefined || !isModifiedShortcut(token)) return false; if (token === undefined) return false;
const shortcuts = actions const shortcuts = actions
.filter((action) => action.shortcut !== undefined && action.enabled !== false) .filter((action) => action.shortcut !== undefined && action.enabled !== false)
.map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") })) .map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") }))
.filter((entry) => entry.tokens.length > 0); .filter((entry) => entry.tokens.length > 0);
const sequence = this.pendingTokens.length > 0 && !isModifiedShortcut(token) if (this.pendingTokens.length > 0) {
? [...this.pendingTokens, token] const handledPending = this.handleSequence([...this.pendingTokens, token], shortcuts);
: [token]; 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)); const exact = shortcuts.find((entry) => sameTokens(entry.tokens, sequence));
if (exact !== undefined) { if (exact !== undefined) {
this.clearPending(); this.clearPending();
@@ -41,18 +53,13 @@ export class KeyboardShortcutDispatcher {
return true; return true;
} }
this.clearPending();
return false; return false;
} }
reset(): void {
this.clearPending();
}
private setPending(tokens: string[]): void { private setPending(tokens: string[]): void {
this.clearPending(); this.clearPending();
this.pendingTokens = tokens; this.pendingTokens = tokens;
this.pendingTimer = window.setTimeout(() => { this.pendingTimer = globalThis.setTimeout(() => {
this.pendingTokens = []; this.pendingTokens = [];
this.pendingTimer = undefined; this.pendingTimer = undefined;
}, sequenceTimeoutMs); }, sequenceTimeoutMs);
@@ -61,7 +68,7 @@ export class KeyboardShortcutDispatcher {
private clearPending(): void { private clearPending(): void {
this.pendingTokens = []; this.pendingTokens = [];
if (this.pendingTimer !== undefined) { if (this.pendingTimer !== undefined) {
window.clearTimeout(this.pendingTimer); globalThis.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined; this.pendingTimer = undefined;
} }
} }
+2 -2
View File
@@ -18,8 +18,8 @@ export function createCoreActions(): PluginAction[] {
id: "prompt.focus", id: "prompt.focus",
title: "Focus Prompt", title: "Focus Prompt",
description: "Move keyboard focus to the message composer", description: "Move keyboard focus to the message composer",
shortcut: "mod+g c",
group: "General", group: "General",
enabled: (context) => context.state.selectedSession !== undefined,
run: (context) => { context.focusPrompt(); }, run: (context) => { context.focusPrompt(); },
}, },
{ {
@@ -99,7 +99,7 @@ export function createCoreActions(): PluginAction[] {
title: "Go to Chat", title: "Go to Chat",
shortcut: "mod+1", shortcut: "mod+1",
group: "Navigation", group: "Navigation",
run: (context) => { context.selectMainView("chat"); }, run: (context) => { context.focusPrompt(); },
}, },
{ {
id: "view.files", id: "view.files",
+1
View File
@@ -208,6 +208,7 @@ describe("PluginRegistry", () => {
expect(shortcuts).toEqual([ expect(shortcuts).toEqual([
["core:actions.show", "mod+k"], ["core:actions.show", "mod+k"],
["core:prompt.focus", "mod+g c"],
["core:settings.open", "mod+,"], ["core:settings.open", "mod+,"],
["core:view.chat", "mod+1"], ["core:view.chat", "mod+1"],
["core:view.files", "mod+2"], ["core:view.files", "mod+2"],