feat(maestro): rename a session from the web sidebar
ci / web (push) Successful in 31s
ci / rust (push) Successful in 5m26s
ci / flake (push) Successful in 8m13s

Right-clicking a card opens a custom context menu (rename, spawn similar,
kill). Rename swaps the name for an inline field; Enter commits, Escape and
blur cancel. The tree stops rebuilding while the field is open, so a catalog
delta cannot replace what is being typed into.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-30 11:32:20 +01:00
co-authored by Claude Opus 5
parent bdeec6eff5
commit 43b90ccf0a
8 changed files with 525 additions and 15 deletions
+7
View File
@@ -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
+14
View File
@@ -44,6 +44,20 @@ export async function createSession(req: CreateSessionRequest): Promise<Session>
);
}
/** 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<Session> {
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<void> {
await jsonOrError(
await fetch(routes.session(id), {
+105 -14
View File
@@ -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<string>();
/** 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<string, string> = {
@@ -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<void> {
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<HTMLElement>(".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 <a>, 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();
},
+58
View File
@@ -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();
});
});
+81
View File
@@ -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 `<body>` 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;
}
+178
View File
@@ -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> = {}): 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<HTMLElement>(`[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<HTMLButtonElement>(".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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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();
});
});
+62
View File
@@ -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 {
+20 -1
View File
@@ -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<string, unknown>;
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