diff --git a/experimental/papyrus/client/src/App.tsx b/experimental/papyrus/client/src/App.tsx index d50ab16..2cb1c90 100644 --- a/experimental/papyrus/client/src/App.tsx +++ b/experimental/papyrus/client/src/App.tsx @@ -134,6 +134,8 @@ function AppContent() { cwd: n.cwd, connected: !!n.connected, checkoutState: n.checkoutState, + overallState: n.overallState, + kind: n.kind, customName: n.customName, customColor: n.customColor, notes: n.notes, diff --git a/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx b/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx index e9c8f5f..0acd44d 100644 --- a/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx +++ b/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx @@ -1,19 +1,6 @@ import { GitBranch, Folder } from "lucide-react"; import { AgentIcon } from "../AgentIcon"; -// A single node indicator folding connection + checkout state (no live agent -// activity — that needed the plugin hook). Precedence: checkout problems first, -// then whether a session is connected in this papyrus instance. -function nodeIndicator( - connected: boolean, - checkoutState?: string, -): { label: string; color: string } { - if (checkoutState === "failed") return { label: "Checkout failed", color: "#EF4444" }; - if (checkoutState === "pending") return { label: "Cloning…", color: "#FBBF24" }; - if (connected) return { label: "Connected", color: "#22C55E" }; - return { label: "Disconnected", color: "#6B7280" }; -} - interface AgentNodeCardProps { selected: boolean; displayColor: string; @@ -21,7 +8,8 @@ interface AgentNodeCardProps { icon: string; agentId: string; connected: boolean; - checkoutState?: string; + // The workstream-level state indicator (label + color), computed by the parent. + indicator: { label: string; color: string }; cwd?: string; originalCwd?: string; // Mother repo path when using worktrees gitBranch?: string; @@ -36,7 +24,7 @@ export function AgentNodeCard({ icon, agentId, connected, - checkoutState, + indicator, cwd, originalCwd, gitBranch, @@ -45,7 +33,6 @@ export function AgentNodeCard({ }: AgentNodeCardProps) { // agentId is available for future use if needed void agentId; - const indicator = nodeIndicator(connected, checkoutState); // Extract directory name - use originalCwd (mother repo) if available, otherwise cwd const displayCwd = originalCwd || cwd; diff --git a/experimental/papyrus/client/src/components/AgentNode/index.tsx b/experimental/papyrus/client/src/components/AgentNode/index.tsx index c578102..557641a 100644 --- a/experimental/papyrus/client/src/components/AgentNode/index.tsx +++ b/experimental/papyrus/client/src/components/AgentNode/index.tsx @@ -1,6 +1,7 @@ import { NodeProps } from "@xyflow/react"; import { motion } from "framer-motion"; import { useStore } from "../../stores/useStore"; +import { workstreamStateLabel } from "../../workstreamState"; import { AgentNodeCard } from "./AgentNodeCard"; import { AgentNodeContextMenu } from "./AgentNodeContextMenu"; import { useAgentNodeState } from "./useAgentNodeState"; @@ -24,6 +25,16 @@ export const AgentNode = ({ id, data, selected }: NodeProps) => { // Get the full session for other data const session = useStore((state) => state.sessions.get(id)); + // Workstream-level state indicator: silverwood's algebraic state, plus the + // N/M-connected agent count for a Ready Basic workstream. + const indicator = workstreamStateLabel({ + overallState: session?.overallState, + kind: session?.kind, + checkoutState, + connected, + tabs: session?.tabs, + }); + const { contextMenu, handleContextMenu, @@ -49,7 +60,7 @@ export const AgentNode = ({ id, data, selected }: NodeProps) => { icon={displayIcon} agentId={nodeData.agentId} connected={connected} - checkoutState={checkoutState} + indicator={indicator} cwd={session?.cwd} originalCwd={session?.originalCwd} gitBranch={session?.gitBranch} diff --git a/experimental/papyrus/client/src/components/Sidebar.tsx b/experimental/papyrus/client/src/components/Sidebar.tsx index af97f05..be2fe55 100644 --- a/experimental/papyrus/client/src/components/Sidebar.tsx +++ b/experimental/papyrus/client/src/components/Sidebar.tsx @@ -28,6 +28,7 @@ import { type PendingOptimism, shouldDropOptimism } from "./sessionOptimism"; import { Terminal } from "./Terminal"; import { NewSessionMenu } from "./NewSessionMenu"; import { useResizablePane } from "./useResizablePane"; +import { workstreamStateLabel } from "../workstreamState"; const presetColors = [ "#F97316", "#22C55E", "#3B82F6", "#8B5CF6", "#EC4899", "#EF4444", "#FBBF24", "#14B8A6" @@ -295,15 +296,15 @@ export function Sidebar() { }; const displayColor = editColor || session?.customColor || session?.color || "#888"; - // Workstream-level indicator: checkout problems first, then connection. - const headerStatus = - session?.checkoutState === "failed" - ? { label: "Checkout failed", color: "#EF4444" } - : session?.checkoutState === "pending" - ? { label: "Cloning…", color: "#FBBF24" } - : session?.connected - ? { label: "Connected", color: "#22C55E" } - : { label: "Disconnected", color: "#6B7280" }; + // Workstream-level indicator: silverwood's algebraic state, plus the N/M-connected + // agent count for a Ready Basic workstream. + const headerStatus = workstreamStateLabel({ + overallState: session?.overallState, + kind: session?.kind, + checkoutState: session?.checkoutState, + connected: session?.connected ?? false, + tabs, + }); return ( diff --git a/experimental/papyrus/client/src/stores/useStore.ts b/experimental/papyrus/client/src/stores/useStore.ts index 410bb4d..e36b4ab 100644 --- a/experimental/papyrus/client/src/stores/useStore.ts +++ b/experimental/papyrus/client/src/stores/useStore.ts @@ -74,6 +74,10 @@ export interface AgentSession { // Node visuals: any session connected in this papyrus instance, + checkout state. connected: boolean; checkoutState?: string; + // Silverwood's algebraic state string (e.g. `active - basic.ready`) and the + // workstream kind (gates the "N/M Connected" agent count to Basic workstreams). + overallState?: string; + kind?: string; customName?: string; customColor?: string; notes?: string; diff --git a/experimental/papyrus/client/src/workstreamState.test.ts b/experimental/papyrus/client/src/workstreamState.test.ts new file mode 100644 index 0000000..d0eb0cf --- /dev/null +++ b/experimental/papyrus/client/src/workstreamState.test.ts @@ -0,0 +1,75 @@ +import { test, expect, describe } from "bun:test"; +import { workstreamStateLabel } from "./workstreamState"; + +const tab = (connected: boolean) => ({ connected }); + +describe("workstreamStateLabel", () => { + test("basic + ready: shows only N/M Connected (base is omitted)", () => { + const { label, color } = workstreamStateLabel({ + overallState: "active - basic.ready", + kind: "basic", + checkoutState: "ready", + connected: true, + tabs: [tab(true), tab(false), tab(true)], + }); + expect(label).toBe("2/3 Connected"); + expect(color).toBe("#22C55E"); + }); + + test("basic + ready with no agents: 0/0 Connected, gray (nothing connected)", () => { + const { label, color } = workstreamStateLabel({ + overallState: "active - basic.ready", + kind: "basic", + checkoutState: "ready", + connected: false, + tabs: [], + }); + expect(label).toBe("0/0 Connected"); + expect(color).toBe("#6B7280"); + }); + + test("pending: base label only, amber", () => { + const { label, color } = workstreamStateLabel({ + overallState: "active - basic.pending", + kind: "basic", + checkoutState: "pending", + connected: false, + tabs: [], + }); + expect(label).toBe("active - basic.pending"); + expect(color).toBe("#FBBF24"); + }); + + test("failed: base label only, red — even if a session is connected", () => { + const { label, color } = workstreamStateLabel({ + overallState: "active - basic.failed", + kind: "basic", + checkoutState: "failed", + connected: true, + tabs: [tab(true)], + }); + expect(label).toBe("active - basic.failed"); + expect(color).toBe("#EF4444"); + }); + + test("non-basic kind that is ready: no N/M suffix", () => { + const { label } = workstreamStateLabel({ + overallState: "active - basic-external", + kind: "basic-external", + checkoutState: "ready", + connected: true, + tabs: [tab(true)], + }); + expect(label).toBe("active - basic-external"); + }); + + test("missing overallState: degrades gracefully (no leading space)", () => { + const { label } = workstreamStateLabel({ + kind: "basic", + checkoutState: "ready", + connected: true, + tabs: [tab(true), tab(true)], + }); + expect(label).toBe("2/2 Connected"); + }); +}); diff --git a/experimental/papyrus/client/src/workstreamState.ts b/experimental/papyrus/client/src/workstreamState.ts new file mode 100644 index 0000000..c77bca0 --- /dev/null +++ b/experimental/papyrus/client/src/workstreamState.ts @@ -0,0 +1,40 @@ +// The workstream-level state indicator shown on the canvas node banner and in the +// sidebar header. The label is silverwood's algebraic state string (e.g. +// `active - basic.ready`), plus — for a Basic workstream that is Ready — a +// `N/M Connected` agent count (N connected, M total). The color keeps the prior +// indicator precedence: checkout problems first, then whether a session is connected. + +export interface WorkstreamStateInput { + overallState?: string; + kind?: string; + checkoutState?: string; + connected: boolean; + tabs?: { connected: boolean }[]; +} + +export function workstreamStateLabel(input: WorkstreamStateInput): { + label: string; + color: string; +} { + const { overallState, kind, checkoutState, connected, tabs = [] } = input; + + // The only state carrying a connection count is Basic + Ready; there we show just + // "N/M Connected" and omit the (redundant) `active - basic.ready` base. Every other + // state shows the algebraic string alone. + let label = overallState ?? ""; + if (kind === "basic" && checkoutState === "ready") { + const n = tabs.filter((t) => t.connected).length; + label = `${n}/${tabs.length} Connected`; + } + + const color = + checkoutState === "failed" + ? "#EF4444" + : checkoutState === "pending" + ? "#FBBF24" + : connected + ? "#22C55E" + : "#6B7280"; + + return { label, color }; +} diff --git a/experimental/papyrus/server/routes/api.ts b/experimental/papyrus/server/routes/api.ts index a0e8c81..976c6f1 100644 --- a/experimental/papyrus/server/routes/api.ts +++ b/experimental/papyrus/server/routes/api.ts @@ -138,6 +138,10 @@ async function buildNode(ws: sw.Workstream) { // checkout state. No live agent-activity status (that needed the hook). connected: tabs.some((t) => t.connected), checkoutState: cstate, + // Algebraic state from silverwood + the workstream kind (client gates the + // "N/M Connected" agent count to Basic workstreams). + overallState: ws.overall_state, + kind: ws.kind, source: ws.mode?.initial_source, sessions: tabs, }; diff --git a/experimental/papyrus/server/services/silverwood.ts b/experimental/papyrus/server/services/silverwood.ts index 567b31f..ea57ace 100644 --- a/experimental/papyrus/server/services/silverwood.ts +++ b/experimental/papyrus/server/services/silverwood.ts @@ -35,6 +35,8 @@ export interface Workstream { status: "active" | "archived"; created_at: string; kind: string; + // Algebraic state string, e.g. `active - basic.ready` (silverwood's single source of truth). + overall_state: string; mode?: CheckoutMode; location?: Location; kv?: Record>; diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 41cd602..6436021 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -441,7 +441,8 @@ fn run(cli: Cli) -> CliResult { Command::Ls { all } => { let list = forest.list(all)?; - emit(json, &list, || { + let items: Vec<_> = list.iter().map(workstream_json).collect(); + emit(json, &items, || { for ws in &list { println!( "{} {:8} {}", @@ -456,7 +457,7 @@ fn run(cli: Cli) -> CliResult { Command::Show { id } => { let ws = forest.get(parse_id(&id)?)?; - emit(json, &ws, || print_workstream(&ws)); + emit(json, &workstream_json(&ws), || print_workstream(&ws)); Ok(()) } @@ -464,7 +465,7 @@ fn run(cli: Cli) -> CliResult { let id = parse_id(&id)?; forest.archive(id)?; let ws = forest.get(id)?; - emit(json, &ws, || println!("archived {}", ws.id)); + emit(json, &workstream_json(&ws), || println!("archived {}", ws.id)); Ok(()) } @@ -472,7 +473,7 @@ fn run(cli: Cli) -> CliResult { let id = parse_id(&id)?; forest.remove(id, force)?; let ws = forest.get(id)?; - emit(json, &ws, || println!("deleted {}", ws.id)); + emit(json, &workstream_json(&ws), || println!("deleted {}", ws.id)); Ok(()) } @@ -480,7 +481,7 @@ fn run(cli: Cli) -> CliResult { let id = parse_id(&id)?; forest.rename(id, &name)?; let ws = forest.get(id)?; - emit(json, &ws, || print_workstream(&ws)); + emit(json, &workstream_json(&ws), || print_workstream(&ws)); Ok(()) } @@ -513,7 +514,7 @@ fn run_new(forest: &Forest, json: bool, name: Option, variant: NewVarian mode: mode.into_new_mode()?, }, })?; - emit(json, &ws, || print_workstream(&ws)); + emit(json, &workstream_json(&ws), || print_workstream(&ws)); Ok(()) } @@ -784,6 +785,17 @@ fn emit(json: bool, value: &T, human: impl FnOnce()) { } } +/// Serialize a workstream to JSON with the derived `overall_state` injected — the same +/// single-source-of-truth string the human output surfaces as its `state:` line, exposed +/// to `--json` consumers (e.g. papyrus). +fn workstream_json(ws: &Workstream) -> serde_json::Value { + let mut value = serde_json::to_value(ws).unwrap_or_default(); + if let serde_json::Value::Object(map) = &mut value { + map.insert("overall_state".into(), ws.body.overall_state().into()); + } + value +} + fn print_workstream(ws: &Workstream) { println!( "{} [{}] {}",