From 8e8540460f297fa3e16b223328415ebe51201600 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:44:22 +0100 Subject: [PATCH 1/2] feat(maestro): web wire types + client calls for retained worktrees --- web/src/api.ts | 19 +++++++++++++++++++ web/src/routes.ts | 6 ++++++ web/src/types.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/web/src/api.ts b/web/src/api.ts index 89cec3f..b1cd03d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -9,6 +9,7 @@ import type { NameBatch, Projects, RecentDirs, + RetainedWorktree, Session, Uuid, } from "./types.js"; @@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise { return jsonOrError(await fetch(url, { credentials: "same-origin" })); } +/** The daemon's retained worktrees (#80): trees kept after a session ended + * with unsaved work, with what is currently in the way. Drives the + * retained-worktrees view (#84). */ +export async function listWorktrees(): Promise { + return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" })); +} + +/** Reclaim a retained worktree: removes its directory and drops the store + * entry. Never deletes the branch (docs/WORKTREES.md). */ +export async function reclaimWorktree(id: Uuid): Promise { + await jsonOrError( + await fetch(routes.worktree(id), { + method: "DELETE", + credentials: "same-origin", + }), + ); +} + export { eventsWsUrl } from "./routes.js"; diff --git a/web/src/routes.ts b/web/src/routes.ts index 812160a..299812c 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects"; export const GITROOT = "/api/gitroot"; export const NAMES = "/api/names"; export const BRANCHES = "/api/branches"; +export const WORKTREES = "/api/worktrees"; export const EVENTS = "/api/events"; /** Name of the query parameter `DIRS` and `GITROOT` both take. */ @@ -29,6 +30,11 @@ export function attach(id: Uuid): string { return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; } +/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */ +export function worktree(id: Uuid): string { + return `${WORKTREES}/${encodeURIComponent(id)}`; +} + /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; diff --git a/web/src/types.ts b/web/src/types.ts index 11991c3..10a8e43 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -133,6 +133,54 @@ export interface BranchInfo { /** `GET /api/branches?path=` — the local branches of one repository (#55). */ export type Branches = BranchInfo[]; +/** Why a retained worktree outlived its session (#80). New values are + * additive; unknown reasons render generically. */ +export type RetainedReason = "force-killed" | "exited-with-unsaved-work"; + +/** What currently blocks an unforced kill of a worktree's session (#80), + * recomputed by the daemon on every read. All counts default to 0 on the + * wire for older daemons. */ +export interface InTheWay { + uncommitted_files?: number; + untracked_files?: number; + unpushed_commits?: number; +} + +/** One entry of `GET /api/worktrees` (#80): a retained worktree with its + * in-the-way state as of this read. Reclaiming it removes the directory but + * never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */ +export interface RetainedWorktree { + id: Uuid; + path: string; + base_repo: string; + branch: string; + reason: RetainedReason; + retained_at: IsoTimestamp; + in_the_way?: InTheWay; +} + +/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by + * the kill refusal, the TUI and this client, so no two clients can describe + * the same tree differently. Zero parts are omitted; all-zero renders + * "no unsaved work". */ +export function formatInTheWay(w: InTheWay): string { + const parts: string[] = []; + const push = (n: number | undefined, unit: string): void => { + if (!n || n <= 0) return; + parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`); + }; + push(w.uncommitted_files, "uncommitted file"); + push(w.untracked_files, "untracked file"); + push(w.unpushed_commits, "unpushed commit"); + return parts.length > 0 ? parts.join(", ") : "no unsaved work"; +} + +/** Same reason labels the CLI table and the TUI overlay use; unknown future + * reasons render their raw wire value. */ +export function reasonLabel(reason: RetainedReason): string { + return reason; +} + export interface CreateSessionRequest { cwd: string; command: string; From 6f698df9f4e406e8791cf8b23d7a70a7281ef2a0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:44:22 +0100 Subject: [PATCH 2/2] feat(maestro): retained-worktrees view with reclaim (#84) --- web/src/catalog.ts | 9 +++ web/src/main.ts | 37 +++++++++ web/src/styles.css | 145 +++++++++++++++++++++++++++++++++ web/src/worktrees.test.ts | 114 ++++++++++++++++++++++++++ web/src/worktreesView.ts | 166 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 471 insertions(+) create mode 100644 web/src/worktrees.test.ts create mode 100644 web/src/worktreesView.ts diff --git a/web/src/catalog.ts b/web/src/catalog.ts index db6186d..3fad212 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -180,6 +180,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): renderStatus(); const actions = header.querySelector(".app-actions") as HTMLElement; + // Retained worktrees (#84): a section reachable from the header, not a + // sidebar fixture — PRODUCT.md's scan is the product, so this must not + // compete with the session catalog for attention. + const worktreesLink = document.createElement("a"); + worktreesLink.className = "btn btn-ghost"; + worktreesLink.href = "#/worktrees"; + worktreesLink.textContent = "worktrees"; + worktreesLink.title = "Retained worktrees"; + actions.appendChild(worktreesLink); // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by // clicking inside a proxied preview (e.g. crit live) that has no address bar. if (import.meta.env.DEV) { diff --git a/web/src/main.ts b/web/src/main.ts index a89bda9..6b2a26a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,9 @@ import { createSession, fetchNames, getDaemonInfo, + listWorktrees, projects as fetchProjects, + reclaimWorktree, } from "./api.js"; import { buildFastForwardRequest, @@ -22,6 +24,7 @@ import { loadLayout, saveLayout } from "./persistence.js"; import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; +import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -345,16 +348,47 @@ document.addEventListener("keydown", (e) => { type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } + | { kind: "worktrees" } | { kind: "not-found"; raw: string }; function parseRoute(): Route { const hash = location.hash.replace(/^#/, ""); if (hash === "" || hash === "/") return { kind: "catalog" }; + if (hash === "/worktrees") return { kind: "worktrees" }; const m = hash.match(/^\/attach\/([^/?#]+)$/); if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) }; return { kind: "not-found", raw: hash }; } +// Retained worktrees (#84): built lazily on first visit, then kept so a +// revisit re-enters instantly. The daemon's home dir abbreviates paths once +// `/api/info` answers. +let worktreesView: WorktreesViewHandle | undefined; +let homeDir: string | undefined; +void getDaemonInfo() + .then((info) => { + homeDir = info.home_dir; + }) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + +function showWorktrees(): void { + pool.setVisible([]); + pool.setFocus(null); + contentShowsMv = false; + if (!worktreesView) { + worktreesView = createWorktreesView({ + load: listWorktrees, + reclaim: reclaimWorktree, + homeDir: () => homeDir, + }); + } + content.replaceChildren(worktreesView.el); + shellEl.dataset.route = "worktrees"; + void worktreesView.refresh(); +} + function dispatch(): void { const route = parseRoute(); switch (route.kind) { @@ -376,6 +410,9 @@ function dispatch(): void { // tears panes down. showMultiView(); return; + case "worktrees": + showWorktrees(); + return; } } diff --git a/web/src/styles.css b/web/src/styles.css index 86673da..5ed77a1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2225,3 +2225,148 @@ textarea { background: oklch(0 0 0 / 0.5); } } + +/* ---------- retained worktrees (#84) ---------- */ + +.worktrees { + padding: var(--sp-4); + max-width: 960px; + margin: 0 auto; +} + +.wt-head { + margin-bottom: var(--sp-4); +} + +.wt-title { + font-family: var(--font-mono); + font-size: var(--fs-15); + font-weight: 600; + color: var(--ink); + margin: 0 0 var(--sp-1); +} + +.wt-sub { + color: var(--muted); + font-size: var(--fs-13); + margin: 0; +} + +.wt-row { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-2); + border-bottom: 1px solid var(--surface-2); + font-family: var(--font-mono); + font-size: var(--fs-13); + flex-wrap: wrap; +} + +.wt-branch { + color: var(--ink); + font-weight: 600; +} + +.wt-path { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wt-reason { + color: var(--faint); +} + +/* Status never rides on color alone: the label itself says what blocks. */ +.wt-way[data-clear] { + color: var(--faint); +} + +.wt-way:not([data-clear]) { + color: var(--warn); +} + +.wt-row .wt-reclaim-btn { + margin-left: auto; +} + +.wt-row .wt-reclaim-btn::before { + content: "["; + color: var(--danger); +} + +.wt-row .wt-reclaim-btn::after { + content: "]"; + color: var(--danger); +} + +.wt-row .wt-reclaim-btn:hover { + color: var(--danger); + background: var(--danger-tint); +} + +.btn-danger { + color: var(--danger); + font-weight: 600; +} + +.btn-danger:hover { + background: var(--danger-tint); +} + +/* The destructive confirmation, centered over the section. */ +.wt-confirm { + position: fixed; + inset: 0; + z-index: var(--z-backdrop); + display: flex; + align-items: center; + justify-content: center; + background: oklch(0 0 0 / 0.5); + padding: var(--sp-4); +} + +.wt-confirm[hidden] { + display: none; +} + +.wt-confirm-panel { + pointer-events: auto; + background: var(--bg); + border: 1px solid var(--surface-2); + padding: var(--sp-4); + max-width: 480px; + font-family: var(--font-sans); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.wt-confirm-q { + margin: 0; + font-family: var(--font-mono); + font-weight: 600; + color: var(--ink); +} + +.wt-confirm-way { + margin: 0; + font-family: var(--font-mono); + font-size: var(--fs-13); + color: var(--warn); +} + +.wt-confirm-note { + margin: 0; + font-size: var(--fs-13); + color: var(--muted); +} + +.wt-confirm-actions { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + margin-top: var(--sp-2); +} diff --git a/web/src/worktrees.test.ts b/web/src/worktrees.test.ts new file mode 100644 index 0000000..f240877 --- /dev/null +++ b/web/src/worktrees.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; + +import { createWorktreesView } from "./worktreesView.js"; +import type { RetainedWorktree } from "./types.js"; + +function worktree(overrides: Partial): RetainedWorktree { + return { + id: "11111111-2222-3333-4444-555555555555", + path: "/home/naps/tea/maestro/worktrees/feat/login", + base_repo: "/home/naps/tea/maestro", + branch: "feat/login", + reason: "force-killed", + retained_at: "2026-08-24T10:00:00Z", + in_the_way: { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + ...overrides, + }; +} + +/** Mount a view over a scripted store and return (el, reclaimed-ids). */ +function mount(items: RetainedWorktree[], homeDir = "/home/naps") { + const reclaimed: string[] = []; + const view = createWorktreesView({ + load: async () => items, + reclaim: async (id) => { + reclaimed.push(id); + items.splice( + items.findIndex((w) => w.id === id), + 1, + ); + }, + homeDir: () => homeDir, + }); + document.body.appendChild(view.el); + return { view, reclaimed }; +} + +describe("retained-worktrees view (#84)", () => { + it("lists branch, abbreviated path, reason and what is in the way", async () => { + const { view } = mount([ + worktree({ + path: "/home/naps/tea/maestro/worktrees/feat/login", + }), + ]); + await view.refresh(); + const text = view.el.textContent ?? ""; + expect(text).toContain("feat/login"); + // Sidebar-style abbreviation of the path against the daemon's home dir, + // full path kept in the tooltip. + expect(text).toContain("t/m/w/f/login"); + const pathEl = view.el.querySelector(".wt-path")!; + expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login"); + // Same reason labels as `maestro worktrees list` and the TUI overlay. + expect(text).toContain("force-killed"); + // Same phrasing as InTheWay's Display impl — shared with the kill refusal. + expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits"); + }); + + it("says a tree whose work has since been pushed is clear", async () => { + const { view } = mount([worktree({ in_the_way: {} })]); + await view.refresh(); + expect(view.el.textContent).toContain("no unsaved work"); + }); + + it("renders an empty store as a normal state, not an error", async () => { + const { view } = mount([]); + await view.refresh(); + const text = view.el.textContent ?? ""; + expect(text).toContain("no retained worktrees"); + expect(text.toLowerCase()).not.toContain("error"); + expect(text.toLowerCase()).not.toContain("failed"); + }); + + it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => { + const wt = worktree({ branch: "feat/login" }); + const { view, reclaimed } = mount([wt]); + await view.refresh(); + + view.el.querySelector(".wt-reclaim-btn")!.click(); + const confirmText = view.el.querySelector(".wt-confirm")!.textContent ?? ""; + expect(confirmText).toContain("Reclaim worktree feat/login?"); + // Names what will be lost before it happens… + expect(confirmText).toContain( + "2 uncommitted files, 1 untracked file, 3 unpushed commits", + ); + // …and says reclaim does not take the commits too. + expect(confirmText).toContain("never deleted"); + + view.el.querySelector(".wt-confirm-cancel")!.click(); + expect(reclaimed).toEqual([]); + + view.el.querySelector(".wt-reclaim-btn")!.click(); + view.el.querySelector(".wt-confirm-go")!.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]); + // The row is gone once the refresh lands. + expect(view.el.textContent).toContain("no retained worktrees"); + }); + + it("sorts oldest-retained first, like every other client", async () => { + const { view } = mount([ + worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }), + worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }), + ]); + await view.refresh(); + const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent); + expect(branches).toEqual(["older", "newer"]); + }); +}); diff --git a/web/src/worktreesView.ts b/web/src/worktreesView.ts new file mode 100644 index 0000000..2ec67ed --- /dev/null +++ b/web/src/worktreesView.ts @@ -0,0 +1,166 @@ +// Retained-worktrees view (#84): every worktree the daemon is holding that no +// session uses, listed from `GET /api/worktrees` with a reclaim action. +// +// Wording is shared with the CLI (`maestro worktrees list`) and the TUI +// overlay: same reason labels, same in-the-way phrasing via +// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads +// as what it is — destructive — naming what will be lost before it happens +// and saying out loud that the branch survives (docs/WORKTREES.md). + +import { escapeHtml } from "./html.js"; +import { + abbreviateCwd, + abbreviateFolderName, + formatInTheWay, + reasonLabel, + type RetainedWorktree, + type Uuid, +} from "./types.js"; + +export interface WorktreesDeps { + /** `GET /api/worktrees`. */ + load(): Promise; + /** `DELETE /api/worktrees/:id`. */ + reclaim(id: Uuid): Promise; + /** The daemon's home dir, for `~` abbreviation of paths. */ + homeDir(): string | undefined; +} + +export interface WorktreesViewHandle { + el: HTMLElement; + /** Re-fetch the store and re-render. Safe to call repeatedly. */ + refresh(): Promise; + dispose(): void; +} + +/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */ +function sorted(items: RetainedWorktree[]): RetainedWorktree[] { + return [...items].sort( + (a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at), + ); +} + +export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle { + const el = document.createElement("section"); + el.className = "worktrees"; + el.innerHTML = ` +
+

retained worktrees

+

+ Kept when a session ended with unsaved work. Reclaiming removes the + directory only; branches are never deleted. +

+
+
+ + `; + const listHost = el.querySelector(".wt-list")!; + const confirmEl = el.querySelector(".wt-confirm")!; + + let pendingId: string | null = null; + + const render = (items: RetainedWorktree[]) => { + listHost.replaceChildren(); + if (items.length === 0) { + // A drained store is the normal state — say so plainly, not like + // something went wrong. + const empty = document.createElement("div"); + empty.className = "empty"; + empty.innerHTML = ` + +
no retained worktrees
+

Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.

`; + listHost.appendChild(empty); + return; + } + const home = deps.homeDir(); + for (const w of sorted(items)) { + listHost.appendChild(renderRow(w, home)); + } + }; + + const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => { + const row = document.createElement("div"); + row.className = "wt-row"; + const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work"; + row.innerHTML = ` + ${escapeHtml(w.branch)} + ${abbreviateFolderName(abbreviateCwd(w.path, home))} + ${escapeHtml(reasonLabel(w.reason))} + ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))} + + `; + row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => { + showConfirm(w); + }); + return row; + }; + + const showConfirm = (w: RetainedWorktree) => { + pendingId = w.id; + // Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is + // in the way right now, and that the branch survives reclaim. + confirmEl.innerHTML = ` +
+

Reclaim worktree ${escapeHtml(w.branch)}?

+

Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}

