import { escapeHtml } from "./html.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js"; import { openContextMenu } from "./contextMenu.js"; import { toggleHelp } from "./help.js"; import { CommandSet, defineCommands } from "./commands.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { abbreviateCwd, abbreviateFolderName, blockerStats, customSessionName, formatInTheWay, MAX_SESSION_NAME_LEN, normalizeSessionName, sessionBlockers, isProvisionalName, sessionName, sessionTreePath, } from "./types.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { activityColorSlug, activityDurationLabel, activityHeldMs, META_LINE_MIN_HOLD_MS, } from "./activity.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createPaletteSelect, createThemeToggle } from "./theme.js"; import type { DaemonLink } from "./daemonLink.js"; import { createStatusIndicator } from "./statusIndicator.js"; /** Ask the shell to open the spawn modal. Handled in main.ts, where the * multi-view controller lives; dispatched from every Spawn affordance. */ export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; export function requestOpenSpawn(): void { document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); } /** Ask the shell to open the command bar with an empty query. Dispatched by * the sidebar's search field, which is the touch entry point to the bar * (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone. * Handled in main.ts, which owns the session snapshot the bar needs. */ export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar"; export function requestOpenCommandBar(): void { document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT)); } /** Ask the shell to hand the keyboard back to the focused pane's terminal — * 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"; /** Ask the sidebar to kill a session — the command bar's action page picking * `kill` (#151). Detail is the session. Handled here, so the kill goes * through the same confirm-and-blockers flow the card's ✕ does * (`docs/WORKTREES.md`); the bar never reimplements it. */ export const KILL_SESSION_EVENT = "catalog-kill-session"; /** Ask the sidebar to rename a session — the command bar's action page picking * `rename` (#151). Detail is the session. The prompt is the card's own inline * field, pre-filled with the session's name (`docs/SIDEBAR.md`). */ export const RENAME_SESSION_EVENT = "catalog-rename-session"; /** A session row was clicked (issue #133). The row's `href` drives normal * hash navigation, but a split/drag can move focus without ever touching * `location.hash` — so a click back onto a session whose hash still * matches fires no `hashchange` and the app never hears about it. This * event is the click-driven path that doesn't depend on the hash actually * changing. */ export const SESSION_SELECT_EVENT = "catalog-session-select"; /** The rows a keyboard walk stops on, in visual (document) order. Expanded * folder headers are skipped: a directory is not something to focus, and the * cards under it are one row away anyway. A *collapsed* folder still counts — * its header is the only row it has, so skipping it would leave the folder * unreachable. Same rule as the TUI sidebar. */ const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]'; 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 rail's colour // carries it. It survives only in tooltips/aria labels so meaning is not // colour-only. const STATE_WORDS: Record = { active: "Active", idle: "Idle", "awaiting-input": "Awaiting input", error: "Error", }; /** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge, * full height. The row's own padding (`renderNode`) shifts outward with * nesting depth, carrying the rail with it. */ function activityRailMarkup(slug: string, stateWord: string): string { return ``; } function metaLine(s: Session, now: number): string { const dead = s.status === "Dead"; // #162: known from its sidecar with nothing running behind it — a distinct // fragment, since it never exited. const dormant = s.status === "Dormant"; const blockers = sessionBlockers(s); const slug = activityColorSlug(s.activity ?? "Idle"); const held = activityHeldMs(s.activity_since, now); const duration = held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; const fragments = [ duration ? `${duration}` : "", s.activity_detail ? `${escapeHtml(s.activity_detail)}` : "", commandFragment(s.command), blockers ? blockedFragment(blockers) : "", dead ? `exited` : "", dormant ? `dormant` : "", ]; return `${fragments.join("")}`; } /** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2` * — with the spelled-out breakdown kept in the tooltip. */ function blockedFragment(blockers: InTheWay): string { const stats = blockerStats(blockers) .map((st) => `${escapeHtml(st.text)}`) .join(""); const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`; return `${stats}`; } /** docs/SIDEBAR.md: the card names the program only — arguments never reach * the card. The program becomes its tool icon when we have a mark for it, and * stays as its bare name when we do not. The full command line, arguments and * all, stays in the tooltip either way. */ function commandFragment(command: string): string { const { tool, program } = splitCommand(command); const icon = tool ? toolIconSvg(tool) : null; const text = icon ? "" : program; return `${icon ?? ""}${text ? `${escapeHtml(text)}` : ""}`; } function renderNode(node: TreeNode, depth: number): HTMLElement { if (node.kind === "folder") { const wrap = document.createElement("div"); wrap.className = "tree-folder"; if (node.name) { const header = document.createElement("button"); header.type = "button"; header.className = "tree-folder-header"; header.style.paddingLeft = `${depth * 16}px`; const isCollapsed = collapsed.has(node.path); header.setAttribute("aria-expanded", String(!isCollapsed)); header.dataset.navKey = `folder:${node.path}`; // Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the // glyph and the rotation never fight each other. // A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading // segments to initials (`~/g/n/maestro`) so the dir name stays readable in // a narrow sidebar; the full path lives in the tooltip. header.innerHTML = `▾${abbreviateFolderName(node.name)}`; header.addEventListener("click", () => { if (collapsed.has(node.path)) collapsed.delete(node.path); else collapsed.add(node.path); // re-render handled by caller observing the store; trigger a refresh: document.dispatchEvent(new CustomEvent("catalog-rerender")); }); wrap.appendChild(header); if (isCollapsed) return wrap; } for (const child of node.children) { wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth)); } return wrap; } // The row outlives the snapshot it was built from: a refresh that changes // nothing structural patches it in place rather than replacing it, so the // handlers below have to read the current session, not the one captured here. let cur = node.session; const s = node.session; const now = Date.now(); const row = document.createElement("a"); row.className = "tree-leaf session-card"; row.href = `#/attach/${encodeURIComponent(s.id)}`; row.style.paddingLeft = `${depth * 16 + 8}px`; row.dataset.sessionId = s.id; row.dataset.navKey = `session:${s.id}`; const running = s.status === "Running"; row.dataset.status = running ? "running" : "dead"; const name = sessionName(s); const slug = activityColorSlug(s.activity ?? "Idle"); const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; row.setAttribute( "aria-label", `${name}, ${running ? "running" : "exited"}, ${stateWord}`, ); // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. row.innerHTML = ` ${activityRailMarkup(slug, stateWord)} ${escapeHtml(name)} ${metaLine(s, now)} `; const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); requestKill(cur, sessionName(cur)); }); row.addEventListener("click", () => { document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id })); }); row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); openContextMenu(ev.clientX, ev.clientY, [ { label: "Rename…", onSelect: () => beginRename(row, cur) }, { label: "Spawn similar", onSelect: () => document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })), }, { label: "Kill session", danger: true, onSelect: () => requestKill(cur, sessionName(cur)), }, ]); }); leafPatches.set(row, (next) => { cur = next; const nextSlug = activityColorSlug(next.activity ?? "Idle"); const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle; row.setAttribute( "aria-label", `${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`, ); const rail = row.querySelector(".activity-rail"); if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord); const meta = row.querySelector(".card-line2"); if (meta) meta.outerHTML = metaLine(next, Date.now()); }); return row; } /** Refresh a standing card from a newer snapshot of its session, keeping the * element (and so the pointer's `:hover`) exactly where it is. Registered by * `renderNode`; only the bits `treeSignature` leaves out are rewritten. */ const leafPatches = new WeakMap void>(); /** * Everything about the tree that a patch cannot fix up. * * The daemon re-sends a session on every activity, foreground or cwd shift — * a couple of times a minute per live session — and rebuilding the sidebar for * each one drops `:hover` from the row under the pointer. Refreshes whose * signature is unchanged are patched into the standing rows instead; anything * that moves, renames, appears or dies changes the signature and rebuilds. */ function treeSignature(node: TreeNode): string { if (node.kind === "folder") { const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`; if (node.name && collapsed.has(node.path)) return head; return `${head}(${node.children.map(treeSignature).join(",")})`; } const s = node.session; return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`; } /** * Kill behind a confirmation (#160): a kill is destructive and unattended * agents live in these sessions, so the web asks first, the way the TUI always * has. The daemon decides the rest — it refuses a kill over a worktree holding * unsaved work, and that refusal needs a prompt the confirmation cannot give, * a forced kill. Anything else surfaces in the dialog that asked. */ function requestKill(s: Session, name: string): void { openKillConfirmDialog({ name, blockers: sessionBlockers(s), onKill: async () => { try { await killSession(s.id); } catch (e) { if (e instanceof ApiRequestError && e.code === "kill_guarded") { openForceKillDialog({ name, blockers: e.unsavedWork ?? sessionBlockers(s), reason: e.detail, onForce: () => killSession(s.id, true), }); return; } throw e; } }, }); } /** * 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(); } // The tree has one empty state: no sessions at all. The old "No matches" one // belonged to the inline substring filter, which the command bar replaced // (#157) — the bar's own no-results state took over with it. function renderEmpty(onSpawn: () => void): HTMLElement { const el = document.createElement("div"); el.className = "empty"; el.innerHTML = `
No sessions yet

Start your first session — it'll show up here.

`; const spawn = document.createElement("button"); spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; spawn.addEventListener("click", onSpawn); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; } /** Two-tier sidebar highlight state (issue #33): which sessions are open in some * pane (quiet), and which one is focused (loud). */ export interface SidebarHighlights { open: Set; focused: string | null; } /** Live handle to the persistent sidebar (built once, survives navigation). */ export interface SidebarHandle { /** The `