diff --git a/src/screens/leitstand/flow/FlowGraph.test.tsx b/src/screens/leitstand/flow/FlowGraph.test.tsx new file mode 100644 index 00000000..7f83ba7b --- /dev/null +++ b/src/screens/leitstand/flow/FlowGraph.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Worker } from "../../../types"; +import { FlowGraph, type FlowLabel } from "./FlowGraph"; +import { layoutFlow } from "./layout"; +import { T } from "./texts"; + +function worker(id: string, spawnedBy: string | null): Worker { + return { + id, + projectId: "project-a", + task: `Task ${id}`, + profileId: "codex", + branch: `pa/${id}`, + worktreePath: `/tmp/${id}`, + sessionId: null, + status: "running", + kind: "worker", + spawnedBy, + pausedReason: null, + createdAt: 1, + }; +} + +function setup(workers: Worker[], renderNode?: Parameters[0]["renderNode"]) { + const labels: Record = {}; + for (const w of workers) labels[w.id] = { task: w.task, branch: w.branch }; + return render(); +} + +function reducedMotion(matches: boolean) { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: matches && query === "(prefers-reduced-motion: reduce)", + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("FlowGraph", () => { + it("shows the task and the branch of each agent on its node", () => { + reducedMotion(false); + setup([worker("p", null), worker("c", "p")]); + const node = screen.getByRole("group", { name: /Task c/ }); + expect(within(node).getByText("Task c")).toBeInTheDocument(); + expect(within(node).getByText("pa/c")).toBeInTheDocument(); + }); + + it("draws an edge from the parent node to the child", () => { + reducedMotion(false); + const { container } = setup([worker("p", null), worker("c", "p")]); + const edges = container.querySelectorAll("[data-flow-edge]"); + expect(edges).toHaveLength(1); + expect(edges[0]?.getAttribute("data-from")).toBe("p"); + expect(edges[0]?.getAttribute("data-to")).toBe("c"); + expect(edges[0]?.textContent).toBe("Task p startete Task c"); + expect(edges[0]?.classList.contains("fl-edge--moving")).toBe(true); + }); + + it("does not animate edges when reduced motion is requested", () => { + reducedMotion(true); + const { container } = setup([worker("p", null), worker("c", "p")]); + expect(container.querySelectorAll("[data-flow-edge]")).toHaveLength(1); + expect(container.querySelector(".fl-edge--moving")).toBeNull(); + }); + + it("replaces only the node interior when renderNode is passed", () => { + reducedMotion(false); + const { container } = setup([worker("p", null), worker("c", "p")], (node) => slot {node.id}); + expect(screen.getByText("slot c")).toBeInTheDocument(); + expect(screen.queryByText("pa/c")).toBeNull(); + expect(container.querySelectorAll("[data-flow-edge]")).toHaveLength(1); + }); + + it("says so in words when there are no agents and invents no parent", () => { + reducedMotion(false); + const { container } = setup([]); + expect(screen.getByText(T.empty)).toBeInTheDocument(); + expect(screen.queryByRole("group")).toBeNull(); + expect(container.querySelector("[data-flow-edge]")).toBeNull(); + }); +}); diff --git a/src/screens/leitstand/flow/FlowGraph.tsx b/src/screens/leitstand/flow/FlowGraph.tsx new file mode 100644 index 00000000..0b566a1d --- /dev/null +++ b/src/screens/leitstand/flow/FlowGraph.tsx @@ -0,0 +1,111 @@ +import { useId, useMemo, useSyncExternalStore, type ReactNode } from "react"; + +import { HonestState } from "../../../design/data/HonestState"; +import { fill } from "../leitstand"; +import type { FlowLayout, FlowNode } from "./layout"; +import { REDUCED_QUERY, T } from "./texts"; +import "../../../design/glass.css"; +import "./flow.css"; + +export interface FlowLabel { + task: string; + branch: string; +} + +export interface FlowGraphProps { + layout: FlowLayout; + /** Task and branch per node id; an id without a label falls back to the id. */ + labels: Record; + /** Replaces only the inside of a node; the frame and the edges stay. */ + renderNode?: (node: FlowNode, label: FlowLabel) => ReactNode; +} + +const NODE_W = 208; +const NODE_H = 76; +const GAP_X = 24; +const GAP_Y = 56; + +/** Live "user asked for reduced motion"; without `matchMedia` (old webviews, tests) it never matches. */ +function useReducedMotion(): boolean { + const list = useMemo(() => (typeof window.matchMedia === "function" ? window.matchMedia(REDUCED_QUERY) : null), []); + return useSyncExternalStore( + (notify) => { + list?.addEventListener("change", notify); + return () => list?.removeEventListener("change", notify); + }, + () => list?.matches ?? false, + ); +} + +const left = (node: FlowNode) => node.x * (NODE_W + GAP_X); +const top = (node: FlowNode) => node.y * (NODE_H + GAP_Y); + +/** The agent flow on glass: one node per agent, an arrow from each parent to the child it started. */ +export function FlowGraph({ layout, labels, renderNode }: FlowGraphProps) { + const reduced = useReducedMotion(); + const arrow = useId(); + if (layout.nodes.length === 0) return ; + + const byId = new Map(layout.nodes.map((node) => [node.id, node])); + const labelOf = (id: string): FlowLabel => labels[id] ?? { task: id, branch: "" }; + const width = (Math.max(...layout.nodes.map((node) => node.x)) + 1) * (NODE_W + GAP_X) - GAP_X; + const height = (Math.max(...layout.nodes.map((node) => node.y)) + 1) * (NODE_H + GAP_Y) - GAP_Y; + + return ( +
+
+ + + + + + + {layout.edges.map(({ from, to }) => { + const parent = byId.get(from); + const child = byId.get(to); + if (!parent || !child) return null; + const x1 = left(parent) + NODE_W / 2; + const y1 = top(parent) + NODE_H; + const x2 = left(child) + NODE_W / 2; + const y2 = top(child); + const mid = (y1 + y2) / 2; + return ( + ${to}`} + data-flow-edge="" + data-from={from} + data-to={to} + className={reduced ? "fl-edge" : "fl-edge fl-edge--moving"} + d={`M${x1} ${y1}C${x1} ${mid} ${x2} ${mid} ${x2} ${y2}`} + markerEnd={`url(#${arrow})`} + > + {fill(T.edge, { parent: labelOf(from).task, child: labelOf(to).task })} + + ); + })} + + {layout.nodes.map((node) => { + const label = labelOf(node.id); + return ( +
+ {renderNode ? ( + renderNode(node, label) + ) : ( + <> + {label.task} + {label.branch} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/screens/leitstand/flow/flow.css b/src/screens/leitstand/flow/flow.css new file mode 100644 index 00000000..17bba8fc --- /dev/null +++ b/src/screens/leitstand/flow/flow.css @@ -0,0 +1,26 @@ +/* COMP-3-2 agent flow. Glass cards and dashed arrows on the --g-* tokens (Main.dc.html + card/line language). Text is on every node, so colour is never the only signal. */ + +.fl-wrap { overflow: auto; padding: var(--g-pad); min-width: 0; } +.fl-canvas { position: relative; margin: 0 auto; } +.fl-edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; } +.fl-edge { fill: none; stroke: var(--g-faint); stroke-width: 1.5; stroke-dasharray: 6 6; } +.fl-arrow { fill: var(--g-faint); } +.fl-edge--moving { animation: fl-flow calc(var(--g-dur-3) * 4) linear infinite; } +@keyframes fl-flow { to { stroke-dashoffset: -12; } } + +.fl-node { + position: absolute; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; gap: 4px; + padding: 0 16px; overflow: hidden; background: var(--g-content-bg); border: 1px solid var(--g-line); + border-radius: var(--g-r-card); box-shadow: var(--g-spec), var(--g-shadow); + -webkit-backdrop-filter: var(--g-content-blur); backdrop-filter: var(--g-content-blur); +} +.fl-task { + font: 600 13px/1.3 var(--g-font-sans); color: var(--g-ink); + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.fl-branch { font: 500 12px/1.3 var(--g-font-mono); color: var(--g-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +@media (prefers-reduced-motion: reduce) { + .fl-edge--moving { animation: none; } +} diff --git a/src/screens/leitstand/flow/texts.ts b/src/screens/leitstand/flow/texts.ts new file mode 100644 index 00000000..3f5f89f9 --- /dev/null +++ b/src/screens/leitstand/flow/texts.ts @@ -0,0 +1,10 @@ +// German words of the flow graph. Moves into the dictionary (src/i18n) once +// the route mounts it. +export const T = { + label: "Ablauf der Agenten", + empty: "Noch keine Agenten im Ablauf", + edge: "{parent} startete {child}", +} as const; + +/** Not a word, but kept here so the component holds no string literal with spaces. */ +export const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";