Merge remote-tracking branch 'origin/main' into animato
ci / web (push) Successful in 22s
ci / flake (push) Successful in 4m47s
ci / rust (push) Successful in 5m59s

# Conflicts:
#	crates/maestro-daemon/Cargo.toml
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro/Cargo.toml
This commit is contained in:
Miguel Palhas
2026-09-02 18:37:53 +01:00
43 changed files with 1918 additions and 360 deletions
+16
View File
@@ -91,6 +91,22 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
);
}
/** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough
* (docs/SIDEBAR.md). Best-effort: a failure here only delays the glow
* clearing, so callers don't need to surface it. */
export async function setSessionFocus(id: Uuid, focused: boolean): Promise<void> {
await jsonOrError(
await fetch(routes.sessionFocus(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ focused }),
}),
);
}
/** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
* `~`, relative, or absolute — the daemon resolves it against its home dir. */
export async function listDir(path: string): Promise<DirListing> {
+10
View File
@@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -134,6 +135,15 @@ export function createAttachConnection(
term.loadAddon(fit);
term.open(body);
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown" || e.key !== "Escape") return true;
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
return false;
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
+28
View File
@@ -246,6 +246,34 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("dims a name the daemon generated, until it settles (#131)", () => {
const generated = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
metadata: { name: "amber-falcon", name_source: "Generated" },
});
const haiku = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
metadata: { name: "sidebar-rail", name_source: "Haiku" },
});
const chosen = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
metadata: { name: "my-session", name_source: "User" },
});
// Named before `name_source` existed: settled, never dimmed.
const legacy = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
metadata: { name: "older-session" },
});
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
const provisional = (s: Session) =>
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
expect(provisional(generated)).toBe(true);
expect(provisional(haiku)).toBe(false);
expect(provisional(chosen)).toBe(false);
expect(provisional(legacy)).toBe(false);
});
it("renders the command as its tool icon when the tool is known", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
+8 -3
View File
@@ -13,6 +13,7 @@ import {
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
isProvisionalName,
sessionName,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
@@ -36,10 +37,14 @@ export function requestOpenSpawn(): void {
}
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
* what Escape in the sidebar list does. Handled in main.ts, which owns the
* what Tab in the sidebar list does. Handled in main.ts, which owns the
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
@@ -180,7 +185,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
row.innerHTML = `
${activityRailMarkup(slug, stateWord)}
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
@@ -568,7 +573,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav();
else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else return;
e.preventDefault();
});
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
afterEach(() => {
vi.unstubAllGlobals();
});
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
function mockFetch() {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("ConnectionPool.setFocus", () => {
it("reports the new session focused, none unfocused, the first time", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
});
it("unfocuses the previous session and focuses the new one on a switch", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(B);
expect(fetchMock).toHaveBeenCalledTimes(2);
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
url,
JSON.parse((init as RequestInit).body as string),
]);
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
});
it("reports unfocused when focus clears to null", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(null);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
});
it("is a no-op when the focused session doesn't change", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(A);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not let a fetch rejection break the pool", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const pool = new ConnectionPool();
expect(() => pool.setFocus(A)).not.toThrow();
// Let the rejected promise's `.catch` settle before the test ends.
await Promise.resolve();
await Promise.resolve();
});
});
+8
View File
@@ -3,6 +3,7 @@ import {
type AttachConnection,
type AttachInfo,
} from "./attach.js";
import { setSessionFocus } from "./api.js";
import type { Uuid } from "./types.js";
/**
@@ -122,9 +123,16 @@ export class ConnectionPool {
for (const e of entries) this.mount(e.id, e.pane, e.info);
}
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
* only a pane actually becoming the visible view. Best-effort: a failed
* report only delays the glow clearing. */
setFocus(id: Uuid | null): void {
if (id === this.focusedId) return;
const previous = this.focusedId;
this.focusedId = id;
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
if (id !== null) void setSessionFocus(id, true).catch(() => {});
this.opts.onFocusChange?.(id);
}
+2 -1
View File
@@ -31,7 +31,8 @@ export const SHORTCUTS: ShortcutGroup[] = [
{ keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
],
},
{
+7 -1
View File
@@ -31,6 +31,7 @@ import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
@@ -372,8 +373,13 @@ window.addEventListener("keydown", (e) => {
}
});
// Escape in the session list hands the keyboard back to the pane.
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
+4 -2
View File
@@ -162,13 +162,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const name = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
// where it was, whatever the project was last run with.
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// Picking a project is not naming a session: the field stays empty and
// the daemon names it (#131).
expect(name).toBe("");
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
+4
View File
@@ -33,6 +33,10 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
}
export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+2 -2
View File
@@ -109,11 +109,11 @@ describe("sidebar keyboard navigation", () => {
expect(handle.hasListFocus()).toBe(true);
});
it("Escape asks the shell for the pane back", () => {
it("Tab asks the shell for the pane back", () => {
const { el } = twoFolders();
let asked = 0;
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
press(tree(el), "Escape");
press(tree(el), "Tab");
expect(asked).toBe(1);
});
+28 -60
View File
@@ -142,8 +142,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off" />
<button type="button" class="btn btn-icon reroll" aria-label="Generate a new name" title="New name">↻</button>
<input name="name" type="text" autocomplete="off"
placeholder="named after your first prompt" />
</div>
</div>
<fieldset class="worktree">
@@ -257,57 +257,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
};
// ---- name (also seeds the worktree branch) ---------------------------
// The worktree branch defaults to the session name — including a clone's
// prefilled name — and tracks it on reroll/edit until the user types their
// own branch. Only in "new" mode: an existing-branch pick is not a name.
// The field starts empty (#131): a session the operator does not name is
// named by the daemon — a provisional handle at spawn, replaced by one
// derived from the first prompt. Typing here opts out of all of that, and
// still seeds the worktree branch as it always did.
// True only once the operator types their own branch. It cannot be inferred
// from the seed: in "new" mode `emit()` publishes the auto-synced session
// name, so seeding from it froze the branch after any form rebuild
// (#67 review). `worktreeBranchEdited` is carried explicitly instead.
let branchEdited = Boolean(prefill.worktreeBranchEdited);
// A cleared name leaves the branch on its last value rather than blanking
// it: the branch still has to be something, and the session name no longer
// has to be anything.
const syncBranchToName = () => {
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
};
// Handles come from `GET /api/names` in batches (#53): the first one seeds
// the field, rerolls consume the rest locally so they stay instant, and a
// new batch is fetched when (and only when) the queue runs dry. One refill
// at a time keeps rapid rerolls from storming the daemon.
const NAME_BATCH_SIZE = 20;
let nameQueue: string[] = [];
let nameFetch: Promise<void> | null = null;
// Guards against a late batch clobbering something the operator already
// typed or rerolled.
let nameTouched = Boolean(prefill.name);
const refillNames = (): Promise<void> => {
if (!nameFetch) {
nameFetch = fetchNames(NAME_BATCH_SIZE)
.then((batch) => {
if (!destroyed) nameQueue.push(...batch.names);
})
.catch(() => {
/* reroll just stays on the current name */
})
.finally(() => {
nameFetch = null;
});
if (!branchEdited && branchMode === "new" && nameInput.value) {
branchInput.value = nameInput.value;
}
return nameFetch;
};
nameInput.value = prefill.name ?? "";
if (!nameTouched) {
void refillNames().then(() => {
if (destroyed || nameTouched || nameInput.value) return;
const next = nameQueue.shift();
if (next !== undefined) {
nameInput.value = next;
syncBranchToName();
emit();
}
});
}
nameInput.addEventListener("input", () => {
nameTouched = true;
syncBranchToName();
emit();
});
@@ -315,24 +284,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
branchEdited = true;
emit();
});
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
nameTouched = true;
const apply = () => {
if (destroyed) return;
const next = nameQueue.shift();
if (next === undefined) return;
nameInput.value = next;
syncBranchToName();
emit();
};
// Only hit the daemon when the local batch is exhausted.
if (nameQueue.length === 0) {
void refillNames().then(apply);
} else {
apply();
}
});
syncBranchToName(); // seed the branch from the initial name
syncBranchToName(); // seed the branch from a clone's prefilled name
// The branch field still needs a default, and the name field no longer
// provides one, so an unnamed spawn borrows one handle from `GET /api/names`
// (#53) for the branch alone.
if (!branchInput.value && !branchEdited && branchMode === "new") {
void fetchNames(1)
.then((batch) => {
const handle = batch.names[0];
if (destroyed || !handle) return;
if (branchInput.value || branchEdited || branchMode !== "new") return;
branchInput.value = handle;
emit();
})
.catch(() => {
/* the operator types their own branch */
});
}
// ---- command tiles ---------------------------------------------------
// `focus` is false for the two initial applications (construction, and again
+29 -56
View File
@@ -4,10 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["cello", "fugue", "lyre"];
const BATCH_B = ["oboe", "tuba", "harp"];
/** Stub the three fetches the form's initial load makes. Records every
* /api/names URL so tests can assert how many batches were fetched. */
/** Stub the fetches the form's initial load makes. Records every /api/names
* URL so tests can assert how many handles were asked for. */
function stubFetch(batches: string[][]): string[] {
let batch = 0;
const nameUrls: string[] = [];
@@ -30,7 +29,7 @@ function stubFetch(batches: string[][]): string[] {
return nameUrls;
}
describe("spawnForm name batch (#53)", () => {
describe("spawnForm naming (#131)", () => {
let handle: SpawnFormHandle | null = null;
let nameUrls: string[];
@@ -46,7 +45,7 @@ describe("spawnForm name batch (#53)", () => {
});
async function mount(seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
nameUrls = stubFetch([BATCH_A, BATCH_B]);
nameUrls = stubFetch([BATCH_A]);
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
@@ -55,75 +54,49 @@ describe("spawnForm name batch (#53)", () => {
const nameOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const branchOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!.value;
function reroll(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".reroll")!.click();
}
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
it("seeds its name from a fetched batch when no seed provides one", async () => {
it("leaves the name field empty so the daemon names the session", async () => {
const form = await mount();
expect(BATCH_A).toContain(nameOf(form));
expect(nameUrls).toHaveLength(1);
expect(nameUrls[0]).toContain("/api/names");
expect(nameUrls[0]).toContain("count=");
expect(nameOf(form)).toBe("");
expect(form.querySelector(".reroll")).toBeNull();
});
it("keeps a seeded name and defers fetching until the first reroll", async () => {
it("still seeds the worktree branch with one generated handle", async () => {
const form = await mount();
expect(BATCH_A).toContain(branchOf(form));
expect(nameUrls).toHaveLength(1);
expect(nameUrls[0]).toContain("count=1");
});
it("keeps a name a clone prefilled, and seeds the branch from it", async () => {
const form = await mount({ name: "my-session" });
expect(nameOf(form)).toBe("my-session");
expect(nameUrls ?? []).toHaveLength(0);
reroll(form);
await drain();
expect(nameOf(form)).not.toBe("my-session");
expect(nameUrls).toHaveLength(1);
expect(branchOf(form)).toBe("my-session");
});
it("rerolls locally from the batch with no extra round trips", async () => {
it("seeds the branch from a typed name, overriding the fetched handle", async () => {
const form = await mount();
expect(nameUrls).toHaveLength(1);
const seen = new Set([nameOf(form)]);
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
for (let i = 0; i < BATCH_A.length; i++) {
reroll(form);
seen.add(nameOf(form));
}
await drain();
// Every value came from the one fetched batch.
expect(seen.size).toBeGreaterThanOrEqual(2);
for (const value of seen) {
expect([...BATCH_A, ...BATCH_B]).toContain(value);
}
expect(nameUrls).toHaveLength(2); // refetch fired when the batch ran out
expect(nameUrls[1]).toContain("/api/names");
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("hand-typed");
});
it("refetches only once even if rerolls outpace the network", async () => {
const form = await mount();
// Drain the batch without awaiting between rerolls.
for (let i = 0; i < BATCH_A.length + 1; i++) reroll(form);
await drain();
// One refill in flight covers the whole run — no fetch storm.
expect(nameUrls.length).toBeLessThanOrEqual(2);
});
it("does not clobber an operator-typed name when the batch arrives late", async () => {
it("never overwrites a branch the operator typed", async () => {
nameUrls = stubFetch([BATCH_A]);
handle = createSpawnForm({ onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
// The operator types before the fetch resolves.
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
const branch = form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!;
branch.value = "feat/mine";
branch.dispatchEvent(new Event("input"));
for (let i = 0; i < 40; i++) await Promise.resolve();
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("feat/mine");
});
});
+8
View File
@@ -1200,6 +1200,14 @@ textarea {
white-space: nowrap;
}
/* #131: a name the daemon generated at spawn is provisional — the first prompt
may replace it — so it reads as placeholder text rather than a chosen name.
A session that never prompts simply keeps it, dimmed; that is accurate. */
.leaf-name.is-provisional {
color: var(--muted);
font-weight: 400;
}
/* 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 {
+21
View File
@@ -292,6 +292,27 @@ export function customSessionName(s: Session): string | undefined {
return undefined;
}
/** Where a session's name came from (#131). Mirrors
* `maestro_protocol::NameSource`. */
export type NameSource = "User" | "Generated" | "Haiku";
/** Read `metadata.name_source`, leniently: anything unrecognised — including
* a session named before the field existed — is a settled `User` name, so an
* existing name is never dimmed or overwritten. */
export function nameSource(s: Session): NameSource {
const meta = s.metadata as Record<string, unknown>;
const source = meta?.name_source;
if (source === "Generated" || source === "Haiku") return source;
return "User";
}
/** Whether line 1 renders the name as provisional (#131): a handle the daemon
* generated at spawn and may still replace with one derived from the first
* prompt. See docs/SIDEBAR.md. */
export function isProvisionalName(s: Session): boolean {
return customSessionName(s) !== undefined && nameSource(s) === "Generated";
}
/** Longest session name the daemon stores (#125), in characters. Mirrors
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
export const MAX_SESSION_NAME_LEN = 64;