diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 2fc925a..b2f7bc0 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -20,6 +20,13 @@ A card is always two lines: **line 1** and **line 2**. Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited` badge move off this row into line 2. +The name is editable in place (#125). In the web client, right-clicking a card +opens a context menu whose **rename** entry swaps the name for a text field — +Enter commits, Escape cancels. The TUI opens the same edit as a prompt, bound +to `r` in the sidebar by default. Both send `PATCH /api/sessions/:id`; both +start from the session's own name, never from the command it falls back to, +and an empty field clears the name so the fallback resumes. + ## Line 2 — meta line (adaptive) Line 2 always renders — never an empty placeholder row either, since its diff --git a/web/src/api.ts b/web/src/api.ts index b1cd03d..5030aef 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -44,6 +44,20 @@ export async function createSession(req: CreateSessionRequest): Promise ); } +/** Rename a session (#125). An empty name clears the custom one, so the card + * falls back to the command. The daemon trims and caps what it stores and + * broadcasts the update, so callers need not patch the store themselves. */ +export async function renameSession(id: Uuid, name: string): Promise { + return jsonOrError( + await fetch(routes.session(id), { + method: "PATCH", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }), + ); +} + export async function killSession(id: Uuid): Promise { await jsonOrError( await fetch(routes.session(id), { diff --git a/web/src/catalog.ts b/web/src/catalog.ts index bd5c399..6af1c84 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -1,9 +1,18 @@ import { escapeHtml } from "./html.js"; -import { getDaemonInfo, killSession } from "./api.js"; +import { getDaemonInfo, killSession, renameSession } from "./api.js"; +import { openContextMenu } from "./contextMenu.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; -import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js"; +import { + abbreviateFolderName, + customSessionName, + formatInTheWay, + MAX_SESSION_NAME_LEN, + normalizeSessionName, + sessionBlockers, + sessionName, +} from "./types.js"; import { activityColorSlug, activityDurationLabel, @@ -23,6 +32,11 @@ export function requestOpenSpawn(): void { const collapsed = new Set(); +/** Session whose card is currently being renamed inline (#125). The tree + * re-renders on every catalog delta, and an activity update arriving mid-edit + * would otherwise replace the field the user is typing into. */ +let renamingId: string | null = null; + // docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries // it. It survives only in tooltips/aria labels so meaning is not colour-only. const STATE_WORDS: Record = { @@ -124,24 +138,96 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; - killBtn.addEventListener("click", async (ev) => { + killBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - // #83: the confirm restates the warning at the moment of decision. - const blockers = sessionBlockers(s); - const question = blockers - ? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` - : `Kill session "${name}"?`; - if (!confirm(question)) return; - try { - await killSession(s.id); - } catch (e) { - alert(`Failed to kill: ${(e as Error).message}`); - } + void confirmAndKill(s, name); + }); + row.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openContextMenu(ev.clientX, ev.clientY, [ + { label: "Rename…", onSelect: () => beginRename(row, s) }, + { + label: "Spawn similar", + onSelect: () => + document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })), + }, + { label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, + ]); }); return row; } +/** #83: the confirm restates the warning at the moment of decision. */ +async function confirmAndKill(s: Session, name: string): Promise { + const blockers = sessionBlockers(s); + const question = blockers + ? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` + : `Kill session "${name}"?`; + if (!confirm(question)) return; + try { + await killSession(s.id); + } catch (e) { + alert(`Failed to kill: ${(e as Error).message}`); + } +} + +/** + * Swap the card's name for a text field (#125). Enter commits, Escape and + * blur cancel. An emptied field clears the name, so the card falls back to + * the command — the same rule the spawn form's blank name field follows. + */ +function beginRename(row: HTMLElement, s: Session): void { + const nameEl = row.querySelector(".leaf-name"); + if (!nameEl) return; + const input = document.createElement("input"); + input.type = "text"; + input.className = "leaf-name leaf-rename"; + input.value = customSessionName(s) ?? ""; + input.maxLength = MAX_SESSION_NAME_LEN; + input.setAttribute("aria-label", `Rename ${sessionName(s)}`); + // The card is an , and the tree starts a drag on pointerdown: without + // these, clicking into the field navigates away or begins dragging the row. + for (const type of ["click", "pointerdown", "dblclick"]) { + input.addEventListener(type, (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + input.focus(); + }); + } + + renamingId = s.id; + let settled = false; + const finish = () => { + if (settled) return false; + settled = true; + renamingId = null; + document.dispatchEvent(new CustomEvent("catalog-rerender")); + return true; + }; + + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + // Normalised here so what is sent is what the daemon will store. + const name = normalizeSessionName(input.value); + if (!finish()) return; + void renameSession(s.id, name).catch((e: Error) => { + alert(`Failed to rename: ${e.message}`); + }); + } else if (ev.key === "Escape") { + ev.preventDefault(); + finish(); + } + }); + input.addEventListener("blur", () => finish()); + + nameEl.replaceWith(input); + input.focus(); + input.select(); +} + function renderStatusbar(sessions: Session[]): HTMLElement { const wrap = document.createElement("div"); wrap.className = "statusbar"; @@ -402,6 +488,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }; const refresh = () => { + // Rebuilding the tree now would blow away the field being typed into. + if (renamingId !== null) return; const state = store.snapshot(); const sessions = [...state.sessions.values()]; const tree = buildTree(sessions, homeDir, filter); @@ -458,6 +546,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }, dispose() { clearInterval(clock); + // An unfinished rename would otherwise leave every later sidebar + // refusing to refresh. + renamingId = null; document.removeEventListener("catalog-rerender", rerenderHandler); unsubscribe(); }, diff --git a/web/src/contextMenu.test.ts b/web/src/contextMenu.test.ts new file mode 100644 index 0000000..3b356d9 --- /dev/null +++ b/web/src/contextMenu.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { closeContextMenu, openContextMenu } from "./contextMenu.js"; + +afterEach(() => { + closeContextMenu(); + document.body.replaceChildren(); +}); + +function labels(): string[] { + return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? ""); +} + +describe("context menu (#125)", () => { + it("renders one item per entry, in order", () => { + openContextMenu(10, 20, [ + { label: "Rename…", onSelect: () => {} }, + { label: "Kill session", onSelect: () => {}, danger: true }, + ]); + expect(labels()).toEqual(["Rename…", "Kill session"]); + expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe( + "Kill session", + ); + }); + + it("runs the selected entry and closes", () => { + const chosen = vi.fn(); + openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]); + (document.querySelector(".context-menu-item") as HTMLButtonElement).click(); + expect(chosen).toHaveBeenCalledOnce(); + expect(document.querySelector(".context-menu")).toBeNull(); + }); + + it("opening a second menu replaces the first", () => { + openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]); + openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]); + expect(document.querySelectorAll(".context-menu")).toHaveLength(1); + expect(labels()).toEqual(["second"]); + }); + + it("closes on Escape and on a press outside it", () => { + openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(document.querySelector(".context-menu")).toBeNull(); + + openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); + expect(document.querySelector(".context-menu")).toBeNull(); + }); + + it("stays open for a press inside itself", () => { + openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]); + const menu = document.querySelector(".context-menu") as HTMLElement; + menu.dispatchEvent(new Event("pointerdown", { bubbles: true })); + expect(document.querySelector(".context-menu")).not.toBeNull(); + }); +}); diff --git a/web/src/contextMenu.ts b/web/src/contextMenu.ts new file mode 100644 index 0000000..2613fac --- /dev/null +++ b/web/src/contextMenu.ts @@ -0,0 +1,81 @@ +/** Custom right-click menu (#125). + * + * One menu exists at a time: opening a second closes the first. The menu is + * appended to `` rather than to the row it belongs to, so an overflowing + * sidebar cannot clip it. + */ + +export interface ContextMenuItem { + label: string; + onSelect: () => void; + /** Renders the entry in the destructive style. */ + danger?: boolean; +} + +let open: { el: HTMLElement; dispose: () => void } | null = null; + +/** Close the menu that is currently up, if any. */ +export function closeContextMenu(): void { + open?.dispose(); +} + +/** + * Open a menu at viewport coordinates `x`/`y`. The menu closes on selection, + * on Escape, on a pointer press anywhere outside it, and on scroll or resize — + * a menu anchored to coordinates goes stale the moment the page moves under it. + */ +export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement { + closeContextMenu(); + + const el = document.createElement("div"); + el.className = "context-menu"; + el.setAttribute("role", "menu"); + for (const item of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item"; + btn.setAttribute("role", "menuitem"); + btn.textContent = item.label; + btn.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + closeContextMenu(); + item.onSelect(); + }); + el.appendChild(btn); + } + document.body.appendChild(el); + + // Nudge back inside the viewport when the click was near an edge. happy-dom + // reports 0 for every measurement, which lands the menu at the click point — + // the same place it would sit with room to spare. + const rect = el.getBoundingClientRect(); + const maxX = window.innerWidth - rect.width; + const maxY = window.innerHeight - rect.height; + el.style.left = `${Math.max(0, Math.min(x, maxX))}px`; + el.style.top = `${Math.max(0, Math.min(y, maxY))}px`; + + const onPointerDown = (ev: Event) => { + if (!el.contains(ev.target as Node)) closeContextMenu(); + }; + const onKeyDown = (ev: KeyboardEvent) => { + if (ev.key === "Escape") closeContextMenu(); + }; + const dispose = () => { + if (open?.el !== el) return; + open = null; + el.remove(); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", closeContextMenu, true); + window.removeEventListener("resize", closeContextMenu); + }; + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("scroll", closeContextMenu, true); + window.addEventListener("resize", closeContextMenu); + + open = { el, dispose }; + (el.firstElementChild as HTMLElement | null)?.focus(); + return el; +} diff --git a/web/src/rename.test.ts b/web/src/rename.test.ts new file mode 100644 index 0000000..0a01301 --- /dev/null +++ b/web/src/rename.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { renderSidebar } from "./catalog.js"; +import { closeContextMenu } from "./contextMenu.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; +import { normalizeSessionName } from "./types.js"; + +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); +}); + +let mounted: { el: HTMLElement; dispose: () => void } | null = null; + +afterEach(() => { + closeContextMenu(); + mounted?.dispose(); + mounted = null; + vi.unstubAllGlobals(); +}); + +function session(overrides: Partial = {}): Session { + return { + id: "aaaaaaaa-0000-0000-0000-0000000000ff", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: { name: "amber-falcon" }, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + ...overrides, + }; +} + +function storeWith(sessions: Session[]): CatalogStore { + const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; + return { + snapshot: () => state, + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +/** Mount a sidebar with one session; the handle is disposed after each test. */ +function mount(s: Session): HTMLElement { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + const handle = renderSidebar(storeWith([s])); + mounted = handle; + return handle.el; +} + +function cardIn(el: HTMLElement, s: Session): HTMLElement { + return el.querySelector(`[data-session-id="${s.id}"]`) as HTMLElement; +} + +/** Mount a sidebar with one session and right-click its card. */ +function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } { + const el = mount(s); + const row = cardIn(el, s); + row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true })); + return { el, row }; +} + +function menuItem(label: string): HTMLButtonElement { + const found = [...document.querySelectorAll(".context-menu-item")].find( + (b) => b.textContent === label, + ); + if (!found) throw new Error(`no "${label}" entry in the context menu`); + return found; +} + +describe("session name normalisation (#125)", () => { + it("trims, and treats a blank name as a clear", () => { + expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon"); + expect(normalizeSessionName(" ")).toBe(""); + }); + + it("caps at the daemon's limit, counting code points", () => { + expect(normalizeSessionName("x".repeat(80))).toHaveLength(64); + expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64); + }); +}); + +describe("sidebar card context menu (#125)", () => { + it("suppresses the browser menu and offers rename, spawn similar and kill", () => { + const s = session(); + const row = cardIn(mount(s), s); + const ev = new Event("contextmenu", { bubbles: true, cancelable: true }); + row.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([ + "Rename…", + "Spawn similar", + "Kill session", + ]); + }); + + it("spawn similar dispatches the same event the ⧉ button does", () => { + const s = session(); + openCardMenu(s); + const seen = vi.fn(); + document.addEventListener("catalog-spawn-similar", seen); + menuItem("Spawn similar").click(); + document.removeEventListener("catalog-spawn-similar", seen); + expect(seen).toHaveBeenCalledOnce(); + }); +}); + +describe("inline rename (#125)", () => { + it("pre-fills the field with the session's own name", () => { + const { row } = openCardMenu(session()); + menuItem("Rename…").click(); + const input = row.querySelector("input.leaf-rename"); + expect(input?.value).toBe("amber-falcon"); + expect(row.querySelector("span.leaf-name")).toBeNull(); + }); + + it("starts empty for a session that has no name of its own", () => { + const { row } = openCardMenu(session({ metadata: {} })); + menuItem("Rename…").click(); + expect(row.querySelector("input.leaf-rename")?.value).toBe(""); + }); + + it("holds the tree still while the field is open", () => { + const s = session(); + const { el, row } = openCardMenu(s); + menuItem("Rename…").click(); + const input = row.querySelector("input.leaf-rename"); + // A catalog delta mid-edit must not rebuild the row out from under it. + document.dispatchEvent(new CustomEvent("catalog-rerender")); + expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input); + }); + + it("Enter PATCHes the session with the typed name", () => { + const s = session(); + const { row } = openCardMenu(s); + menuItem("Rename…").click(); + const input = row.querySelector("input.leaf-rename") as HTMLInputElement; + input.value = "crimson-otter"; + + const fetchMock = vi.fn(() => + Promise.resolve(new Response(JSON.stringify(s), { status: 200 })), + ); + vi.stubGlobal("fetch", fetchMock); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`/api/sessions/${s.id}`); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" }); + }); + + it("Escape cancels without a request and restores the name", () => { + const s = session(); + const { el, row } = openCardMenu(s); + menuItem("Rename…").click(); + const input = row.querySelector("input.leaf-rename") as HTMLInputElement; + input.value = "discarded"; + + const fetchMock = vi.fn(() => Promise.reject(new Error("offline"))); + vi.stubGlobal("fetch", fetchMock); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(fetchMock).not.toHaveBeenCalled(); + // The row is rebuilt from the store, so the old name comes back. + expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon"); + expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull(); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css index 07dbd41..ae114e7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -814,6 +814,68 @@ textarea { white-space: nowrap; } +/* Inline rename field (#125): sits exactly where the name it replaces did, so + the card does not jump when the edit starts. */ +input.leaf-rename { + font: inherit; + font-weight: 500; + font-size: var(--fs-13); + color: var(--ink); + background: var(--surface-2); + border: 1px solid var(--accent-ring); + border-radius: var(--r-2); + padding: 0 var(--sp-1); + min-width: 0; +} + +input.leaf-rename:focus { + outline: none; + box-shadow: 0 0 0 3px var(--accent-tint); +} + +/* Right-click menu on a session card (#125). Positioned from the click point, + so it is fixed to the viewport rather than to the scrolling sidebar. */ +.context-menu { + position: fixed; + z-index: var(--z-modal); + display: flex; + flex-direction: column; + min-width: 168px; + padding: var(--sp-1); + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--r-3); + box-shadow: var(--shadow-2); +} + +.context-menu-item { + appearance: none; + background: none; + border: 0; + border-radius: var(--r-2); + color: var(--ink); + font: inherit; + font-size: var(--fs-13); + text-align: left; + padding: var(--sp-1) var(--sp-2); + cursor: pointer; +} + +.context-menu-item:hover, +.context-menu-item:focus-visible { + background: var(--surface-3); + outline: none; +} + +.context-menu-item.is-danger { + color: var(--danger); +} + +.context-menu-item.is-danger:hover, +.context-menu-item.is-danger:focus-visible { + background: var(--danger-tint); +} + /* 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 { diff --git a/web/src/types.ts b/web/src/types.ts index 3731779..6c627d8 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -244,10 +244,29 @@ export interface ApiError { } export function sessionName(s: Session): string { + return customSessionName(s) ?? s.command; +} + +/** The session's own name, without the command fallback (#125): `undefined` + * for a session that was never given one. */ +export function customSessionName(s: Session): string | undefined { const meta = s.metadata as Record; const name = meta?.name; if (typeof name === "string" && name.length > 0) return name; - return s.command; + return undefined; +} + +/** Longest session name the daemon stores (#125), in characters. Mirrors + * `maestro_protocol::MAX_SESSION_NAME_LEN`. */ +export const MAX_SESSION_NAME_LEN = 64; + +/** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN} + * characters. Empty means "clear the name", so the card falls back to the + * command. Mirrors `maestro_protocol::normalize_session_name` — the daemon + * applies the same rule, this only keeps the UI honest about what it will + * get back. Uses the spread so the cap counts code points, not UTF-16 units. */ +export function normalizeSessionName(name: string): string { + return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join(""); } /** The directory a session lives in: its live `current_cwd` if the daemon has