Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a503ec7c2b | ||
|
|
904a665432 | ||
|
|
8e1f26ac8d | ||
|
|
f0851a62ed |
+12
-9
@@ -1,6 +1,7 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
|
import { toggleHelp } from "./help.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import {
|
import {
|
||||||
@@ -305,6 +306,14 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
kit.title = "Open the design kit (DEV)";
|
kit.title = "Open the design kit (DEV)";
|
||||||
actions.appendChild(kit);
|
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());
|
actions.appendChild(createThemeToggle());
|
||||||
const spawnBtn = document.createElement("button");
|
const spawnBtn = document.createElement("button");
|
||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
@@ -318,12 +327,10 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
/** 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
|
* pane (quiet), and which one is focused (loud). */
|
||||||
* a row maps to its pane. */
|
|
||||||
export interface SidebarHighlights {
|
export interface SidebarHighlights {
|
||||||
open: Set<string>;
|
open: Set<string>;
|
||||||
focused: string | null;
|
focused: string | null;
|
||||||
colors: Map<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
||||||
@@ -333,7 +340,7 @@ export interface SidebarHandle {
|
|||||||
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
||||||
* Back-compat shim over {@link setHighlights}. */
|
* Back-compat shim over {@link setHighlights}. */
|
||||||
setActive(id: string | null): void;
|
setActive(id: string | null): void;
|
||||||
/** Apply the full open/focused/colour highlight state. */
|
/** Apply the full open/focused highlight state. */
|
||||||
setHighlights(h: SidebarHighlights): void;
|
setHighlights(h: SidebarHighlights): void;
|
||||||
/** Tear down the store subscription + listeners. */
|
/** Tear down the store subscription + listeners. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -442,7 +449,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
|
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||||
const applyHighlights = () => {
|
const applyHighlights = () => {
|
||||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||||
const id = n.dataset.sessionId;
|
const id = n.dataset.sessionId;
|
||||||
@@ -451,9 +458,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
const open = highlights.open.has(id);
|
const open = highlights.open.has(id);
|
||||||
n.classList.toggle("is-active", focused);
|
n.classList.toggle("is-active", focused);
|
||||||
n.classList.toggle("is-open", open && !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");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -506,7 +510,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
highlights = {
|
highlights = {
|
||||||
open: id ? new Set([id]) : new Set(),
|
open: id ? new Set([id]) : new Set(),
|
||||||
focused: id,
|
focused: id,
|
||||||
colors: new Map(),
|
|
||||||
};
|
};
|
||||||
applyHighlights();
|
applyHighlights();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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("<input>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||||
|
expect(isTypingTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+159
@@ -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 <kbd>. */
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
+10
-13
@@ -1,6 +1,7 @@
|
|||||||
import { renderSidebar, renderTopbar } from "./catalog.js";
|
import { renderSidebar, renderTopbar } from "./catalog.js";
|
||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
|
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import { shouldRetryClose } from "./reconnect.js";
|
import { shouldRetryClose } from "./reconnect.js";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
resolveFastForward,
|
resolveFastForward,
|
||||||
} from "./fastForward.js";
|
} from "./fastForward.js";
|
||||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||||
|
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||||
import { mountHud, type Hud } from "./hud.js";
|
import { mountHud, type Hud } from "./hud.js";
|
||||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
@@ -28,6 +30,7 @@ import "./styles.css";
|
|||||||
initTheme();
|
initTheme();
|
||||||
installViewportSizing();
|
installViewportSizing();
|
||||||
installHudKeybind();
|
installHudKeybind();
|
||||||
|
installHelpKeybind();
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
|
|
||||||
// Register the PWA service worker so the app is installable and the shell loads
|
// 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
|
Pick a session from the list, or start a new one. Open sessions keep
|
||||||
streaming in the background — switching back is instant.
|
streaming in the background — switching back is instant.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
||||||
<div class="empty-actions"></div>`;
|
<div class="empty-actions"></div>`;
|
||||||
const spawn = document.createElement("button");
|
const spawn = document.createElement("button");
|
||||||
spawn.type = "button";
|
spawn.type = "button";
|
||||||
@@ -265,8 +269,8 @@ function showMultiView(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn affordances (#62): the header button, the sidebar's empty state, and
|
// 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
|
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
|
||||||
// cloned session's dir + command.
|
// cloned session (see spawnSimilar.ts).
|
||||||
function requestOpenSpawn(): void {
|
function requestOpenSpawn(): void {
|
||||||
showMultiView();
|
showMultiView();
|
||||||
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
// 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) => {
|
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
mv.openDraft(spawnSimilarSeed(s));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
|
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
|
||||||
@@ -327,16 +331,9 @@ async function requestFastForward(): Promise<void> {
|
|||||||
// textarea, search) so an attached session's keys are never hijacked.
|
// textarea, search) so an attached session's keys are never hijacked.
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
const target = e.target as HTMLElement | null;
|
if (isTypingTarget(e.target)) return;
|
||||||
if (
|
// The help overlay owns the keyboard while it is up.
|
||||||
target &&
|
if (isHelpOpen()) return;
|
||||||
(target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.tagName === "SELECT" ||
|
|
||||||
target.isContentEditable)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "n") {
|
if (e.key === "n") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
requestOpenSpawn();
|
requestOpenSpawn();
|
||||||
|
|||||||
+3
-15
@@ -22,7 +22,6 @@ import {
|
|||||||
removeLeaf,
|
removeLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
} from "./layout.js";
|
} from "./layout.js";
|
||||||
import { sessionColor } from "./sessioncolor.js";
|
|
||||||
import type { AttachInfo } from "./attach.js";
|
import type { AttachInfo } from "./attach.js";
|
||||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
|
||||||
@@ -97,12 +96,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
// ---- highlight helpers ----
|
// ---- highlight helpers ----
|
||||||
|
|
||||||
function colorMap(): Map<string, string> {
|
|
||||||
const m = new Map<string, string>();
|
|
||||||
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusedLeafSession(): string | null {
|
function focusedLeafSession(): string | null {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
||||||
@@ -120,7 +113,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: openSessions(layout),
|
open: openSessions(layout),
|
||||||
focused: focusedLeafSession(),
|
focused: focusedLeafSession(),
|
||||||
colors: colorMap(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +134,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
const pane = document.createElement("div");
|
const pane = document.createElement("div");
|
||||||
pane.className = "mv-pane";
|
pane.className = "mv-pane";
|
||||||
pane.dataset.leafId = leaf.id;
|
pane.dataset.leafId = leaf.id;
|
||||||
if (leaf.sessionId) {
|
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
|
||||||
pane.dataset.sessionId = leaf.sessionId;
|
|
||||||
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
host.className = "mv-pane-host";
|
host.className = "mv-pane-host";
|
||||||
@@ -482,7 +471,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
pool.setVisible([]);
|
pool.setVisible([]);
|
||||||
pool.setFocus(null);
|
pool.setFocus(null);
|
||||||
canvas.appendChild(renderWelcome());
|
canvas.appendChild(renderWelcome());
|
||||||
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
|
sidebar.setHighlights({ open: openSessions(layout), focused: null });
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -523,7 +512,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
pill.type = "button";
|
pill.type = "button";
|
||||||
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
|
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
|
||||||
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
|
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", () => {
|
pill.addEventListener("click", () => {
|
||||||
tab.focusedLeafId = l.id;
|
tab.focusedLeafId = l.id;
|
||||||
render();
|
render();
|
||||||
@@ -543,7 +531,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||||
);
|
);
|
||||||
pool.setFocus(focused.sessionId ?? null);
|
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 {
|
function closeSession(sessionId: string): void {
|
||||||
|
|||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -589,7 +589,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd = seedCwd;
|
cwd = seedCwd;
|
||||||
cwdRaw.value = seedCwd;
|
cwdRaw.value = seedCwd;
|
||||||
// Restoring must not touch the worktree toggle — the seed carries it.
|
// 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 {
|
} else {
|
||||||
showRawCwd(seedCwd, false);
|
showRawCwd(seedCwd, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Draft> {
|
||||||
|
const wt = s.worktree ?? null;
|
||||||
|
return {
|
||||||
|
command: s.command,
|
||||||
|
cwd: wt ? wt.base_repo : s.cwd,
|
||||||
|
worktreeEnabled: wt != null,
|
||||||
|
cwdRawVisible: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
+112
-43
@@ -876,6 +876,113 @@ input.leaf-rename:focus {
|
|||||||
background: var(--danger-tint);
|
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
|
/* 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. */
|
state word itself is never spelled out here; the colour carries it. */
|
||||||
.card-line2 {
|
.card-line2 {
|
||||||
@@ -2106,7 +2213,7 @@ input.leaf-rename:focus {
|
|||||||
.mv-canvas > .mv-split,
|
.mv-canvas > .mv-split,
|
||||||
.mv-canvas > .mv-pane {
|
.mv-canvas > .mv-pane {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--sp-2);
|
inset: 0;
|
||||||
}
|
}
|
||||||
.mv-canvas > .content-welcome {
|
.mv-canvas > .content-welcome {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -2194,20 +2301,10 @@ input.leaf-rename:focus {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
/* per-session colour strip — maps a pane to its sidebar row */
|
/* focused pane: accent ring, full strength */
|
||||||
.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 */
|
|
||||||
.mv-pane.is-focused {
|
.mv-pane.is-focused {
|
||||||
border-color: var(--accent);
|
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 */
|
/* unfocused panes recede so the eye finds the live one */
|
||||||
.mv-pane:not(.is-focused) {
|
.mv-pane:not(.is-focused) {
|
||||||
@@ -2310,31 +2407,12 @@ input.leaf-rename:focus {
|
|||||||
border-radius: var(--r-1);
|
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
|
.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 {
|
.tree-leaf.is-open {
|
||||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
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
|
multi-view mobile (#35): below 860px the split tree projects to a pill strip
|
||||||
@@ -2346,7 +2424,6 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.mv-mobile {
|
.mv-mobile {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* Flush to the edges — a single full-screen pane needs no canvas inset. */
|
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2380,14 +2457,6 @@ input.leaf-rename:focus {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid transparent;
|
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 {
|
.mv-pill.is-active {
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
|
|||||||
@@ -240,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => {
|
|||||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||||
expect(toggle(form).checked).toBe(false);
|
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<HTMLElement>(".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<HTMLInputElement>(".cwd-raw")!;
|
||||||
|
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(false);
|
||||||
|
expect(raw.value).toBe("/home/user/gone");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user