A host reboot kills the tmux server, so `SessionManager::recover` iterated an empty list and every session vanished from the API while its sidecar sat untouched on disk. A second recovery pass now reads the sidecars tmux had nothing to adopt and registers each as `SessionStatus::Dormant` — a record of what was running, with no pane and no PTY bridge behind it. `SessionStatus` deserializes leniently now, like `ActivityState`: an unknown spelling reads as `Dead` rather than failing the whole `Session`. Refs #162 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
880 lines
36 KiB
TypeScript
880 lines
36 KiB
TypeScript
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<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 rail's colour
|
|
// carries it. It survives only in tooltips/aria labels so meaning is not
|
|
// colour-only.
|
|
const STATE_WORDS: Record<string, string> = {
|
|
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 `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
|
}
|
|
|
|
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 ? `<span class="activity-duration">${duration}</span>` : "",
|
|
s.activity_detail
|
|
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
|
: "",
|
|
commandFragment(s.command),
|
|
blockers ? blockedFragment(blockers) : "",
|
|
dead ? `<span class="card-flag">exited</span>` : "",
|
|
dormant ? `<span class="card-flag">dormant</span>` : "",
|
|
];
|
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
|
}
|
|
|
|
/** 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) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
|
|
.join("");
|
|
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
|
|
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
|
}
|
|
|
|
/** 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 `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
|
}
|
|
|
|
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 = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
|
|
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)}
|
|
<span class="card-line1">
|
|
<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>
|
|
</span>
|
|
</span>
|
|
${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<HTMLElement, (s: Session) => 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<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();
|
|
}
|
|
|
|
// 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 = `
|
|
<div class="empty-glyph" aria-hidden="true">+</div>
|
|
<div class="empty-title">No sessions yet</div>
|
|
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
|
<div class="empty-actions"></div>`;
|
|
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<string>;
|
|
focused: string | null;
|
|
}
|
|
|
|
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
|
export interface SidebarHandle {
|
|
/** The `<aside>` element to mount into the shell. */
|
|
el: HTMLElement;
|
|
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
|
* Back-compat shim over {@link setHighlights}. */
|
|
setActive(id: string | null): void;
|
|
/** Apply the full open/focused highlight state. */
|
|
setHighlights(h: SidebarHighlights): void;
|
|
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
|
* with `null`. A preview is a view, not state, so this only paints. */
|
|
setPreview(sessionId: string | null): void;
|
|
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
|
focusList(): void;
|
|
/** Whether the session list currently holds DOM focus. */
|
|
hasListFocus(): boolean;
|
|
/** Tear down the store subscription + listeners. */
|
|
dispose(): void;
|
|
}
|
|
|
|
/**
|
|
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
|
* live session status line, and the folder-grouped session tree. Unlike
|
|
* the old full-screen catalog this is mounted once by the shell and stays put
|
|
* while the content pane swaps between sessions — so the store subscription and
|
|
* scroll position survive navigation. The active session is highlighted.
|
|
*/
|
|
export interface SidebarOptions {
|
|
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
|
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
|
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
|
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
|
* design kit, which render the sidebar without a daemon. */
|
|
link?: DaemonLink;
|
|
/** The highlight moved onto a session, or off every session (`null`): the
|
|
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
|
* `source` is there so a mouse sweep can be debounced differently from a
|
|
* keyboard step. */
|
|
onPreview?: (sessionId: string | null) => void;
|
|
}
|
|
|
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
|
const el = document.createElement("aside");
|
|
el.className = "sidebar";
|
|
|
|
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
|
|
// renders with absolute paths until it lands.
|
|
let homeDir: string | undefined;
|
|
|
|
// The field never holds a query: it is the touch entry point to the command
|
|
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
|
// on-screen keyboard over a field nothing is typed into.
|
|
const searchHeader = document.createElement("header");
|
|
searchHeader.className = "sidebar-search";
|
|
searchHeader.innerHTML = `
|
|
<input
|
|
type="search"
|
|
class="search"
|
|
readonly
|
|
placeholder="Search sessions…"
|
|
aria-label="Search sessions"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
/>
|
|
`;
|
|
el.appendChild(searchHeader);
|
|
|
|
const treeHost = document.createElement("div");
|
|
treeHost.className = "tree";
|
|
el.appendChild(treeHost);
|
|
|
|
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
|
// rather than the sidebar's own scrolling content, so they stay reachable
|
|
// regardless of tree scroll position.
|
|
const footer = document.createElement("div");
|
|
footer.className = "sidebar-footer";
|
|
const helpBtn = document.createElement("button");
|
|
helpBtn.type = "button";
|
|
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
|
helpBtn.textContent = "?";
|
|
helpBtn.title = "Keyboard shortcuts (?)";
|
|
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
|
helpBtn.addEventListener("click", () => toggleHelp());
|
|
footer.appendChild(helpBtn);
|
|
footer.appendChild(createThemeToggle());
|
|
footer.appendChild(createPaletteSelect());
|
|
// 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) {
|
|
const kit = document.createElement("a");
|
|
kit.className = "btn btn-ghost";
|
|
kit.href = "?kit";
|
|
kit.textContent = "kit";
|
|
kit.title = "Open the design kit (DEV)";
|
|
footer.appendChild(kit);
|
|
}
|
|
// Daemon status sits beside the spawn button: colour for the link state,
|
|
// hover for the release the daemon is running.
|
|
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
|
if (status) footer.appendChild(status.el);
|
|
const spawnBtn = document.createElement("button");
|
|
spawnBtn.type = "button";
|
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
|
spawnBtn.textContent = "+ Spawn";
|
|
// The keyboard pair sits right here in the tooltip (#67).
|
|
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
|
|
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
|
footer.appendChild(spawnBtn);
|
|
el.appendChild(footer);
|
|
|
|
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
|
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
|
treeHost.addEventListener("pointerdown", (e) => {
|
|
if (e.button !== 0 || !opts.onLeafDrag) return;
|
|
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
|
const id = leaf?.dataset.sessionId;
|
|
if (id) opts.onLeafDrag(id, e);
|
|
});
|
|
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
|
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
|
// until release. Suppress it so the pointer-based drag runs from the start.
|
|
treeHost.addEventListener("dragstart", (e) => {
|
|
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
|
});
|
|
|
|
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
|
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
|
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
|
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
|
|
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
|
|
const handle = document.createElement("div");
|
|
handle.className = "sidebar-resize";
|
|
handle.setAttribute("aria-hidden", "true");
|
|
el.appendChild(handle);
|
|
let dragStartX = 0;
|
|
let dragStartW = 0;
|
|
let dragging = false;
|
|
handle.addEventListener("pointerdown", (e) => {
|
|
dragging = true;
|
|
dragStartX = e.clientX;
|
|
dragStartW = el.getBoundingClientRect().width;
|
|
handle.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
e.preventDefault();
|
|
});
|
|
handle.addEventListener("pointermove", (e) => {
|
|
if (!dragging) return;
|
|
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
|
|
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
|
|
});
|
|
const endDrag = (e: PointerEvent) => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
try {
|
|
handle.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// pointer may already be released
|
|
}
|
|
document.body.style.userSelect = "";
|
|
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
|
|
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
|
|
};
|
|
handle.addEventListener("pointerup", endDrag);
|
|
handle.addEventListener("pointercancel", endDrag);
|
|
|
|
// Touch entry (#157): the field is a button that looks like a field. It
|
|
// never holds a query — focusing or tapping it hands the keyboard straight
|
|
// to the bar's own input, which is also why it blurs first: leaving the
|
|
// focus here would make the bar impossible to close without reopening it.
|
|
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
|
const openBar = () => {
|
|
search.value = "";
|
|
search.blur();
|
|
requestOpenCommandBar();
|
|
};
|
|
search.addEventListener("focus", openBar);
|
|
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
|
// the pointer path opens the bar too; the blur above keeps the two from
|
|
// stacking two overlays.
|
|
search.addEventListener("pointerdown", (e) => {
|
|
e.preventDefault();
|
|
openBar();
|
|
});
|
|
|
|
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
|
// The previewed row is painted alongside the open/focused tiers so a
|
|
// re-render can't drop it, but it is never part of `highlights`: a preview
|
|
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
|
let previewing: string | null = null;
|
|
const applyHighlights = () => {
|
|
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
|
const id = n.dataset.sessionId;
|
|
if (!id) continue;
|
|
const focused = highlights.focused === id;
|
|
const open = highlights.open.has(id);
|
|
n.classList.toggle("is-active", focused);
|
|
n.classList.toggle("is-open", open && !focused);
|
|
n.classList.toggle("is-previewing", previewing === id);
|
|
}
|
|
};
|
|
|
|
// ---- keyboard navigation ----
|
|
// The walk reads the rendered DOM rather than a parallel model, so it can
|
|
// never disagree with what is on screen. Rows carry a `data-nav-key`
|
|
// (`session:<id>` / `folder:<path>`) that survives a re-render.
|
|
let navKey: string | null = null;
|
|
|
|
const navRows = (): HTMLElement[] => [
|
|
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
|
|
];
|
|
|
|
const rowFor = (key: string | null): HTMLElement | undefined =>
|
|
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
|
|
|
|
const applyNav = () => {
|
|
const rows = navRows();
|
|
// A row that went away (session killed, folder collapsed) drops the
|
|
// highlight rather than leaving it pointing at nothing.
|
|
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
|
|
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
|
};
|
|
|
|
const sessionOf = (key: string | null): string | null =>
|
|
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
|
|
|
const moveNav = (delta: number) => {
|
|
const rows = navRows();
|
|
if (rows.length === 0) return;
|
|
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
|
|
// Nothing highlighted: start one off the end we are walking from, so the
|
|
// first step lands on the first (or last) row.
|
|
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
|
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
|
navKey = next.dataset.navKey ?? null;
|
|
applyNav();
|
|
next.scrollIntoView({ block: "nearest" });
|
|
opts.onPreview?.(sessionOf(navKey));
|
|
};
|
|
|
|
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
|
const activateNav = () => {
|
|
const row = rowFor(navKey);
|
|
if (!row) return;
|
|
if (row.classList.contains("tree-leaf")) {
|
|
row.click();
|
|
return;
|
|
}
|
|
// Expanding replaces the folder's header in the walk with the rows it was
|
|
// hiding, so land on the first of them instead of losing the highlight.
|
|
const key = navKey;
|
|
row.click();
|
|
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
|
|
(h) => h.dataset.navKey === key,
|
|
);
|
|
const revealed = header?.parentElement
|
|
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
|
|
: undefined;
|
|
navKey = revealed?.dataset.navKey ?? null;
|
|
applyNav();
|
|
};
|
|
|
|
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
|
// out: there is no hover there, and a tap already opens the session.
|
|
let hovered: string | null = null;
|
|
const setHovered = (id: string | null) => {
|
|
if (hovered === id) return;
|
|
hovered = id;
|
|
opts.onPreview?.(id);
|
|
};
|
|
treeHost.addEventListener("pointerover", (e) => {
|
|
if (e.pointerType === "touch") return;
|
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
|
setHovered(row?.dataset.sessionId ?? null);
|
|
});
|
|
treeHost.addEventListener("pointerleave", (e) => {
|
|
if (e.pointerType === "touch") return;
|
|
setHovered(null);
|
|
});
|
|
|
|
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
|
// keyboard stays here while j/k runs.
|
|
treeHost.tabIndex = -1;
|
|
// The keyboard walk's preview belongs to the list having the keyboard: once
|
|
// it doesn't, the content pane goes back to what the operator actually opened.
|
|
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
|
|
|
/** The session behind the highlighted row, if the highlight is on a card. */
|
|
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
|
const row = rowFor(navKey);
|
|
const id = row?.dataset.sessionId;
|
|
if (!row || !id) return undefined;
|
|
const session = store.snapshot().sessions.get(id);
|
|
return session ? { row, session } : undefined;
|
|
};
|
|
|
|
/** `c`: collapse or expand the highlighted folder, the TUI's
|
|
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
|
* the key does nothing rather than toggling some other folder. */
|
|
const toggleHighlightedFolder = () => {
|
|
const row = rowFor(navKey);
|
|
if (!row || !row.classList.contains("tree-folder-header")) return;
|
|
row.click();
|
|
applyNav();
|
|
};
|
|
|
|
// The session list's half of the command registry (#148). One set per
|
|
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
|
// labels and chords still come from the one registry.
|
|
const listKeys = new CommandSet(
|
|
defineCommands({
|
|
"list.next": () => moveNav(1),
|
|
"list.prev": () => moveNav(-1),
|
|
"list.activate": () => activateNav(),
|
|
"list.toggleFolder": () => toggleHighlightedFolder(),
|
|
"list.rename": () => {
|
|
const hit = highlightedSession();
|
|
if (hit) beginRename(hit.row, hit.session);
|
|
},
|
|
"list.kill": () => {
|
|
const hit = highlightedSession();
|
|
if (hit) requestKill(hit.session, sessionName(hit.session));
|
|
},
|
|
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
|
}),
|
|
);
|
|
|
|
// The inline rename field lives inside a row and owns its own keys; the set's
|
|
// plain-key rule keeps this walk out of it.
|
|
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
|
|
|
|
/** Signature of what is currently on screen — see `treeSignature`. */
|
|
let renderedSignature: string | null = null;
|
|
|
|
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);
|
|
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
|
if (signature === renderedSignature) {
|
|
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
|
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
|
if (s) leafPatches.get(row)?.(s);
|
|
}
|
|
return;
|
|
}
|
|
renderedSignature = signature;
|
|
treeHost.replaceChildren();
|
|
if (sessions.length === 0) {
|
|
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
|
return;
|
|
}
|
|
if (tree.kind === "folder") {
|
|
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
|
}
|
|
applyHighlights();
|
|
applyNav();
|
|
};
|
|
|
|
void getDaemonInfo()
|
|
.then((info) => {
|
|
homeDir = info.home_dir;
|
|
refresh();
|
|
})
|
|
.catch(() => {
|
|
/* absolute paths are a fine fallback */
|
|
});
|
|
|
|
// Dispatched when something outside the store changed what the tree should
|
|
// look like — a folder collapsed, a rename ended — none of which the store
|
|
// snapshot can show, so the signature check has to be bypassed.
|
|
const rerenderHandler = () => {
|
|
renderedSignature = null;
|
|
refresh();
|
|
};
|
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
|
|
|
// The command bar's action page (#151) picks an action and hands it here:
|
|
// both flows are the ones the card already has, reached by session rather
|
|
// than by row.
|
|
const killHandler = (ev: Event) => {
|
|
const s = (ev as CustomEvent<Session>).detail;
|
|
requestKill(s, sessionName(s));
|
|
};
|
|
const renameHandler = (ev: Event) => {
|
|
const s = (ev as CustomEvent<Session>).detail;
|
|
const row = rowForSession(s);
|
|
if (row) beginRename(row, s);
|
|
};
|
|
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
|
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
|
|
|
/** The card a session is rendered as, expanding whatever folder hides it
|
|
* first: the rename prompt lives inside the card, so a collapsed folder
|
|
* would otherwise swallow the request. */
|
|
function rowForSession(s: Session): HTMLElement | null {
|
|
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
|
const found = find();
|
|
if (found) return found;
|
|
// Folder paths are the abbreviated directory's segments joined back up
|
|
// (see buildTree), so the session's own path is normalised the same way
|
|
// before it is compared against them.
|
|
const cwd = sessionTreePath(s);
|
|
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
|
for (const folder of [...collapsed]) {
|
|
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
|
}
|
|
rerenderHandler();
|
|
return find();
|
|
}
|
|
|
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
|
// multi-view controller can seed the spawn modal with the session's dir/command.
|
|
|
|
const unsubscribe = store.subscribe(refresh);
|
|
|
|
// Contract granularity: re-render at least every 30s while visible so the
|
|
// duration cutover points land within one tick.
|
|
const clock = setInterval(() => {
|
|
if (document.visibilityState !== "visible") return;
|
|
refresh();
|
|
}, 30_000);
|
|
|
|
return {
|
|
el,
|
|
setActive(id: string | null) {
|
|
highlights = {
|
|
open: id ? new Set([id]) : new Set(),
|
|
focused: id,
|
|
};
|
|
applyHighlights();
|
|
},
|
|
setHighlights(h: SidebarHighlights) {
|
|
highlights = h;
|
|
applyHighlights();
|
|
},
|
|
setPreview(sessionId: string | null) {
|
|
previewing = sessionId;
|
|
applyHighlights();
|
|
},
|
|
focusList() {
|
|
// Start from the session on screen — that is where the user's attention
|
|
// already is — falling back to the standing highlight, then the top row.
|
|
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
|
|
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
|
|
navKey = start?.dataset.navKey ?? null;
|
|
applyNav();
|
|
treeHost.focus();
|
|
start?.scrollIntoView({ block: "nearest" });
|
|
},
|
|
hasListFocus() {
|
|
return treeHost.contains(document.activeElement);
|
|
},
|
|
dispose() {
|
|
clearInterval(clock);
|
|
status?.dispose();
|
|
// An unfinished rename would otherwise leave every later sidebar
|
|
// refusing to refresh.
|
|
renamingId = null;
|
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
|
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
|
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
|
unsubscribe();
|
|
},
|
|
};
|
|
}
|