diff --git a/web/src/components/sidebar/ActivityRail.tsx b/web/src/components/sidebar/ActivityRail.tsx new file mode 100644 index 0000000..6db0a59 --- /dev/null +++ b/web/src/components/sidebar/ActivityRail.tsx @@ -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 = { + active: "Active", + idle: "Idle", + "awaiting-input": "Awaiting input", + error: "Error", +}; + +/** Rail fill per state, and the motion the state earns. */ +const RAIL_CLASS: Record = { + 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 = { + 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 ( + + ); +} diff --git a/web/src/components/sidebar/ContextMenu.tsx b/web/src/components/sidebar/ContextMenu.tsx new file mode 100644 index 0000000..f945fb1 --- /dev/null +++ b/web/src/components/sidebar/ContextMenu.tsx @@ -0,0 +1,64 @@ +// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar, +// kill. Rendered into 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(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( +
+ {items.map((item) => ( + + ))} +
, + document.body, + ); +} diff --git a/web/src/components/sidebar/FolderNode.tsx b/web/src/components/sidebar/FolderNode.tsx new file mode 100644 index 0000000..647adf0 --- /dev/null +++ b/web/src/components/sidebar/FolderNode.tsx @@ -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 ( +
+ + {!props.collapsed && props.children} +
+ ); +} diff --git a/web/src/components/sidebar/KillConfirm.tsx b/web/src/components/sidebar/KillConfirm.tsx new file mode 100644 index 0000000..8d8be5f --- /dev/null +++ b/web/src/components/sidebar/KillConfirm.tsx @@ -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(null); + useEffect(() => cancel.current?.focus(), []); + + return createPortal( +
{ + if (e.key === "Escape") props.onCancel(); + }} + > +
+

+ {props.force ? "Force kill" : "Kill"} {props.name}? +

+ {props.blockers && ( +

{formatInTheWay(props.blockers)}

+ )} +
+ + +
+
+
, + document.body, + ); +} diff --git a/web/src/components/sidebar/SessionCard.tsx b/web/src/components/sidebar/SessionCard.tsx new file mode 100644 index 0000000..f429b3f --- /dev/null +++ b/web/src/components/sidebar/SessionCard.tsx @@ -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 ( + { + if (renaming) return; + e.preventDefault(); + props.onSelect(); + }} + onContextMenu={(e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + props.onOpenMenu(e.clientX, e.clientY); + }} + > + + + {renaming ? ( + + ) : ( + + {name} + + )} + + + + + + + + ); +} + +/** 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 ( + + {duration && ( + + {duration} + + )} + {s.activity_detail && ( + + {s.activity_detail} + + )} + + {blockers && } + {s.status === "Dead" && exited} + + ); +} + +/** 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 ( + + {icon ? ( + + ) : ( + {program} + )} + + ); +} + +/** 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 ( + + {stats.map((st) => ( + + {st.text} + + ))} + + ); +} + +/** 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(null); + const settled = useRef(false); + + useEffect(() => { + ref.current?.focus(); + ref.current?.select(); + }, []); + + return ( + 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(); + }} + /> + ); +} diff --git a/web/src/components/sidebar/Sidebar.test.tsx b/web/src/components/sidebar/Sidebar.test.tsx new file mode 100644 index 0000000..934cf1d --- /dev/null +++ b/web/src/components/sidebar/Sidebar.test.tsx @@ -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 { + 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(`[data-session-id="${id}"]`); + if (!el) throw new Error(`no card for ${id}`); + return el; +} + +function tree(): HTMLElement { + return document.querySelector(".tree")!; +} + +describe("SessionCard layout", () => { + it("renders two lines: the name, then a meta line naming the program", () => { + render(); + + 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(); + + 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( + , + ); + + const rail = (id: string) => card(id).querySelector("[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( + , + ); + + const blocked = card("alpha").querySelector(".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( + , + ); + + 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(); + + 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(); + + const header = document.querySelector('[data-nav-key="folder:one"]')!; + fireEvent.click(header); + expect(header.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.keyDown(tree(), { key: "j" }); + expect( + document.querySelector('[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(); + + 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(); + + 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(); + + 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( + , + ); + + 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(); + + fireEvent.contextMenu(card("alpha")); + fireEvent.click(screen.getByText("Rename…")); + + const input = card("alpha").querySelector(".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(); + + 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(); + + fireEvent.click(screen.getByText("+ Spawn session")); + expect(onSpawn).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/sidebar/Sidebar.tsx b/web/src/components/sidebar/Sidebar.tsx new file mode 100644 index 0000000..013d3d2 --- /dev/null +++ b/web/src/components/sidebar/Sidebar.tsx @@ -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; + /** 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>(() => new Set()); + const [navKey, setNavKey] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [menu, setMenu] = useState(null); + const [kill, setKill] = useState(null); + const treeRef = useRef(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 ( + 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 ( + toggleFolder(node.path)} + > + {node.children.map((c) => renderNode(c, depth + 1))} + + ); + }; + + return ( + + ); +} + +/** The tree's one empty state: no sessions at all. */ +function EmptyState({ onSpawn }: { onSpawn(): void }) { + return ( +
+ +
No sessions yet
+

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

+ +
+ ); +} + +/** 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 }).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() }; +const EMPTY_STORE = { + getState: () => EMPTY_STATE, + getInitialState: () => EMPTY_STATE, + setState: () => {}, + subscribe: () => () => {}, +} as unknown as CatalogStoreApi; diff --git a/web/src/components/sidebar/SidebarSearch.tsx b/web/src/components/sidebar/SidebarSearch.tsx new file mode 100644 index 0000000..4a6620e --- /dev/null +++ b/web/src/components/sidebar/SidebarSearch.tsx @@ -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 ( +
+ onChange(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Escape") return; + e.preventDefault(); + if (value) onChange(""); + else onDismiss?.(); + }} + /> +
+ ); +} diff --git a/web/src/components/sidebar/index.ts b/web/src/components/sidebar/index.ts new file mode 100644 index 0000000..b7825ae --- /dev/null +++ b/web/src/components/sidebar/index.ts @@ -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"; diff --git a/web/src/components/sidebar/model.test.ts b/web/src/components/sidebar/model.test.ts new file mode 100644 index 0000000..c9ab9c7 --- /dev/null +++ b/web/src/components/sidebar/model.test.ts @@ -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"]); + }); +}); diff --git a/web/src/components/sidebar/model.ts b/web/src/components/sidebar/model.ts new file mode 100644 index 0000000..6a2839c --- /dev/null +++ b/web/src/components/sidebar/model.ts @@ -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): 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, + 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 | 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) + ); + }); +} diff --git a/web/src/components/sidebar/sidebar.css b/web/src/components/sidebar/sidebar.css new file mode 100644 index 0000000..b86cd4d --- /dev/null +++ b/web/src/components/sidebar/sidebar.css @@ -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; + } +} diff --git a/web/src/components/sidebar/useNow.ts b/web/src/components/sidebar/useNow.ts new file mode 100644 index 0000000..7e2e178 --- /dev/null +++ b/web/src/components/sidebar/useNow.ts @@ -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; +}