feat(maestro): rename a session from the web sidebar
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:
co-authored by
Claude Opus 5
parent
bdeec6eff5
commit
43b90ccf0a
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user