Archived
feat: add keyboard navigation ladder
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user