+

The directory is removed; the branch is never deleted.

+
+ + +
+
`; + confirmEl.hidden = false; + confirmEl.querySelector(".wt-confirm-go")!.focus(); + }; + + const hideConfirm = () => { + pendingId = null; + confirmEl.hidden = true; + confirmEl.replaceChildren(); + }; + + confirmEl.addEventListener("click", async (e) => { + const target = e.target as HTMLElement; + if (target.closest(".wt-confirm-cancel")) { + hideConfirm(); + return; + } + if (!target.closest(".wt-confirm-go") || !pendingId) return; + const id = pendingId; + hideConfirm(); + try { + await deps.reclaim(id); + } catch (err) { + alert(`Failed to reclaim: ${(err as Error).message}`); + } + await refresh(); + }); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !confirmEl.hidden) hideConfirm(); + }; + document.addEventListener("keydown", onKey); + + const refresh = async () => { + try { + render(await deps.load()); + } catch (err) { + listHost.replaceChildren(); + const fail = document.createElement("div"); + fail.className = "empty"; + fail.innerHTML = `
Couldn't load retained worktrees

`; + fail.querySelector(".empty-hint")!.textContent = (err as Error).message; + listHost.appendChild(fail); + } + }; + + return { + el, + refresh, + dispose() { + document.removeEventListener("keydown", onKey); + el.remove(); + }, + }; +}