From f0851a62ed3bb1bc98d4a351eceead7898a17cf9 Mon Sep 17 00:00:00 2001
From: Miguel Palhas
Date: Mon, 31 Aug 2026 14:37:53 +0100
Subject: [PATCH 1/3] feat(maestro): keyboard shortcut overlay on web
`?` toggles a dialog listing every web binding, mirroring the TUI's help
overlay. A `?` button in the header covers pointer-only clients, and the
welcome screen points at the key.
One registry (SHORTCUTS) drives the rendering, so a binding added without
an entry reads as missing. main.ts and the overlay now share one
isTypingTarget guard instead of two copies.
---
web/src/catalog.ts | 9 +++
web/src/help.test.ts | 112 ++++++++++++++++++++++++++++++
web/src/help.ts | 159 +++++++++++++++++++++++++++++++++++++++++++
web/src/main.ts | 16 ++---
web/src/styles.css | 110 +++++++++++++++++++++++++++++-
5 files changed, 394 insertions(+), 12 deletions(-)
create mode 100644 web/src/help.test.ts
create mode 100644 web/src/help.ts
diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index febb706..2f1a4f2 100644
--- a/web/src/catalog.ts
+++ b/web/src/catalog.ts
@@ -1,6 +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 { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import {
@@ -305,6 +306,14 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
+ const helpBtn = document.createElement("button");
+ helpBtn.type = "button";
+ helpBtn.className = "btn btn-ghost btn-icon help-btn";
+ helpBtn.textContent = "?";
+ helpBtn.title = "Keyboard shortcuts (?)";
+ helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
+ helpBtn.addEventListener("click", () => toggleHelp());
+ actions.appendChild(helpBtn);
actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
diff --git a/web/src/help.test.ts b/web/src/help.test.ts
new file mode 100644
index 0000000..660e06d
--- /dev/null
+++ b/web/src/help.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ SHORTCUTS,
+ closeHelp,
+ installHelpKeybind,
+ isHelpOpen,
+ isTypingTarget,
+ openHelp,
+} from "./help.js";
+
+beforeEach(() => {
+ installHelpKeybind();
+});
+
+afterEach(() => {
+ closeHelp();
+ document.body.replaceChildren();
+});
+
+function press(key: string, init: KeyboardEventInit = {}): void {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
+}
+
+function rows(): string[] {
+ return [...document.querySelectorAll(".help-row")].map((r) =>
+ [...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
+ );
+}
+
+describe("help overlay", () => {
+ it("renders every registered shortcut, grouped", () => {
+ openHelp();
+ const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
+ expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
+ expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
+ expect(document.querySelectorAll(".help-desc")).toHaveLength(
+ SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
+ );
+ });
+
+ it("opens on ? and closes on a second ?", () => {
+ press("?", { shiftKey: true });
+ expect(isHelpOpen()).toBe(true);
+ press("?", { shiftKey: true });
+ expect(isHelpOpen()).toBe(false);
+ });
+
+ it("closes on Escape, on the close button, and on a click outside the panel", () => {
+ openHelp();
+ press("Escape");
+ expect(isHelpOpen()).toBe(false);
+
+ openHelp();
+ (document.querySelector(".help-close") as HTMLButtonElement).click();
+ expect(isHelpOpen()).toBe(false);
+
+ openHelp();
+ (document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ expect(isHelpOpen()).toBe(false);
+ });
+
+ it("stays open for a click inside the panel", () => {
+ openHelp();
+ (document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ expect(isHelpOpen()).toBe(true);
+ });
+
+ it("opening twice leaves one overlay", () => {
+ openHelp();
+ openHelp();
+ expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
+ });
+
+ it("ignores ? while typing and when a non-shift modifier is held", () => {
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
+ expect(isHelpOpen()).toBe(false);
+
+ press("?", { ctrlKey: true });
+ expect(isHelpOpen()).toBe(false);
+ });
+
+ it("installing the keybind twice still toggles once", () => {
+ installHelpKeybind();
+ press("?", { shiftKey: true });
+ expect(isHelpOpen()).toBe(true);
+ expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
+ });
+});
+
+describe("isTypingTarget", () => {
+ it("is true for form fields and editable nodes, false otherwise", () => {
+ const el = (html: string) => {
+ const d = document.createElement("div");
+ d.innerHTML = html;
+ return d.firstElementChild as HTMLElement;
+ };
+ expect(isTypingTarget(el(""))).toBe(true);
+ expect(isTypingTarget(el(""))).toBe(true);
+ expect(isTypingTarget(el(""))).toBe(true);
+ expect(isTypingTarget(el(''))).toBe(true);
+ expect(isTypingTarget(el(""))).toBe(false);
+ expect(isTypingTarget(null)).toBe(false);
+ });
+});
diff --git a/web/src/help.ts b/web/src/help.ts
new file mode 100644
index 0000000..2437aec
--- /dev/null
+++ b/web/src/help.ts
@@ -0,0 +1,159 @@
+/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
+ * overlay. One registry drives both the rendering and what we claim is bound,
+ * so a binding added to `main.ts` without an entry here reads as missing. */
+
+export interface Shortcut {
+ /** Keycaps, rendered left to right as separate . */
+ keys: string[];
+ description: string;
+}
+
+export interface ShortcutGroup {
+ title: string;
+ items: Shortcut[];
+}
+
+export const SHORTCUTS: ShortcutGroup[] = [
+ {
+ title: "Sessions",
+ items: [
+ { keys: ["n"], description: "Open a spawn draft pane" },
+ { keys: ["N"], description: "Spawn a session with every default resolved" },
+ ],
+ },
+ {
+ title: "Help",
+ items: [
+ { keys: ["?"], description: "Show or hide this help" },
+ { keys: ["Esc"], description: "Close the help overlay or an open menu" },
+ ],
+ },
+ {
+ title: "Debug",
+ items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
+ },
+];
+
+/** True when the event target swallows plain keystrokes — a form field, or the
+ * hidden textarea xterm types into. Shared with `main.ts` so every global
+ * single-key binding is deaf in the same places. */
+export function isTypingTarget(target: EventTarget | null): boolean {
+ const el = target as HTMLElement | null;
+ if (!el || typeof el.tagName !== "string") return false;
+ return (
+ el.tagName === "INPUT" ||
+ el.tagName === "TEXTAREA" ||
+ el.tagName === "SELECT" ||
+ el.isContentEditable
+ );
+}
+
+let open: { el: HTMLElement; dispose: () => void } | null = null;
+
+export function isHelpOpen(): boolean {
+ return open !== null;
+}
+
+export function closeHelp(): void {
+ open?.dispose();
+}
+
+export function toggleHelp(): void {
+ if (open) closeHelp();
+ else openHelp();
+}
+
+/** Mount the overlay. Opening while already open is a no-op re-render, so a
+ * stray second call can never stack two dialogs. */
+export function openHelp(): HTMLElement {
+ closeHelp();
+
+ const el = document.createElement("div");
+ el.className = "help-overlay";
+ el.setAttribute("role", "dialog");
+ el.setAttribute("aria-modal", "true");
+ el.setAttribute("aria-label", "Keyboard shortcuts");
+
+ const panel = document.createElement("div");
+ panel.className = "help-panel";
+
+ const head = document.createElement("header");
+ head.className = "help-head";
+ const title = document.createElement("h2");
+ title.className = "help-title";
+ title.textContent = "Keyboard shortcuts";
+ const close = document.createElement("button");
+ close.type = "button";
+ close.className = "help-close";
+ close.setAttribute("aria-label", "Close");
+ close.textContent = "✕";
+ close.addEventListener("click", closeHelp);
+ head.append(title, close);
+ panel.appendChild(head);
+
+ for (const group of SHORTCUTS) {
+ const section = document.createElement("section");
+ section.className = "help-group";
+ const heading = document.createElement("h3");
+ heading.className = "help-group-title";
+ heading.textContent = group.title;
+ const list = document.createElement("dl");
+ list.className = "help-list";
+ for (const item of group.items) {
+ const row = document.createElement("div");
+ row.className = "help-row";
+ const keys = document.createElement("dt");
+ keys.className = "help-keys";
+ for (const key of item.keys) {
+ const cap = document.createElement("kbd");
+ cap.textContent = key;
+ keys.appendChild(cap);
+ }
+ const desc = document.createElement("dd");
+ desc.className = "help-desc";
+ desc.textContent = item.description;
+ row.append(keys, desc);
+ list.appendChild(row);
+ }
+ section.append(heading, list);
+ panel.appendChild(section);
+ }
+
+ // A press on the scrim closes; one inside the panel is left alone.
+ el.addEventListener("click", (ev) => {
+ if (!panel.contains(ev.target as Node)) closeHelp();
+ });
+ el.appendChild(panel);
+ document.body.appendChild(el);
+
+ const dispose = () => {
+ if (open?.el !== el) return;
+ open = null;
+ el.remove();
+ };
+ open = { el, dispose };
+ close.focus();
+ return el;
+}
+
+let installed = false;
+
+/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
+ * second call during a hot reload doesn't double-toggle. */
+export function installHelpKeybind(): void {
+ if (installed) return;
+ installed = true;
+ window.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && open) {
+ e.preventDefault();
+ closeHelp();
+ return;
+ }
+ // Shift is how `?` is typed on most layouts; the others would be a
+ // different binding entirely.
+ if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
+ if (isTypingTarget(e.target)) return;
+ e.preventDefault();
+ toggleHelp();
+ });
+}
diff --git a/web/src/main.ts b/web/src/main.ts
index 11130bd..857c96f 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -1,6 +1,7 @@
import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installHudKeybind } from "./debug.js";
+import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
@@ -28,6 +29,7 @@ import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
+installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
@@ -219,6 +221,7 @@ function renderWelcome(): HTMLElement {
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
+ Press ? for keyboard shortcuts.
`;
const spawn = document.createElement("button");
spawn.type = "button";
@@ -327,16 +330,9 @@ async function requestFastForward(): Promise {
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
- const target = e.target as HTMLElement | null;
- if (
- target &&
- (target.tagName === "INPUT" ||
- target.tagName === "TEXTAREA" ||
- target.tagName === "SELECT" ||
- target.isContentEditable)
- ) {
- return;
- }
+ if (isTypingTarget(e.target)) return;
+ // The help overlay owns the keyboard while it is up.
+ if (isHelpOpen()) return;
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
diff --git a/web/src/styles.css b/web/src/styles.css
index 67fabfb..ddb2e04 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -876,6 +876,113 @@ input.leaf-rename:focus {
background: var(--danger-tint);
}
+/* ---------- help overlay (`?`) ---------- */
+.help-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-modal);
+ display: grid;
+ place-items: center;
+ padding: var(--sp-4);
+ background: oklch(0 0 0 / 0.5);
+ backdrop-filter: blur(4px);
+}
+
+.help-panel {
+ width: min(440px, 100%);
+ max-height: min(80vh, 640px);
+ overflow-y: auto;
+ padding: var(--sp-4);
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r-3);
+ box-shadow: var(--shadow-2);
+}
+
+.help-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--sp-2);
+ margin-bottom: var(--sp-3);
+}
+
+.help-title {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: var(--fs-15);
+ color: var(--ink);
+}
+
+.help-close {
+ appearance: none;
+ background: none;
+ border: 0;
+ border-radius: var(--r-2);
+ color: var(--muted);
+ font: inherit;
+ line-height: 1;
+ padding: var(--sp-1) var(--sp-2);
+ cursor: pointer;
+}
+
+.help-close:hover,
+.help-close:focus-visible {
+ background: var(--surface-3);
+ color: var(--ink);
+ outline: none;
+}
+
+.help-group + .help-group {
+ margin-top: var(--sp-4);
+}
+
+.help-group-title {
+ margin: 0 0 var(--sp-2);
+ font-size: var(--fs-11);
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+
+.help-list {
+ margin: 0;
+}
+
+/* Keycaps in a fixed column so the descriptions line up down the list. */
+.help-row {
+ display: grid;
+ grid-template-columns: 14ch 1fr;
+ align-items: baseline;
+ gap: var(--sp-3);
+ padding: var(--sp-1) 0;
+}
+
+.help-keys {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: var(--sp-1);
+ margin: 0;
+}
+
+.help-keys kbd {
+ font-family: var(--font-mono);
+ font-size: var(--fs-11);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: var(--r-1);
+ padding: 1px 6px;
+ color: var(--ink);
+}
+
+.help-desc {
+ margin: 0;
+ font-size: var(--fs-13);
+ color: var(--muted);
+}
+
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
@@ -2106,7 +2213,7 @@ input.leaf-rename:focus {
.mv-canvas > .mv-split,
.mv-canvas > .mv-pane {
position: absolute;
- inset: var(--sp-2);
+ inset: 0;
}
.mv-canvas > .content-welcome {
position: absolute;
@@ -2346,7 +2453,6 @@ input.leaf-rename:focus {
.mv-mobile {
position: absolute;
- /* Flush to the edges — a single full-screen pane needs no canvas inset. */
inset: 0;
display: flex;
flex-direction: column;
From 8e1f26ac8db4121726daecf89e1df24ea0c9ec8a Mon Sep 17 00:00:00 2001
From: Miguel Palhas
Date: Mon, 31 Aug 2026 14:39:29 +0100
Subject: [PATCH 2/3] fix(maestro): spawn similar preselects the cloned project
Spawn similar dropped the session's cwd into the free-text directory hatch,
so the project picker showed nothing selected. For a worktree session that
cwd was the daemon-made checkout, and the worktree toggle defaulted on, so
the clone asked for a worktree of a worktree.
It now seeds the picker: the cloned project's box selected, the same
worktree decision, base repo plus '+ new' when the source had a worktree.
A seeded path that matches no project falls back to the raw input showing
that path instead of an empty picker.
---
web/src/main.ts | 7 +++---
web/src/spawnForm.ts | 5 +++-
web/src/spawnSimilar.test.ts | 45 ++++++++++++++++++++++++++++++++++++
web/src/spawnSimilar.ts | 30 ++++++++++++++++++++++++
web/src/worktreeStep.test.ts | 31 +++++++++++++++++++++++++
5 files changed, 114 insertions(+), 4 deletions(-)
create mode 100644 web/src/spawnSimilar.test.ts
create mode 100644 web/src/spawnSimilar.ts
diff --git a/web/src/main.ts b/web/src/main.ts
index 11130bd..bd002ee 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -15,6 +15,7 @@ import {
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
+import { spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
@@ -265,8 +266,8 @@ function showMultiView(): void {
}
// Spawn affordances (#62): the header button, the sidebar's empty state, and
-// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
-// cloned session's dir + command.
+// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
+// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
showMultiView();
// A new tab, not a split of the pane you were reading (#100). "Spawn
@@ -279,7 +280,7 @@ document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
const s = (ev as CustomEvent).detail;
showMultiView();
- mv.openDraft({ cwd: s.cwd, command: s.command });
+ mv.openDraft(spawnSimilarSeed(s));
});
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts
index 8055757..96fa2a1 100644
--- a/web/src/spawnForm.ts
+++ b/web/src/spawnForm.ts
@@ -589,7 +589,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = seedCwd;
cwdRaw.value = seedCwd;
// Restoring must not touch the worktree toggle — the seed carries it.
- if (!restoreSelection(seedCwd)) clearProjectSelection();
+ // A path no box matches (the project is gone, or the scan never saw
+ // it) falls back to the raw input showing that path: an empty picker
+ // with an invisible cwd reads as a form that lost the directory.
+ if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false);
} else {
showRawCwd(seedCwd, false);
}
diff --git a/web/src/spawnSimilar.test.ts b/web/src/spawnSimilar.test.ts
new file mode 100644
index 0000000..e80e9ac
--- /dev/null
+++ b/web/src/spawnSimilar.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { spawnSimilarSeed } from "./spawnSimilar.js";
+import type { Session } from "./types.js";
+
+function session(overrides: Partial = {}): Session {
+ return {
+ id: "s1",
+ cwd: "/repos/app",
+ command: "claude",
+ env: {},
+ metadata: {},
+ status: "Running",
+ created_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ } as Session;
+}
+
+describe("spawnSimilarSeed (#28)", () => {
+ it("seeds the project picker, not the free-text hatch", () => {
+ const seed = spawnSimilarSeed(session());
+ expect(seed.cwd).toBe("/repos/app");
+ expect(seed.command).toBe("claude");
+ expect(seed.cwdRawVisible).toBe(false);
+ });
+
+ it("keeps the worktree toggle off for a session that has no worktree", () => {
+ expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false);
+ });
+
+ it("clones a worktree session as a new worktree of its base repo", () => {
+ const seed = spawnSimilarSeed(
+ session({
+ cwd: "/repos/app-worktrees/feature",
+ worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
+ }),
+ );
+ expect(seed.cwd).toBe("/repos/app");
+ expect(seed.worktreeEnabled).toBe(true);
+ });
+
+ it("never carries the cloned session's name", () => {
+ const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } }));
+ expect(seed.name).toBeUndefined();
+ });
+});
diff --git a/web/src/spawnSimilar.ts b/web/src/spawnSimilar.ts
new file mode 100644
index 0000000..0d85f9b
--- /dev/null
+++ b/web/src/spawnSimilar.ts
@@ -0,0 +1,30 @@
+// "Spawn similar" (#28): the seed a cloned session hands to a draft pane.
+// Pure — the caller owns opening the pane.
+
+import type { Draft } from "./layout.js";
+import type { Session } from "./types.js";
+
+/** Clone a session into draft values: same project, same command, same
+ * worktree decision, fresh name.
+ *
+ * A worktree session seeds its *base repository*, not the checkout the daemon
+ * made for it: "similar" is another session of that project, and the daemon
+ * never adopts an existing worktree (docs/WORKTREES.md), so seeding the
+ * checkout would ask it to build a worktree out of a worktree. The picker
+ * then lands on that repository's box with "+ new" active.
+ *
+ * `cwdRawVisible: false` keeps the two-step project picker on screen with the
+ * cloned project selected, instead of dropping the path into the free-text
+ * escape hatch.
+ *
+ * The name is left out on purpose: a clone is a new session and takes its own
+ * generated handle. */
+export function spawnSimilarSeed(s: Session): Partial {
+ const wt = s.worktree ?? null;
+ return {
+ command: s.command,
+ cwd: wt ? wt.base_repo : s.cwd,
+ worktreeEnabled: wt != null,
+ cwdRawVisible: false,
+ };
+}
diff --git a/web/src/worktreeStep.test.ts b/web/src/worktreeStep.test.ts
index 6fb1431..f84047d 100644
--- a/web/src/worktreeStep.test.ts
+++ b/web/src/worktreeStep.test.ts
@@ -240,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => {
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(toggle(form).checked).toBe(false);
});
+
+ it("lands a 'spawn similar' seed on the cloned project with '+ new' active", async () => {
+ urls = stubFetch([MAESTRO, WT_A, WT_B]);
+ handle = createSpawnForm({
+ onSuccess() {},
+ seed: { cwd: MAESTRO.path, cwdRawVisible: false, worktreeEnabled: true },
+ });
+ document.body.appendChild(handle.el);
+ await drain();
+ const form = handle.el;
+
+ expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
+ expect(activeChoice(form)!.dataset.kind).toBe("new");
+ expect(toggle(form).checked).toBe(true);
+ expect(form.querySelector(".cwd-raw-wrap")!.hidden).toBe(true);
+ });
+
+ it("falls back to the raw input when the seeded directory matches no project", async () => {
+ urls = stubFetch([MAESTRO, WT_A]);
+ handle = createSpawnForm({
+ onSuccess() {},
+ seed: { cwd: "/home/user/gone", cwdRawVisible: false, worktreeEnabled: false },
+ });
+ document.body.appendChild(handle.el);
+ await drain();
+ const form = handle.el;
+
+ const raw = form.querySelector(".cwd-raw")!;
+ expect(form.querySelector(".cwd-raw-wrap")!.hidden).toBe(false);
+ expect(raw.value).toBe("/home/user/gone");
+ });
});
From a503ec7c2b4719dab4765dcedd82664604ce40d6 Mon Sep 17 00:00:00 2001
From: Miguel Palhas
Date: Mon, 31 Aug 2026 14:51:24 +0100
Subject: [PATCH 3/3] refactor(maestro): drop per-session colour and pane glow
The sidebar row dot, the pane top strip and the mobile pill dot all
painted a hashed per-session colour; the focused pane added an outer
accent glow on top of its ring. Removes the colour concept entirely
along with the glow, leaving the flat accent ring as the only focus mark.
---
web/src/catalog.ts | 12 +++--------
web/src/multiview.ts | 18 +++--------------
web/src/sessioncolor.ts | 23 ---------------------
web/src/styles.css | 45 ++++-------------------------------------
4 files changed, 10 insertions(+), 88 deletions(-)
delete mode 100644 web/src/sessioncolor.ts
diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index 2f1a4f2..0d58035 100644
--- a/web/src/catalog.ts
+++ b/web/src/catalog.ts
@@ -327,12 +327,10 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
- * pane (quiet), which one is focused (loud), and a stable per-session colour so
- * a row maps to its pane. */
+ * pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
open: Set;
focused: string | null;
- colors: Map;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
@@ -342,7 +340,7 @@ export interface SidebarHandle {
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
* Back-compat shim over {@link setHighlights}. */
setActive(id: string | null): void;
- /** Apply the full open/focused/colour highlight state. */
+ /** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Tear down the store subscription + listeners. */
dispose(): void;
@@ -451,7 +449,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
refresh();
});
- let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
+ let highlights: SidebarHighlights = { open: new Set(), focused: null };
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -460,9 +458,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
- const color = highlights.colors.get(id);
- if (color && (open || focused)) n.style.setProperty("--mv-color", color);
- else n.style.removeProperty("--mv-color");
}
};
@@ -515,7 +510,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
- colors: new Map(),
};
applyHighlights();
},
diff --git a/web/src/multiview.ts b/web/src/multiview.ts
index f19de89..3da26a8 100644
--- a/web/src/multiview.ts
+++ b/web/src/multiview.ts
@@ -22,7 +22,6 @@ import {
removeLeaf,
splitLeaf,
} from "./layout.js";
-import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -97,12 +96,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- highlight helpers ----
- function colorMap(): Map {
- const m = new Map();
- for (const id of openSessions(layout)) m.set(id, sessionColor(id));
- return m;
- }
-
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -120,7 +113,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
- colors: colorMap(),
});
}
@@ -142,10 +134,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
- if (leaf.sessionId) {
- pane.dataset.sessionId = leaf.sessionId;
- pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
- }
+ if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
const host = document.createElement("div");
host.className = "mv-pane-host";
@@ -482,7 +471,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
- sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
+ sidebar.setHighlights({ open: openSessions(layout), focused: null });
persist();
return;
}
@@ -523,7 +512,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pill.type = "button";
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
- if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
pill.addEventListener("click", () => {
tab.focusedLeafId = l.id;
render();
@@ -543,7 +531,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
);
pool.setFocus(focused.sessionId ?? null);
- sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
+ sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
diff --git a/web/src/sessioncolor.ts b/web/src/sessioncolor.ts
deleted file mode 100644
index a61caab..0000000
--- a/web/src/sessioncolor.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-// Stable per-session accent colour, used to map a sidebar row to the pane
-// showing that session (issue #33). Hash the session id into a fixed OKLCH
-// palette tuned to the maestro tokens; collisions are tolerated when there are
-// more open sessions than hues — the colour is a hint, not an identity.
-
-import type { Uuid } from "./types.js";
-
-const PALETTE = [
- "oklch(0.80 0.13 152)", // green (the house accent)
- "oklch(0.80 0.12 90)", // amber
- "oklch(0.76 0.13 30)", // coral
- "oklch(0.78 0.11 210)", // teal
- "oklch(0.74 0.13 265)", // blue
- "oklch(0.76 0.14 320)", // magenta
- "oklch(0.80 0.11 60)", // gold
- "oklch(0.78 0.12 185)", // cyan
-];
-
-export function sessionColor(id: Uuid): string {
- let h = 0;
- for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
- return PALETTE[h % PALETTE.length];
-}
diff --git a/web/src/styles.css b/web/src/styles.css
index ddb2e04..0f729a4 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -2301,20 +2301,10 @@ input.leaf-rename:focus {
min-width: 0;
min-height: 0;
}
-/* per-session colour strip — maps a pane to its sidebar row */
-.mv-pane::before {
- content: "";
- position: absolute;
- inset: 0 0 auto 0;
- height: 2px;
- background: var(--mv-color, transparent);
- z-index: 3;
- pointer-events: none;
-}
-/* focused pane: accent ring + glow, full strength */
+/* focused pane: accent ring, full strength */
.mv-pane.is-focused {
border-color: var(--accent);
- box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
+ box-shadow: inset 0 0 0 1px var(--accent);
}
/* unfocused panes recede so the eye finds the live one */
.mv-pane:not(.is-focused) {
@@ -2417,31 +2407,12 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
-/* sidebar two-tier highlight + colour dot (issue #33).
+/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
- elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
+ elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
-.tree-leaf.is-open::after,
-.tree-leaf.is-active::after {
- content: "";
- position: absolute;
- right: 9px;
- top: 50%;
- width: 7px;
- height: 7px;
- margin-top: -3.5px;
- border-radius: 50%;
- background: var(--mv-color, var(--accent));
- pointer-events: none;
-}
-.tree-leaf.is-open::after {
- opacity: 0.7;
-}
-.tree-leaf.is-active::after {
- box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
-}
/* =========================================================================
multi-view mobile (#35): below 860px the split tree projects to a pill strip
@@ -2486,14 +2457,6 @@ input.leaf-rename:focus {
cursor: pointer;
border: 1px solid transparent;
}
-.mv-pill::before {
- content: "";
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--mv-color, var(--faint));
- flex: none;
-}
.mv-pill.is-active {
background: var(--accent-tint);
color: var(--accent-strong);