From fc2e0a67621ead00a64c7d658f4ab87e27af0c9a Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sun, 23 Aug 2026 18:46:27 -0700 Subject: [PATCH] [papyrus] client: render the New Tab menu from `session-schema` Generate the New Tab menu rows from `/api/session-schema` (falling back to the known kinds until the fetch lands), so the kind list is no longer hardcoded in the client. Each row's title is the silverwood kind tag and its description is the kind's `about` (wrapped, not truncated); a kind with a required bool option (`run-direnv-exec`) expands into two rows, differentiated by the flag value. Ephemeral kinds (plain-shell, disk-space) get the close/remove affordances via a shared predicate, while claude-code* kinds keep the resume/lock treatment. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AQFcZthJ1Y2SQjFFGt3gV5 --- .../src/components/NewSessionMenu.test.tsx | 77 ++++++----- .../client/src/components/NewSessionMenu.tsx | 90 +++++++------ .../papyrus/client/src/components/Sidebar.tsx | 36 ++--- .../src/components/sessionSchema.test.ts | 100 ++++++++++++++ .../client/src/components/sessionSchema.ts | 127 ++++++++++++++++++ 5 files changed, 333 insertions(+), 97 deletions(-) create mode 100644 experimental/papyrus/client/src/components/sessionSchema.test.ts create mode 100644 experimental/papyrus/client/src/components/sessionSchema.ts 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); -- 2.51.2