diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index 65d8efd..5dd1045 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 { InTheWay, Session } from "./types.js";
import {
@@ -325,6 +326,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";
@@ -338,12 +347,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). */
@@ -353,7 +360,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;
@@ -462,7 +469,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;
@@ -471,9 +478,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");
}
};
@@ -526,7 +530,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/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..cb268aa 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 {
@@ -15,6 +16,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";
@@ -28,6 +30,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 +222,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";
@@ -265,8 +269,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 +283,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
@@ -327,16 +331,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/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/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/styles.css b/web/src/styles.css
index cca53fd..566c681 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 {
@@ -2146,7 +2253,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;
@@ -2234,20 +2341,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) {
@@ -2350,31 +2447,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
@@ -2386,7 +2464,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;
@@ -2420,14 +2497,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);
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");
+ });
});