From 1afe079ca0f1e0227aab4031d61159395f861811 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 31 Aug 2026 15:04:02 +0100 Subject: [PATCH] feat(maestro): keyboard navigation for the web sidebar Ctrl+Shift+H moves the keyboard between the session list and the pane; j/k walk the cards, Enter opens, Escape hands the pane back. The walk skips expanded folder headers and stops on collapsed ones, matching the TUI. --- docs/SIDEBAR.md | 19 ++++++ web/src/attach.ts | 5 ++ web/src/catalog.ts | 105 +++++++++++++++++++++++++++++- web/src/connections.ts | 7 ++ web/src/help.ts | 13 ++++ web/src/main.ts | 21 +++++- web/src/multiview.test.ts | 4 ++ web/src/sidebarNav.test.ts | 128 +++++++++++++++++++++++++++++++++++++ web/src/styles.css | 12 ++++ 9 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 web/src/sidebarNav.test.ts diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index b2f7bc0..9b378c8 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -146,3 +146,22 @@ dropping the whole session would be worse than dimming one card. A report arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown state is a 4xx: an agent posting a state the daemon does not know is a bug in the agent, and answering 204 would hide it. + +## Keyboard walk + +`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients +follow one rule for what the walk stops on: + +- **Session cards, always.** +- **Folder headers, only while collapsed.** An expanded folder's cards are one + row away, so its header is nothing to land on; a collapsed folder's header is + the only row it has, and skipping it would leave the folder unreachable. + +Enter opens the highlighted session, or expands the highlighted folder and +lands on the first row it was hiding. A highlight parked on a skipped row — +reachable by clicking a folder header — still steps to its neighbour rather +than jumping to the end of the list. + +The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the +same chord back): an attached terminal owns every plain keystroke. The TUI uses +`ctrl-w h`. diff --git a/web/src/attach.ts b/web/src/attach.ts index 0ec5594..99744e6 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -27,6 +27,8 @@ export interface AttachConnection { onMount(): void; /** Parked into the cache: keep streaming, just stop driving focus/resize. */ onUnmount(): void; + /** Put the keyboard back into this terminal — what leaving the sidebar does. */ + focusTerminal(): void; /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ dispose(): void; } @@ -382,6 +384,9 @@ export function createAttachConnection( onUnmount() { mounted = false; }, + focusTerminal() { + term.focus(); + }, dispose() { if (disposed) return; disposed = true; diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 2f1a4f2..6a73478 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -1,7 +1,7 @@ import { escapeHtml } from "./html.js"; import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { openContextMenu } from "./contextMenu.js"; -import { toggleHelp } from "./help.js"; +import { isTypingTarget, toggleHelp } from "./help.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; import { @@ -30,6 +30,18 @@ export function requestOpenSpawn(): void { document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); } +/** Ask the shell to hand the keyboard back to the focused pane's terminal — + * what Escape in the sidebar list does. Handled in main.ts, which owns the + * connection pool. */ +export const FOCUS_PANE_EVENT = "catalog-focus-pane"; + +/** The rows a keyboard walk stops on, in visual (document) order. Expanded + * folder headers are skipped: a directory is not something to focus, and the + * cards under it are one row away anyway. A *collapsed* folder still counts — + * its header is the only row it has, so skipping it would leave the folder + * unreachable. Same rule as the TUI sidebar. */ +const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]'; + const collapsed = new Set(); /** Session whose card is currently being renamed inline (#125). The tree @@ -82,6 +94,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { header.style.paddingLeft = `${depth * 16}px`; const isCollapsed = collapsed.has(node.path); header.setAttribute("aria-expanded", String(!isCollapsed)); + header.dataset.navKey = `folder:${node.path}`; // Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the // glyph and the rotation never fight each other. // A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading @@ -111,6 +124,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { row.href = `#/attach/${encodeURIComponent(s.id)}`; row.style.paddingLeft = `${depth * 16 + 8}px`; row.dataset.sessionId = s.id; + row.dataset.navKey = `session:${s.id}`; const running = s.status === "Running"; row.dataset.status = running ? "running" : "dead"; const name = sessionName(s); @@ -344,6 +358,10 @@ export interface SidebarHandle { setActive(id: string | null): void; /** Apply the full open/focused/colour highlight state. */ setHighlights(h: SidebarHighlights): void; + /** Hand the keyboard to the session list, highlighting a row to walk from. */ + focusList(): void; + /** Whether the session list currently holds DOM focus. */ + hasListFocus(): boolean; /** Tear down the store subscription + listeners. */ dispose(): void; } @@ -466,6 +484,77 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S } }; + // ---- keyboard navigation ---- + // The walk reads the rendered DOM rather than a parallel model, so it can + // never disagree with what is on screen. Rows carry a `data-nav-key` + // (`session:` / `folder:`) that survives a re-render. + let navKey: string | null = null; + + const navRows = (): HTMLElement[] => [ + ...treeHost.querySelectorAll(NAV_SELECTOR), + ]; + + const rowFor = (key: string | null): HTMLElement | undefined => + key === null ? undefined : navRows().find((r) => r.dataset.navKey === key); + + const applyNav = () => { + const rows = navRows(); + // A row that went away (session killed, folder collapsed) drops the + // highlight rather than leaving it pointing at nothing. + if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null; + for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey); + }; + + const moveNav = (delta: number) => { + const rows = navRows(); + if (rows.length === 0) return; + const cur = rows.findIndex((r) => r.dataset.navKey === navKey); + // Nothing highlighted: start one off the end we are walking from, so the + // first step lands on the first (or last) row. + const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur; + const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length]; + navKey = next.dataset.navKey ?? null; + applyNav(); + next.scrollIntoView({ block: "nearest" }); + }; + + /** Enter: open the highlighted session, or expand the highlighted folder. */ + const activateNav = () => { + const row = rowFor(navKey); + if (!row) return; + if (row.classList.contains("tree-leaf")) { + row.click(); + return; + } + // Expanding replaces the folder's header in the walk with the rows it was + // hiding, so land on the first of them instead of losing the highlight. + const key = navKey; + row.click(); + const header = [...treeHost.querySelectorAll(".tree-folder-header")].find( + (h) => h.dataset.navKey === key, + ); + const revealed = header?.parentElement + ? [...header.parentElement.querySelectorAll(NAV_SELECTOR)][0] + : undefined; + navKey = revealed?.dataset.navKey ?? null; + applyNav(); + }; + + // Focusable as a unit: the walk moves a highlight, not DOM focus, so the + // keyboard stays here while j/k runs. + treeHost.tabIndex = -1; + treeHost.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.altKey || e.metaKey) return; + // The inline rename field lives inside a row and owns its own keys. + if (isTypingTarget(e.target)) return; + if (e.key === "j" || e.key === "ArrowDown") moveNav(1); + else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1); + else if (e.key === "Enter") activateNav(); + else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)); + else return; + e.preventDefault(); + }); + const refresh = () => { // Rebuilding the tree now would blow away the field being typed into. if (renamingId !== null) return; @@ -483,6 +572,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S for (const child of tree.children) treeHost.appendChild(renderNode(child, 0)); } applyHighlights(); + applyNav(); }; void getDaemonInfo() @@ -523,6 +613,19 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S highlights = h; applyHighlights(); }, + focusList() { + // Start from the session on screen — that is where the user's attention + // already is — falling back to the standing highlight, then the top row. + const preferred = highlights.focused ? `session:${highlights.focused}` : null; + const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0]; + navKey = start?.dataset.navKey ?? null; + applyNav(); + treeHost.focus(); + start?.scrollIntoView({ block: "nearest" }); + }, + hasListFocus() { + return treeHost.contains(document.activeElement); + }, dispose() { clearInterval(clock); // An unfinished rename would otherwise leave every later sidebar diff --git a/web/src/connections.ts b/web/src/connections.ts index b83123f..fc8b6e0 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -140,6 +140,13 @@ export class ConnectionPool { this.setFocus(null); } + /** Put the keyboard back into the focused session's terminal. No-op when + * nothing is focused (welcome pane) or its connection is gone. */ + focusTerminal(): void { + if (this.focusedId === null) return; + this.cache.get(this.focusedId)?.focusTerminal(); + } + /** Fully disconnect + forget a session. */ close(id: Uuid): void { const conn = this.cache.get(id); diff --git a/web/src/help.ts b/web/src/help.ts index 2437aec..895a8b2 100644 --- a/web/src/help.ts +++ b/web/src/help.ts @@ -21,6 +21,19 @@ export const SHORTCUTS: ShortcutGroup[] = [ { keys: ["N"], description: "Spawn a session with every default resolved" }, ], }, + { + title: "Session list", + items: [ + { + keys: ["Ctrl", "Shift", "H"], + description: "Move the keyboard between the session list and the pane", + }, + { keys: ["j"], description: "Highlight the next session" }, + { keys: ["k"], description: "Highlight the previous session" }, + { keys: ["Enter"], description: "Open the highlighted session, or expand a folder" }, + { keys: ["Esc"], description: "Hand the keyboard back to the pane" }, + ], + }, { title: "Help", items: [ diff --git a/web/src/main.ts b/web/src/main.ts index cb268aa..0a8c106 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -21,7 +21,7 @@ import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; -import { OPEN_SPAWN_EVENT } from "./catalog.js"; +import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; @@ -343,6 +343,25 @@ document.addEventListener("keydown", (e) => { } }); +// Ctrl+Shift+H moves the keyboard between the session list and the pane, the +// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached +// terminal owns every plain keystroke, so nothing unmodified could ever get +// the focus back out of it. +window.addEventListener("keydown", (e) => { + if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return; + if (e.key !== "H" && e.key !== "h") return; + e.preventDefault(); + if (sidebar.hasListFocus()) { + pool.focusTerminal(); + } else { + openDrawer(); // no-op on desktop; on mobile the list is off-canvas + sidebar.focusList(); + } +}); + +// Escape in the session list hands the keyboard back to the pane. +document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal()); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index 253715f..012693e 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -36,6 +36,10 @@ function fakeSidebar(): SidebarHandle { el: document.createElement("aside"), setActive() {}, setHighlights() {}, + focusList() {}, + hasListFocus() { + return false; + }, dispose() {}, }; } diff --git a/web/src/sidebarNav.test.ts b/web/src/sidebarNav.test.ts new file mode 100644 index 0000000..538d143 --- /dev/null +++ b/web/src/sidebarNav.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment happy-dom +import { beforeAll, describe, expect, it } from "vitest"; + +import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; + +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); + window.fetch = () => Promise.reject(new Error("offline")); +}); + +function session(id: string, cwd: string): Session { + return { + id, + cwd, + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + } as Session; +} + +function storeWith(sessions: Session[]): CatalogStore { + const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; + return { + snapshot: () => state, + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +const A = "aaaaaaaa-0000-0000-0000-00000000000a"; +const B = "aaaaaaaa-0000-0000-0000-00000000000b"; + +/** Two sessions in sibling directories: two folder headers, two cards. */ +function twoFolders(): { el: HTMLElement; handle: ReturnType } { + const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")])); + // Mounted: `focusList` moves real DOM focus, which needs a live document. + document.body.replaceChildren(handle.el); + return { el: handle.el, handle }; +} + +function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void { + el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); +} + +function highlighted(el: HTMLElement): string | null { + return el.querySelector(".is-highlighted")?.dataset.navKey ?? null; +} + +function tree(el: HTMLElement): HTMLElement { + return el.querySelector(".tree")!; +} + +describe("sidebar keyboard navigation", () => { + it("walks the cards and never stops on an expanded folder", () => { + const { el } = twoFolders(); + // The folders are there — the walk just refuses to land on them. + expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2); + + press(tree(el), "j"); + expect(highlighted(el)).toBe(`session:${A}`); + press(tree(el), "j"); + expect(highlighted(el)).toBe(`session:${B}`); + // Wraps. + press(tree(el), "j"); + expect(highlighted(el)).toBe(`session:${A}`); + press(tree(el), "k"); + expect(highlighted(el)).toBe(`session:${B}`); + }); + + it("accepts the arrow keys as well as j/k", () => { + const { el } = twoFolders(); + press(tree(el), "ArrowDown"); + expect(highlighted(el)).toBe(`session:${A}`); + press(tree(el), "ArrowUp"); + // Backwards from the first card wraps to the last. + expect(highlighted(el)).toBe(`session:${B}`); + }); + + it("stops on a collapsed folder, since its cards are gone", () => { + const { el } = twoFolders(); + const header = el.querySelectorAll(".tree-folder-header")[0]; + header.click(); // collapse /one + + press(tree(el), "j"); + expect(highlighted(el)).toBe("folder:one"); + press(tree(el), "j"); + expect(highlighted(el)).toBe(`session:${B}`); + + // Enter expands it again and lands on the card it was hiding. + press(tree(el), "j"); + expect(highlighted(el)).toBe("folder:one"); + press(tree(el), "Enter"); + expect(highlighted(el)).toBe(`session:${A}`); + }); + + it("focusList starts from the session on screen", () => { + const { el, handle } = twoFolders(); + handle.setHighlights({ open: new Set([B]), focused: B, colors: new Map() }); + handle.focusList(); + expect(highlighted(el)).toBe(`session:${B}`); + expect(handle.hasListFocus()).toBe(true); + }); + + it("Escape asks the shell for the pane back", () => { + const { el } = twoFolders(); + let asked = 0; + document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1)); + press(tree(el), "Escape"); + expect(asked).toBe(1); + }); + + it("leaves the keyboard alone while a row is being renamed", () => { + const { el } = twoFolders(); + press(tree(el), "j"); + const input = document.createElement("input"); + tree(el).appendChild(input); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true })); + expect(highlighted(el)).toBe(`session:${A}`); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css index ddb2e04..ead281d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -764,6 +764,18 @@ textarea { box-shadow: inset 0 0 0 1px var(--accent-ring); } +/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this + is a cursor, not a statement about what is mounted, so it reads as a ring + rather than a fill and the two can sit on the same row. */ +.tree-leaf.is-highlighted, +.tree-folder-header.is-highlighted { + box-shadow: inset 0 0 0 2px var(--accent-ring); +} + +.tree { + outline: none; +} + /* The session currently mounted in the content pane. */ .tree-leaf.is-active, .tree-leaf.is-active[data-status="running"] {