diff --git a/experimental/papyrus/client/src/components/NewSessionMenu.test.tsx b/experimental/papyrus/client/src/components/NewSessionMenu.test.tsx index f755aab..d9bfd0a 100644 --- a/experimental/papyrus/client/src/components/NewSessionMenu.test.tsx +++ b/experimental/papyrus/client/src/components/NewSessionMenu.test.tsx @@ -1,62 +1,65 @@ -// UNIT test (mine to maintain — see ../../../TESTING.md): the variant picker lists -// one row per agent plus a Plain shell, and each row picks its variant then closes. -import { describe, test, expect, mock } from "bun:test"; +// UNIT test (mine to maintain — see ../../../TESTING.md): the menu renders one row per +// session kind from silverwood's schema (a required bool option becomes two rows), and each +// row picks its { kind, options } then closes. The /api/session-schema fetch is stubbed to +// reject, so the menu falls back to FALLBACK_SESSION_SCHEMA (deterministic, no network). +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; import { render, screen, fireEvent } from "@testing-library/react"; import { NewSessionMenu } from "./NewSessionMenu"; -import type { Agent } from "../stores/useStore"; - -const agents: Agent[] = [ - { - id: "claude", - name: "Claude Code", - command: "claude", - description: "Anthropic's official CLI for Claude", - color: "#F97316", - icon: "sparkles", - }, -]; - -// A minimal anchor rect; only bottom/left are read for positioning. + const anchor = { bottom: 100, left: 40 } as DOMRect; +let originalFetch: typeof globalThis.fetch; +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.reject(new Error("no network in test"))) as any; +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + function renderMenu(open = true) { - const onPick = mock(() => {}); + const onPick = mock((_: { kind: string; options: Record }) => {}); const onClose = mock(() => {}); - render( - , - ); + render(); return { onPick, onClose }; } describe("NewSessionMenu", () => { - test("renders a row per agent plus Plain shell", () => { + test("renders a row per fallback kind, titled by the silverwood tag (noninteractive → two rows)", () => { renderMenu(); - expect(screen.getByText("Claude Code")).not.toBeNull(); - expect(screen.getByText("Plain shell")).not.toBeNull(); + expect(screen.getByText("claude-code")).not.toBeNull(); + expect(screen.getByText("plain-shell")).not.toBeNull(); + expect(screen.getByText("disk-space")).not.toBeNull(); + // The noninteractive kind's required bool option becomes two rows, differentiated by flag. + expect(screen.getByText(/run-direnv-exec=true/)).not.toBeNull(); + expect(screen.getByText(/run-direnv-exec=false/)).not.toBeNull(); }); test("renders nothing when closed", () => { renderMenu(false); - expect(screen.queryByText("Plain shell")).toBeNull(); + expect(screen.queryByText("plain-shell")).toBeNull(); }); - test("picking the agent row fires onPick('claude-code') then onClose", () => { + test("picking claude-code fires onPick({ kind: 'claude-code', options: {} }) then onClose", () => { const { onPick, onClose } = renderMenu(); - fireEvent.click(screen.getByText("Claude Code")); - expect(onPick).toHaveBeenCalledWith("claude-code"); + fireEvent.click(screen.getByText("claude-code")); + expect(onPick).toHaveBeenCalledWith({ kind: "claude-code", options: {} }); expect(onClose).toHaveBeenCalled(); }); - test("picking Plain shell fires onPick('plain-shell') then onClose", () => { + test("picking plain-shell fires onPick({ kind: 'plain-shell', options: {} }) then onClose", () => { const { onPick, onClose } = renderMenu(); - fireEvent.click(screen.getByText("Plain shell")); - expect(onPick).toHaveBeenCalledWith("plain-shell"); + fireEvent.click(screen.getByText("plain-shell")); + expect(onPick).toHaveBeenCalledWith({ kind: "plain-shell", options: {} }); expect(onClose).toHaveBeenCalled(); }); + + test("picking a noninteractive row carries its run-direnv-exec option value", () => { + const { onPick } = renderMenu(); + fireEvent.click(screen.getByText(/run-direnv-exec=true/)); + expect(onPick).toHaveBeenCalledWith({ + kind: "claude-code-noninteractive", + options: { "run-direnv-exec": "true" }, + }); + }); }); diff --git a/experimental/papyrus/client/src/components/NewSessionMenu.tsx b/experimental/papyrus/client/src/components/NewSessionMenu.tsx index 7ba3d41..462a144 100644 --- a/experimental/papyrus/client/src/components/NewSessionMenu.tsx +++ b/experimental/papyrus/client/src/components/NewSessionMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { Terminal, @@ -11,7 +11,11 @@ import { Brain, Wand2, } from "lucide-react"; -import { Agent } from "../stores/useStore"; +import { + FALLBACK_SESSION_SCHEMA, + schemaToRows, + type SessionKindInfo, +} from "./sessionSchema"; // Icon-string → component, matching the canvas node's map (AgentNode/index.tsx). const iconMap: Record = { @@ -26,32 +30,40 @@ const iconMap: Record = { terminal: Terminal, }; -// One row in the picker. `variant` is the string POSTed to the server; the agent -// variant(s) map to the durable "claude-code" session, "plain-shell" to a durable -// `silverwood spawn` login shell. -interface VariantItem { - variant: string; - label: string; - description: string; - icon: string; - color?: string; +// What a picked row creates: the silverwood kind tag + its option values (POSTed verbatim). +export interface SessionPick { + kind: string; + options: Record; } interface NewSessionMenuProps { open: boolean; anchor: DOMRect | null; - agents: Agent[]; onClose: () => void; - onPick: (variant: string) => void; + onPick: (pick: SessionPick) => void; } const MENU_WIDTH = 244; -// An anchored dropdown that opens under the "+" button, letting the user pick the -// session variant to start. Rows are the available agent session kinds (from -// `agents`) plus a static Plain shell. Rendered in a body portal so it escapes the -// sidebar's overflow; closes on outside-click or Escape. -export function NewSessionMenu({ open, anchor, agents, onClose, onPick }: NewSessionMenuProps) { +// An anchored dropdown that opens under the "+" button, letting the user pick the session +// to start. Rows are generated from `silverwood session-schema` (GET /api/session-schema) +// so the kind list lives only in silverwood — a required bool option (e.g. run-direnv-exec) +// becomes two rows. Rendered in a body portal so it escapes the sidebar's overflow; closes +// on outside-click or Escape. +export function NewSessionMenu({ open, anchor, onClose, onPick }: NewSessionMenuProps) { + const [schema, setSchema] = useState(FALLBACK_SESSION_SCHEMA); + + useEffect(() => { + if (!open) return; + // Refresh from silverwood each open (cheap); keep the fallback if it fails. + fetch("/api/session-schema") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (Array.isArray(data)) setSchema(data); + }) + .catch(() => {}); + }, [open]); + useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { @@ -70,25 +82,9 @@ export function NewSessionMenu({ open, anchor, agents, onClose, onPick }: NewSes }; }, [open, onClose]); - if (!open || !anchor) return null; + const rows = useMemo(() => schemaToRows(schema), [schema]); - const items: VariantItem[] = [ - // Today silverwood has exactly one create-variant (claude-code); each agent - // maps to it. Add a new agent/kind here to grow the picker. - ...agents.map((a) => ({ - variant: "claude-code", - label: a.name, - description: a.description, - icon: a.icon, - color: a.color, - })), - { - variant: "plain-shell", - label: "Plain shell", - description: "Login shell (silverwood spawn)", - icon: "terminal", - }, - ]; + if (!open || !anchor) return null; const left = Math.max(8, Math.min(anchor.left, window.innerWidth - MENU_WIDTH - 8)); const top = anchor.bottom + 4; @@ -98,24 +94,30 @@ export function NewSessionMenu({ open, anchor, agents, onClose, onPick }: NewSes className="new-session-menu fixed z-[9999] rounded-lg border border-popover-border bg-popover shadow-xl py-1" style={{ left, top, width: MENU_WIDTH }} > - {items.map((item, i) => { - const Icon = iconMap[item.icon] || Cpu; + {rows.map((row) => { + const Icon = iconMap[row.icon] || Cpu; return ( ); diff --git a/experimental/papyrus/client/src/components/Sidebar.tsx b/experimental/papyrus/client/src/components/Sidebar.tsx index 7762866..2fa4413 100644 --- a/experimental/papyrus/client/src/components/Sidebar.tsx +++ b/experimental/papyrus/client/src/components/Sidebar.tsx @@ -26,10 +26,15 @@ import { useStore, SessionTab } from "../stores/useStore"; import { AgentIcon } from "./AgentIcon"; import { type PendingOptimism, shouldDropOptimism, mergePendingTabs } from "./sessionOptimism"; import { Terminal } from "./Terminal"; -import { NewSessionMenu } from "./NewSessionMenu"; +import { NewSessionMenu, type SessionPick } from "./NewSessionMenu"; import { useResizablePane } from "./useResizablePane"; import { workstreamStateLabel } from "../workstreamState"; +// An ephemeral (non-agent) session kind — a shell or disk-space monitor. These reopen fresh +// (no conversation to resume), carry no advisory lock, and are directly removable, unlike the +// resumable claude-code* kinds. Mirrors the server's `kindLocks` split. +const isEphemeralKind = (kind?: string) => !kind?.startsWith("claude-code"); + const presetColors = [ "#F97316", "#22C55E", "#3B82F6", "#8B5CF6", "#EC4899", "#EF4444", "#FBBF24", "#14B8A6" ]; @@ -60,7 +65,6 @@ export function Sidebar() { updateSession, updateNode, nodes, - agents, } = useStore(); const session = selectedNodeId ? sessions.get(selectedNodeId) : null; @@ -215,8 +219,10 @@ export function Sidebar() { } }; - // Start a fresh session of `variant` ("claude-code" | "plain-shell") in this workstream. - const startSession = async (variant: string) => { + // Start a fresh session of the picked kind (+ its option values) in this workstream. The + // server records the durable silverwood session, then returns the id + the kind/name it + // used, so the optimistic tab is correct for every kind (not just shell vs claude). + const startSession = async (pick: SessionPick) => { setMenuAnchor(null); if (!selectedNodeId) return; setBusy(true); @@ -225,18 +231,17 @@ export function Sidebar() { const res = await fetch(`/api/sessions/${selectedNodeId}/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ variant }), + body: JSON.stringify({ kind: pick.kind, options: pick.options }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setConnectError(data.error || "Failed to start session"); return; } - const shell = variant === "plain-shell"; setPending((p) => ({ ...p, [data.sessionId]: { - ov: { connected: true, ...(shell ? { name: "shell", kind: "plain-shell" } : {}) }, + ov: { connected: true, kind: data.kind, name: data.name }, seq: reconcileSeqRef.current, }, })); @@ -530,14 +535,14 @@ export function Sidebar() { > - {/* Close: only plain shells (a shell has no doctor-based removal). */} - {t.kind === "plain-shell" && ( + {/* Close: ephemeral kinds only (a claude session has no doctor-based removal). */} + {isEphemeralKind(t.kind) && ( )} - {activeTab.kind === "plain-shell" && ( + {isEphemeralKind(activeTab.kind) && ( )} {connectError &&

