Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/screens/leitstand/flow/FlowGraph.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof FlowGraph>[0]["renderNode"]) {
const labels: Record<string, FlowLabel> = {};
for (const w of workers) labels[w.id] = { task: w.task, branch: w.branch };
return render(<FlowGraph layout={layoutFlow(workers)} labels={labels} renderNode={renderNode} />);
}

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) => <i>slot {node.id}</i>);
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();
});
});
111 changes: 111 additions & 0 deletions src/screens/leitstand/flow/FlowGraph.tsx
Original file line number Diff line number Diff line change
@@ -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<string, FlowLabel>;
/** 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 <HonestState kind="empty" title={T.empty} />;

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 (
<section className="fl-wrap" aria-label={T.label}>
<div className="fl-canvas" style={{ width, height }}>
<svg className="fl-edges" width={width} height={height}>
<defs>
<marker id={arrow} className="fl-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0 0L8 4L0 8z" />
</marker>
</defs>
{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 (
<path
key={`${from}>${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})`}
>
<title>{fill(T.edge, { parent: labelOf(from).task, child: labelOf(to).task })}</title>
</path>
);
})}
</svg>
{layout.nodes.map((node) => {
const label = labelOf(node.id);
return (
<div
key={node.id}
className="fl-node"
role="group"
aria-label={label.task}
style={{ left: left(node), top: top(node), width: NODE_W, height: NODE_H }}
>
{renderNode ? (
renderNode(node, label)
) : (
<>
<span className="fl-task">{label.task}</span>
<span className="fl-branch">{label.branch}</span>
</>
)}
</div>
);
})}
</div>
</section>
);
}
26 changes: 26 additions & 0 deletions src/screens/leitstand/flow/flow.css
Original file line number Diff line number Diff line change
@@ -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; }
}
10 changes: 10 additions & 0 deletions src/screens/leitstand/flow/texts.ts
Original file line number Diff line number Diff line change
@@ -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)";
Loading