Compare commits

...
Author SHA1 Message Date
Miguel Palhas e8e98baf4d feat(maestro): port the sidebar to React
Ports catalog.ts's tree renderer to components under
src/components/sidebar/, following docs/SIDEBAR.md: two-line cards
behind a 5px activity rail, adaptive meta line (duration, detail,
program, diff stat), collapsible folders, j/k walk, inline rename and a
right-click menu.

The nav walk reads a pure model (model.ts) rather than the DOM, so the
rule that an expanded folder header is skipped and a collapsed one is
not is testable without a render. Rail motion needs @keyframes, so it
stays in sidebar.css rather than the Tailwind config.

Closes #244
2026-09-09 00:21:13 +01:00
13 changed files with 1343 additions and 0 deletions
@@ -0,0 +1,47 @@
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
// corners, full card height. It paints for every card, Idle included, and it
// carries the accessible name of the state so status is never colour-only.
import { activityColorSlug } from "../../activity.js";
import type { ActivityState } from "../../types.js";
export const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
/** Rail fill per state, and the motion the state earns. */
const RAIL_CLASS: Record<string, string> = {
active: "bg-live",
idle: "bg-border-strong",
"awaiting-input": "bg-warn rail-blink",
error: "bg-danger rail-flash",
};
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
export const DURATION_CLASS: Record<string, string> = {
active: "text-live",
idle: "text-muted",
"awaiting-input": "text-warn",
error: "text-danger",
};
export function stateWordFor(activity: ActivityState | string | undefined): string {
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
}
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
const slug = activityColorSlug(activity ?? "Idle");
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
return (
<span
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
data-activity={slug}
role="img"
title={word}
aria-label={word}
/>
);
}
@@ -0,0 +1,64 @@
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
danger?: boolean;
onSelect(): void;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown, true);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown, true);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: x, top: y }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "text-danger" : "text-ink"
}`}
onClick={() => {
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+51
View File
@@ -0,0 +1,51 @@
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
// of the cards it holds, so it carries no highlight of its own.
import type { ReactNode } from "react";
import { splitFolderLabel } from "./model.js";
export interface FolderNodeProps {
name: string;
path: string;
depth: number;
collapsed: boolean;
highlighted: boolean;
onToggle(): void;
children: ReactNode;
}
export function FolderNode(props: FolderNodeProps) {
const { stem, last } = splitFolderLabel(props.name);
return (
<div className="tree-folder">
<button
type="button"
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
props.highlighted ? "ring-1 ring-accent-ring" : ""
}`}
style={{ paddingLeft: props.depth * 16 }}
aria-expanded={!props.collapsed}
data-nav-key={`folder:${props.path}`}
data-highlighted={props.highlighted ? "true" : undefined}
title={props.name}
onClick={props.onToggle}
>
{/* One glyph, rotated when collapsed, so the mark and the rotation
never disagree about which way the folder points. */}
<span
aria-hidden="true"
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
>
▾
</span>
<span className="truncate">
{stem && <span className="text-faint">{stem}/</span>}
{last}
</span>
</button>
{!props.collapsed && props.children}
</div>
);
}
@@ -0,0 +1,63 @@
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
// confirmation names what the card last saw in the way — the daemon refuses an
// unforced kill over such a tree, and saying so beforehand is cheaper than the
// refusal. Cancel holds the focus so a stray Enter kills nothing.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { InTheWay } from "../../types.js";
import { formatInTheWay } from "../../types.js";
export interface KillConfirmProps {
name: string;
blockers: InTheWay | null;
/** Set when the daemon already refused an unforced kill: this pass forces. */
force?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function KillConfirm(props: KillConfirmProps) {
const cancel = useRef<HTMLButtonElement>(null);
useEffect(() => cancel.current?.focus(), []);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={props.force ? "Force kill session" : "Kill session"}
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onKeyDown={(e) => {
if (e.key === "Escape") props.onCancel();
}}
>
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<p className="text-fs-13 text-ink">
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
</p>
{props.blockers && (
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
)}
<div className="mt-sp-3 flex justify-end gap-sp-2">
<button
ref={cancel}
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={props.onCancel}
>
Cancel
</button>
<button
type="button"
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={props.onConfirm}
>
{props.force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+264
View File
@@ -0,0 +1,264 @@
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
// adaptive meta line — with the activity rail in the gutter beside them.
import { useEffect, useRef, type MouseEvent } from "react";
import {
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "../../activity.js";
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
import type { InTheWay, Session } from "../../types.js";
import {
blockerStats,
customSessionName,
formatInTheWay,
isProvisionalName,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
sessionName,
} from "../../types.js";
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
import { activityColorSlug } from "../../activity.js";
export interface SessionCardProps {
session: Session;
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
depth: number;
/** Clock the duration is measured against — the sidebar ticks it. */
now: number;
highlighted: boolean;
/** Showing in the focused pane. */
active: boolean;
/** Open in some pane, not the focused one. */
open: boolean;
previewing: boolean;
renaming: boolean;
onSelect(): void;
onOpenMenu(x: number, y: number): void;
onSpawnSimilar(): void;
onKill(): void;
onRenameCommit(name: string): void;
onRenameCancel(): void;
}
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
* wins. Written as one ordered run so the precedence is the order it reads in. */
function rowFill(p: SessionCardProps): string {
if (p.active) return "bg-accent-fill";
if (p.previewing) return "bg-preview-tint";
if (p.open) return "bg-accent/[0.06]";
if (p.highlighted) return "bg-preview-tint";
return "";
}
export function SessionCard(props: SessionCardProps) {
const { session: s, depth, now, renaming } = props;
const name = sessionName(s);
const running = s.status === "Running";
const word = stateWordFor(s.activity);
return (
<a
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
style={{ paddingLeft: depth * 16 + 8 }}
data-session-id={s.id}
data-nav-key={`session:${s.id}`}
data-status={running ? "running" : "dead"}
data-highlighted={props.highlighted ? "true" : undefined}
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
onClick={(e) => {
if (renaming) return;
e.preventDefault();
props.onSelect();
}}
onContextMenu={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
props.onOpenMenu(e.clientX, e.clientY);
}}
>
<ActivityRail activity={s.activity} />
<span className="flex min-w-0 items-center gap-sp-2">
{renaming ? (
<RenameField
session={s}
onCommit={props.onRenameCommit}
onCancel={props.onRenameCancel}
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-fs-13 ${
isProvisionalName(s) ? "text-muted" : "text-ink"
}`}
>
{name}
</span>
)}
<span className="flex flex-none items-center gap-[2px]">
<button
type="button"
className="leaf-clone text-faint hover:text-ink"
title="Spawn similar"
aria-label={`Spawn similar to ${name}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onSpawnSimilar();
}}
>
⧉
</button>
<button
type="button"
className="leaf-kill text-faint hover:text-danger"
title="Kill session"
aria-label={`Kill ${name}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onKill();
}}
>
✕
</button>
</span>
</span>
<MetaLine session={s} now={now} />
</a>
);
}
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
* The line itself is unconditional; only its fragments are adaptive. */
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
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 blockers = sessionBlockers(s);
return (
<span
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
data-activity={slug}
>
{duration && (
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
{duration}
</span>
)}
{s.activity_detail && (
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
{s.activity_detail}
</span>
)}
<CommandFragment command={s.command} />
{blockers && <BlockedFragment blockers={blockers} />}
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
</span>
);
}
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
* have one. The full command line stays in the tooltip either way. */
function CommandFragment({ command }: { command: string }) {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
return (
<span
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
title={command}
>
{icon ? (
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
) : (
<span className="truncate">{program}</span>
)}
</span>
);
}
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
* colour; the spelled-out breakdown survives as the tooltip. */
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
const stats = blockerStats(blockers);
return (
<span
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
>
{stats.map((st) => (
<span
key={st.kind}
data-stat={st.kind}
className={
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
}
>
{st.text}
</span>
))}
</span>
);
}
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
* field clears the name so the command fallback resumes. It starts from the
* session's own name, never from the command it falls back to. */
function RenameField({
session: s,
onCommit,
onCancel,
}: {
session: Session;
onCommit(name: string): void;
onCancel(): void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
return (
<input
ref={ref}
type="text"
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
defaultValue={customSessionName(s) ?? ""}
maxLength={MAX_SESSION_NAME_LEN}
aria-label={`Rename ${sessionName(s)}`}
// The card is an <a> the tree also drags from: without these, clicking
// into the field navigates away or starts a drag.
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
if (settled.current) return;
settled.current = true;
onCommit(normalizeSessionName(e.currentTarget.value));
} else if (e.key === "Escape") {
e.preventDefault();
if (settled.current) return;
settled.current = true;
onCancel();
}
}}
onBlur={() => {
if (settled.current) return;
settled.current = true;
onCancel();
}}
/>
);
}
+227
View File
@@ -0,0 +1,227 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Session } from "../../types.js";
import { Sidebar } from "./Sidebar.js";
afterEach(() => {
cleanup();
vi.useRealTimers();
});
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/n/alpha",
command: "hx src/",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...over,
} as Session;
}
/** The card element for a session, by the `data-session-id` the row carries. */
function card(id: string): HTMLElement {
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
if (!el) throw new Error(`no card for ${id}`);
return el;
}
function tree(): HTMLElement {
return document.querySelector<HTMLElement>(".tree")!;
}
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
const row = card("alpha");
expect(row.textContent).toContain("alpha");
const meta = row.querySelector(".card-line2")!;
// The program only — never its arguments.
expect(meta.textContent).toContain("hx");
expect(meta.textContent).not.toContain("src/");
});
it("shows no duration under the 30s hold, and one above it", () => {
const now = Date.parse("2026-08-24T10:00:00Z");
vi.setSystemTime(now);
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
render(<Sidebar sessions={[fresh, held]} />);
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
});
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
render(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
expect(rail("idle").dataset.activity).toBe("idle");
expect(rail("idle").className).not.toContain("rail-");
expect(rail("await").className).toContain("rail-blink");
expect(rail("err").className).toContain("rail-flash");
// Status never rests on colour alone.
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
});
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
render(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
expect(blocked.textContent).toBe("+124-18?3");
expect(blocked.title).toContain("2 uncommitted files");
});
it("dims a provisional name and leaves a settled one alone", () => {
render(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
);
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
expect(card("user").textContent).toContain("user");
});
});
describe("keyboard walk", () => {
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
it("j and k step the highlight through the cards", () => {
render(<Sidebar sessions={two} />);
fireEvent.keyDown(tree(), { key: "j" });
expect(card("a").dataset.highlighted).toBe("true");
fireEvent.keyDown(tree(), { key: "j" });
expect(card("b").dataset.highlighted).toBe("true");
fireEvent.keyDown(tree(), { key: "k" });
expect(card("a").dataset.highlighted).toBe("true");
});
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
render(<Sidebar sessions={two} />);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
expect(header.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(tree(), { key: "j" });
expect(
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
).toBe("true");
fireEvent.keyDown(tree(), { key: "Enter" });
expect(card("a").dataset.highlighted).toBe("true");
});
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
const onSelect = vi.fn();
const onFocusPane = vi.fn();
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith("a");
fireEvent.keyDown(tree(), { key: "Tab" });
expect(onFocusPane).toHaveBeenCalled();
});
it("previews the session the walk lands on", () => {
const onPreview = vi.fn();
render(<Sidebar sessions={two} onPreview={onPreview} />);
fireEvent.keyDown(tree(), { key: "j" });
expect(onPreview).toHaveBeenCalledWith("a");
});
it("x asks to kill the highlighted session and r renames it", () => {
const onKill = vi.fn();
render(<Sidebar sessions={two} onKill={onKill} />);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "x" });
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
fireEvent.keyDown(tree(), { key: "r" });
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
});
});
describe("context menu", () => {
it("offers rename, spawn similar and kill", () => {
const onSpawnSimilar = vi.fn();
const onKill = vi.fn();
render(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
);
fireEvent.contextMenu(card("alpha"));
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
fireEvent.click(screen.getByText("Spawn similar"));
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
});
it("renames in place: Enter commits the field, Escape cancels", () => {
const onRename = vi.fn();
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
expect(input.value).toBe("alpha");
fireEvent.change(input, { target: { value: " renamed " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
});
});
describe("search", () => {
it("filters the tree down to matching sessions", () => {
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
});
});
describe("empty state", () => {
it("offers a spawn when there are no sessions at all", () => {
const onSpawn = vi.fn();
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
});
});
+368
View File
@@ -0,0 +1,368 @@
// React port of the sidebar tree (catalog.ts). The contract it renders is
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
// repository, walked with j/k.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import { ApiRequestError, killSession, renameSession } from "../../api.js";
import type { CatalogStoreApi } from "../../stores/catalogStore.js";
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
import { FolderNode } from "./FolderNode.js";
import { KillConfirm } from "./KillConfirm.js";
import { SessionCard } from "./SessionCard.js";
import { SidebarSearch } from "./SidebarSearch.js";
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
import { useNow } from "./useNow.js";
import "./sidebar.css";
export interface SidebarProps {
/** Live catalog. Either this or `sessions` supplies the tree. */
store?: CatalogStoreApi;
/** Static session list — what tests and the design kit render from. */
sessions?: Session[];
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
homeDir?: string;
/** Showing in the focused pane. */
activeId?: string | null;
/** Open in some pane, focused one included. */
openIds?: ReadonlySet<string>;
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
previewId?: string | null;
onSelect?(id: string): void;
/** The highlight moved onto a session, or off every session. */
onPreview?(id: string | null): void;
onSpawnSimilar?(session: Session): void;
onSpawn?(): void;
/** Fast-forward spawn (`N`). */
onSpawnDefaults?(): void;
/** Tab: hand the keyboard back to the pane. */
onFocusPane?(): void;
onHelp?(): void;
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
onKill?(session: Session): void;
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
onRename?(id: string, name: string): void;
}
interface MenuState {
x: number;
y: number;
session: Session;
}
interface KillState {
session: Session;
blockers: InTheWay | null;
force: boolean;
}
export function Sidebar(props: SidebarProps) {
const sessions = useSessions(props);
const now = useNow();
const [query, setQuery] = useState("");
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
const [navKey, setNavKey] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [menu, setMenu] = useState<MenuState | null>(null);
const [kill, setKill] = useState<KillState | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const tree = useMemo(
() => buildTree(filterSessions(sessions, query), props.homeDir),
[sessions, query, props.homeDir],
);
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
// A row that went away — session killed, folder collapsed over it — drops
// the highlight rather than leaving it pointing at nothing.
useEffect(() => {
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
}, [rows, navKey]);
const preview = props.onPreview;
const previewOf = useCallback(
(key: string | null) => {
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
},
[preview],
);
const moveNav = useCallback(
(delta: number) => {
if (rows.length === 0) return;
const cur = rows.findIndex((r) => r.key === navKey);
// Nothing highlighted: start one off the end being walked 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];
setNavKey(next.key);
previewOf(next.key);
},
[rows, navKey, previewOf],
);
const toggleFolder = useCallback((path: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
/** Enter: open the highlighted session, or expand the highlighted folder and
* land on the first row it was hiding. */
const activateNav = useCallback(() => {
const row = rows.find((r) => r.key === navKey);
if (!row) return;
if (row.kind === "session") {
props.onSelect?.(row.id);
return;
}
const expanded = new Set(collapsed);
expanded.delete(row.path);
setCollapsed(expanded);
const revealed = firstRowUnder(tree, expanded, row.path);
setNavKey(revealed?.key ?? null);
}, [rows, navKey, collapsed, tree, props]);
const beginKill = useCallback(
(s: Session) => {
if (props.onKill) {
props.onKill(s);
return;
}
setKill({ session: s, blockers: sessionBlockers(s), force: false });
},
[props],
);
const commitRename = useCallback(
(id: string, name: string) => {
setRenamingId(null);
if (props.onRename) props.onRename(id, name);
else void renameSession(id, name).catch(() => {});
},
[props],
);
/** The session behind the highlighted row, when the highlight is on a card. */
const highlightedSession = useCallback((): Session | undefined => {
const row = rows.find((r) => r.key === navKey);
if (row?.kind !== "session") return undefined;
return sessions.find((s) => s.id === row.id);
}, [rows, navKey, sessions]);
const onKeyDown = (e: React.KeyboardEvent) => {
// The inline rename field owns its own keys; the walk stays out of it.
if (e.target instanceof HTMLInputElement) return;
switch (e.key) {
case "j":
case "ArrowDown":
e.preventDefault();
moveNav(1);
return;
case "k":
case "ArrowUp":
e.preventDefault();
moveNav(-1);
return;
case "Enter":
e.preventDefault();
activateNav();
return;
case "Tab":
e.preventDefault();
props.onFocusPane?.();
return;
case "c": {
const row = rows.find((r) => r.key === navKey);
if (row?.kind !== "folder") return;
e.preventDefault();
toggleFolder(row.path);
return;
}
case "r": {
const s = highlightedSession();
if (!s) return;
e.preventDefault();
setRenamingId(s.id);
return;
}
case "x": {
const s = highlightedSession();
if (!s) return;
e.preventDefault();
beginKill(s);
return;
}
case "n":
e.preventDefault();
props.onSpawn?.();
return;
case "N":
e.preventDefault();
props.onSpawnDefaults?.();
return;
case "?":
e.preventDefault();
props.onHelp?.();
return;
}
};
const menuItems = (s: Session): ContextMenuItem[] => [
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
];
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
if (node.kind === "leaf") {
const s = node.session;
return (
<SessionCard
key={s.id}
session={s}
depth={depth}
now={now}
highlighted={navKey === sessionKey(s.id)}
active={props.activeId === s.id}
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
previewing={props.previewId === s.id && props.activeId !== s.id}
renaming={renamingId === s.id}
onSelect={() => props.onSelect?.(s.id)}
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
onKill={() => beginKill(s)}
onRenameCommit={(name) => commitRename(s.id, name)}
onRenameCancel={() => setRenamingId(null)}
/>
);
}
if (!node.name) {
return node.children.map((c) => renderNode(c, depth));
}
return (
<FolderNode
key={node.path}
name={node.name}
path={node.path}
depth={depth}
collapsed={collapsed.has(node.path)}
highlighted={navKey === `folder:${node.path}`}
onToggle={() => toggleFolder(node.path)}
>
{node.children.map((c) => renderNode(c, depth + 1))}
</FolderNode>
);
};
return (
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
<SidebarSearch
value={query}
onChange={setQuery}
onDismiss={() => treeRef.current?.focus()}
/>
<div
ref={treeRef}
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
tabIndex={-1}
role="tree"
aria-label="Sessions"
onKeyDown={onKeyDown}
// The keyboard walk's preview belongs to the list holding the
// keyboard: once it doesn't, the pane goes back to what was opened.
onBlur={() => preview?.(null)}
>
{sessions.length === 0 ? (
<EmptyState onSpawn={() => props.onSpawn?.()} />
) : (
renderNode(tree, 0)
)}
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
items={menuItems(menu.session)}
onClose={() => setMenu(null)}
/>
)}
{kill && (
<KillConfirm
name={sessionName(kill.session)}
blockers={kill.blockers}
force={kill.force}
onCancel={() => setKill(null)}
onConfirm={() => {
const { session: s, force } = kill;
setKill(null);
void killSession(s.id, force).catch((e: unknown) => {
// The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md).
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
setKill({
session: s,
blockers: e.unsavedWork ?? sessionBlockers(s),
force: true,
});
}
});
}}
/>
)}
</aside>
);
}
/** The tree's one empty state: no sessions at all. */
function EmptyState({ onSpawn }: { onSpawn(): void }) {
return (
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
<div aria-hidden="true" className="text-fs-20 text-faint">
+
</div>
<div className="text-fs-13 text-ink">No sessions yet</div>
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
<button
type="button"
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={onSpawn}
>
+ Spawn session
</button>
</div>
);
}
/** Sessions from the store when one is wired, else the static prop. */
function useSessions(props: SidebarProps): Session[] {
const fromStore = useStore(
// `useStore` needs a store every render; the no-store case is served by a
// stable empty stand-in whose result is thrown away below.
props.store ?? EMPTY_STORE,
(s) => (s as { sessions: Map<string, Session> }).sessions,
);
return useMemo(
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
[props.store, props.sessions, fromStore],
);
}
// One frozen state object, not a fresh one per call: `useStore` reads the
// snapshot on every render and a new Map each time would never settle.
const EMPTY_STATE = { sessions: new Map<string, Session>() };
const EMPTY_STORE = {
getState: () => EMPTY_STATE,
getInitialState: () => EMPTY_STATE,
setState: () => {},
subscribe: () => () => {},
} as unknown as CatalogStoreApi;
@@ -0,0 +1,34 @@
// The sidebar's search field: a substring filter over the tree (name, command
// and cwd). Escape clears it and hands the keyboard back to the list.
export interface SidebarSearchProps {
value: string;
onChange(value: string): void;
/** Escape on an already-empty field — the list wants the keyboard back. */
onDismiss?(): void;
}
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
return (
<header className="sidebar-search p-sp-2">
<input
type="search"
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
placeholder="Search sessions…"
aria-label="Search sessions"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Escape") return;
e.preventDefault();
if (value) onChange("");
else onDismiss?.();
}}
/>
</header>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { Sidebar, type SidebarProps } from "./Sidebar.js";
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
export { ActivityRail } from "./ActivityRail.js";
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
export { KillConfirm } from "./KillConfirm.js";
export * from "./model.js";
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { buildTree } from "../../tree.js";
import type { Session } from "../../types.js";
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
function session(id: string, cwd: string, command = "claude"): Session {
return {
id,
cwd,
command,
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
describe("navRows", () => {
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
it("stops on every card and skips expanded folder headers", () => {
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
});
it("stops on a collapsed folder header instead of the cards it hides", () => {
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
"folder:one",
"session:b",
]);
});
it("names the first row an expanded folder reveals", () => {
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
});
});
describe("splitFolderLabel", () => {
it("keeps a shallow path whole", () => {
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
});
it("initials the leading segments of a deep path", () => {
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
stem: "~/g/n",
last: "maestro",
});
});
});
describe("filterSessions", () => {
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
it("returns everything for a blank query", () => {
expect(filterSessions(all, " ")).toHaveLength(2);
});
it("matches the cwd and the command, case-insensitively", () => {
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
});
});
+90
View File
@@ -0,0 +1,90 @@
// Pure model helpers behind the React sidebar. Kept out of the components so
// the walk order and the folder label can be tested without a DOM.
import type { Session } from "../../types.js";
import { sessionCwd, sessionName } from "../../types.js";
import type { TreeNode } from "../../tree.js";
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
* every session card, and a folder header only while it is collapsed. */
export type NavRow =
| { key: string; kind: "folder"; path: string }
| { key: string; kind: "session"; id: string };
export function folderKey(path: string): string {
return `folder:${path}`;
}
export function sessionKey(id: string): string {
return `session:${id}`;
}
/** The walkable rows in visual order. An expanded folder contributes nothing
* itself — its cards are one row away — while a collapsed one is the only row
* it has, so skipping it would leave the folder unreachable. */
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
const out: NavRow[] = [];
const walk = (node: TreeNode): void => {
if (node.kind === "leaf") {
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
return;
}
if (node.name && collapsed.has(node.path)) {
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
return;
}
for (const child of node.children) walk(child);
};
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
return out;
}
/** The first walkable row inside `path`, i.e. where the highlight lands when
* Enter expands that folder. */
export function firstRowUnder(
root: TreeNode,
collapsed: ReadonlySet<string>,
path: string,
): NavRow | undefined {
const found = findFolder(root, path);
if (!found) return undefined;
return navRows({ ...found, name: "" }, collapsed)[0];
}
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
if (node.kind === "leaf") return null;
if (node.path === path && node.name) return node;
for (const child of node.children) {
const hit = findFolder(child, path);
if (hit) return hit;
}
return null;
}
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
* segments as initials so the tail stays readable in a narrow sidebar. */
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
const parts = name.split("/");
if (parts.length <= 2) return { stem: null, last: name };
const last = parts[parts.length - 1];
const stem = parts
.slice(0, -1)
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
.join("/");
return { stem, last };
}
/** Case-insensitive substring filter over a session's name, command and cwd —
* what the sidebar's own search field narrows the tree with. */
export function filterSessions(sessions: Session[], query: string): Session[] {
const q = query.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter((s) => {
const cwd = sessionCwd(s) ?? "";
return (
sessionName(s).toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q) ||
cwd.toLowerCase().includes(q)
);
});
}
+49
View File
@@ -0,0 +1,49 @@
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
AwaitingInput, a fast flash while Error. Active and Idle are solid and
still — colour alone is the signal. Kept as real CSS rather than Tailwind
utilities because both need @keyframes of their own. */
@keyframes maestro-rail-blink {
0%,
50% {
opacity: 1;
}
50.01%,
100% {
opacity: 0;
}
}
@keyframes maestro-rail-flash {
0%,
100% {
opacity: 1;
}
15% {
opacity: 0.25;
}
30% {
opacity: 1;
}
45% {
opacity: 0.25;
}
60% {
opacity: 1;
}
}
.rail-blink {
animation: maestro-rail-blink 1.2s steps(1) infinite;
}
.rail-flash {
animation: maestro-rail-flash 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rail-blink,
.rail-flash {
animation: none;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/** A clock that ticks at least every `intervalMs` while the page is visible.
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
* cutover points land within one tick. */
export function useNow(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => {
if (document.visibilityState !== "visible") return;
setNow(Date.now());
}, intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}