{connectError}

} @@ -729,7 +734,6 @@ export function Sidebar() { setMenuAnchor(null)} onPick={startSession} /> diff --git a/experimental/papyrus/client/src/components/sessionSchema.test.ts b/experimental/papyrus/client/src/components/sessionSchema.test.ts new file mode 100644 index 0000000..94a5084 --- /dev/null +++ b/experimental/papyrus/client/src/components/sessionSchema.test.ts @@ -0,0 +1,100 @@ +// UNIT test (mine to maintain — see ../../../TESTING.md): the session-schema → menu-row +// expansion. silverwood is the source of truth for kinds; row titles are the kind tag and +// descriptions are silverwood's `about`. A required bool option expands into two rows. +import { describe, test, expect } from "bun:test"; +import { + expandKindToRows, + schemaToRows, + kindPresentation, + FALLBACK_SESSION_SCHEMA, + type SessionKindInfo, +} from "./sessionSchema"; + +describe("expandKindToRows", () => { + test("a kind with no options → one row titled by its tag, carrying empty options", () => { + const rows = expandKindToRows({ kind: "plain-shell", description: "A shell.", options: [] }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: "plain-shell", + options: {}, + label: "plain-shell", + description: "A shell.", + }); + expect(rows[0].disabled).toBeUndefined(); + }); + + test("titles and descriptions come from silverwood (tag + its about), not invented labels", () => { + const rows = expandKindToRows({ + kind: "disk-space", + description: "A disk-space monitor session (a `df` refresh loop).", + options: [], + }); + expect(rows[0].label).toBe("disk-space"); + expect(rows[0].description).toBe("A disk-space monitor session (a `df` refresh loop)."); + }); + + test("a required bool option → two rows, one per true/false, differentiated by the flag name", () => { + const noni = FALLBACK_SESSION_SCHEMA.find((k) => k.kind === "claude-code-noninteractive")!; + const rows = expandKindToRows(noni); + expect(rows).toHaveLength(2); + const byExec = Object.fromEntries(rows.map((r) => [r.options["run-direnv-exec"], r])); + expect(byExec["true"]).toBeDefined(); + expect(byExec["false"]).toBeDefined(); + // Title stays the silverwood tag, with the chosen flag appended (no invented wording). + expect(byExec["true"].label).toBe("claude-code-noninteractive (run-direnv-exec=true)"); + expect(byExec["false"].label).toBe("claude-code-noninteractive (run-direnv-exec=false)"); + expect(rows[0].key).not.toBe(rows[1].key); + }); + + test("an unknown kind still renders, titled by its tag with a generic icon", () => { + const rows = expandKindToRows({ kind: "future-kind", description: "New.", options: [] }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: "future-kind", label: "future-kind", icon: "terminal" }); + }); + + test("a required non-bool option → one disabled row (a flat click can't collect text)", () => { + const k: SessionKindInfo = { + kind: "needs-text", + description: "…", + options: [{ long: "branch", help: "", required: true, value_kind: "string" }], + }; + const rows = expandKindToRows(k); + expect(rows).toHaveLength(1); + expect(rows[0].disabled).toBe(true); + expect(rows[0].description).toContain("--branch"); + }); + + test("a non-required option is not surfaced as a row dimension", () => { + const k: SessionKindInfo = { + kind: "opt", + description: "…", + options: [{ long: "verbose", help: "", required: false, value_kind: "bool" }], + }; + const rows = expandKindToRows(k); + expect(rows).toHaveLength(1); + expect(rows[0].options).toEqual({}); + }); +}); + +describe("schemaToRows", () => { + test("expands the fallback schema in kind order (noninteractive → 2 rows)", () => { + const rows = schemaToRows(FALLBACK_SESSION_SCHEMA); + // 4 kinds, one of which (noninteractive) yields 2 rows → 5 rows. + expect(rows).toHaveLength(5); + expect(rows.map((r) => r.kind)).toEqual([ + "claude-code", + "plain-shell", + "claude-code-noninteractive", + "claude-code-noninteractive", + "disk-space", + ]); + }); +}); + +describe("kindPresentation", () => { + test("known kinds carry an icon; unknown kinds fall back to a generic terminal icon", () => { + expect(kindPresentation("claude-code").icon).toBe("sparkles"); + expect(kindPresentation("disk-space").icon).toBe("cpu"); + expect(kindPresentation("mystery")).toEqual({ icon: "terminal" }); + }); +}); diff --git a/experimental/papyrus/client/src/components/sessionSchema.ts b/experimental/papyrus/client/src/components/sessionSchema.ts new file mode 100644 index 0000000..13cafb1 --- /dev/null +++ b/experimental/papyrus/client/src/components/sessionSchema.ts @@ -0,0 +1,127 @@ +// The session kinds a tab can be created from (mirrors `silverwood session-schema`, served +// at /api/session-schema). One entry per `session create` subcommand; `options` are the +// user-supplied flags beyond the papyrus-minted id/session_id/name. silverwood is the single +// source of truth — the New Tab menu's titles (the kind tag) and descriptions (the kind's +// clap `about`) come straight from here; papyrus only decides the icon. + +export interface SessionOptionInfo { + long: string; // long flag without `--`, e.g. "run-direnv-exec" + help: string; + required: boolean; + value_kind: "bool" | "string"; +} + +export interface SessionKindInfo { + kind: string; // kebab kind tag, = the silverwood SessionKind tag + description: string; + options: SessionOptionInfo[]; +} + +// Shown until GET /api/session-schema responds (and if it fails): the kinds that exist +// today, so the menu is usable before the fetch lands. Descriptions are terse placeholders — +// the real ones arrive from silverwood on fetch. +export const FALLBACK_SESSION_SCHEMA: SessionKindInfo[] = [ + { kind: "claude-code", description: "A Claude Code session.", options: [] }, + { kind: "plain-shell", description: "A plain login shell.", options: [] }, + { + kind: "claude-code-noninteractive", + description: "A Claude Code session run non-interactively.", + options: [ + { + long: "run-direnv-exec", + help: "Load the checkout's .envrc (direnv exec).", + required: true, + value_kind: "bool", + }, + ], + }, + { kind: "disk-space", description: "A disk-space monitor session.", options: [] }, +]; + +// Icon per kind — the ONLY presentation papyrus owns (silverwood supplies the title + +// description). An unknown (future) kind still renders, with a generic terminal icon. +export interface KindPresentation { + icon: string; + color?: string; +} +const KIND_PRESENTATION: Record = { + "claude-code": { icon: "sparkles", color: "#F97316" }, + "claude-code-noninteractive": { icon: "sparkles", color: "#F97316" }, + "plain-shell": { icon: "terminal" }, + "disk-space": { icon: "cpu" }, +}; +export function kindPresentation(kind: string): KindPresentation { + return KIND_PRESENTATION[kind] ?? { icon: "terminal" }; +} + +// One rendered menu row: clicking it POSTs `{ kind, options }` to create the session. `label` +// is the silverwood kind tag (plus the chosen option flag for a sub-row); `description` is the +// kind's silverwood `about`. A `disabled` row is shown but not clickable. +export interface VariantRow { + key: string; + kind: string; + options: Record; + label: string; + description: string; + icon: string; + color?: string; + disabled?: boolean; +} + +// Expand one kind into the rows that create it (click = create immediately): +// - no required options → 1 row +// - required bool option(s) → the cartesian of true/false across them (1 today → 2 rows) +// - a required non-bool option → 1 disabled row (a flat click can't supply free text) +// A sub-row's title appends `long=value` (the silverwood flag name) so the choice is explicit. +export function expandKindToRows(k: SessionKindInfo): VariantRow[] { + const p = kindPresentation(k.kind); + const required = k.options.filter((o) => o.required); + const nonBool = required.find((o) => o.value_kind !== "bool"); + if (nonBool) { + return [ + { + key: k.kind, + kind: k.kind, + options: {}, + label: k.kind, + description: `Needs --${nonBool.long}; not supported here`, + icon: p.icon, + color: p.color, + disabled: true, + }, + ]; + } + if (required.length === 0) { + return [ + { + key: k.kind, + kind: k.kind, + options: {}, + label: k.kind, + description: k.description, + icon: p.icon, + color: p.color, + }, + ]; + } + let combos: Array> = [{}]; + for (const o of required) { + combos = combos.flatMap((c) => ["true", "false"].map((v) => ({ ...c, [o.long]: v }))); + } + return combos.map((options) => { + const flags = required.map((o) => `${o.long}=${options[o.long]}`); + return { + key: `${k.kind}:${flags.join(",")}`, + kind: k.kind, + options, + label: `${k.kind} (${flags.join(", ")})`, + description: k.description, + icon: p.icon, + color: p.color, + }; + }); +} + +// All menu rows for a schema, in kind order (each kind expanded to its row(s)). +export const schemaToRows = (schema: SessionKindInfo[]): VariantRow[] => + schema.flatMap(expandKindToRows);