From 202c7abc5517c4a3fef63bc44cb24067e6db29a0 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:49:41 +0200 Subject: [PATCH 1/2] test(leitstand): expect the flow graph at /leitstand/fluss Red first: the route does not exist yet, so the flow graph, its tab link and the empty hint are missing. Co-Authored-By: Claude Sonnet 5.5 --- src/screens/leitstand/LeitstandRoute.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/screens/leitstand/LeitstandRoute.test.tsx b/src/screens/leitstand/LeitstandRoute.test.tsx index 1ba906f4..1fe3ba98 100644 --- a/src/screens/leitstand/LeitstandRoute.test.tsx +++ b/src/screens/leitstand/LeitstandRoute.test.tsx @@ -135,6 +135,38 @@ describe("Leitstand route behind D1", () => { expect(screen.queryByRole("region", { name: "Warteschlange" })).toBeNull(); }); + it("renders the flow graph at /leitstand/fluss and marks its tab as the current one", async () => { + localStorage.setItem(FLAG_KEY, "1"); + window.location.hash = "#/leitstand/fluss"; + const { container } = render(); + const flow = await screen.findByRole("region", { name: "Ablauf der Agenten" }); + expect(await within(flow).findByRole("group", { name: "Fix the start block" })).toBeInTheDocument(); + expect(tabs().getByRole("link", { name: "Fluss" })).toHaveAttribute("aria-current", "page"); + expect(tabs().getByRole("link", { name: "Haupt" })).not.toHaveAttribute("aria-current"); + expect(screen.queryByRole("region", { name: "Agenten" })).toBeNull(); + expect(container.querySelector(".g-mount-legacy")).toHaveAttribute("hidden"); + }); + + it("shows the empty hint at /leitstand/fluss while no agent runs", async () => { + localStorage.setItem(FLAG_KEY, "1"); + window.location.hash = "#/leitstand/fluss"; + const base = mocks.invoke.getMockImplementation()!; + mocks.invoke.mockImplementation((command: string) => + command === "get_board_state" ? Promise.resolve({ cards: [], coordinators: [] }) : base(command)); + const { container } = render(); + expect(await screen.findByText("Noch keine Agenten im Ablauf")).toBeInTheDocument(); + expect(container.querySelector(".fl-node")).toBeNull(); + }); + + it("reaches Fluss from Haupt by its tab link", async () => { + localStorage.setItem(FLAG_KEY, "1"); + render(); + await screen.findByRole("region", { name: "Agenten" }); + fireEvent.click(tabs().getByRole("link", { name: "Fluss" })); + await waitFor(() => expect(window.location.hash).toBe("#/leitstand/fluss")); + expect(await screen.findByRole("region", { name: "Ablauf der Agenten" })).toBeInTheDocument(); + }); + it("offers no Betrieb route while the switch is off", async () => { window.location.hash = "#/leitstand/betrieb"; const { container } = render(); From 30b801b2ae82fa3d5d040bced6e9b7132f915493 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:51:37 +0200 Subject: [PATCH 2/2] feat(leitstand): add the Fluss tab at /leitstand/fluss Mounts the flow graph (COMP-3-2) as a Leitstand tab beside Haupt and Betrieb: one node per agent of the active project, edges from the agent that started another. Reduced motion and the empty hint come from the graph itself; an unread or unreadable board shows the offline state instead of claiming "no agents". Test-First: src/screens/leitstand/LeitstandRoute.test.tsx::renders the flow graph at /leitstand/fluss and marks its tab as the current one Test-First: src/screens/leitstand/LeitstandRoute.test.tsx::shows the empty hint at /leitstand/fluss while no agent runs Test-First: src/screens/leitstand/LeitstandRoute.test.tsx::reaches Fluss from Haupt by its tab link Test-First: src/screens/leitstand/LeitstandRoute.test.tsx::does not claim an empty flow when the board cannot be read at /leitstand/fluss Co-Authored-By: Claude Sonnet 5.5 --- src/i18n/de.ts | 1 + src/screens/leitstand/LeitstandRoute.test.tsx | 12 +++++++++ src/screens/leitstand/LeitstandRoute.tsx | 26 ++++++++++++++++--- src/shell/mount/routing.ts | 4 +-- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 5518c84e..c4127799 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -82,6 +82,7 @@ export const de = { "leitstand.tab.classic": "Klassisch", "leitstand.tab.tools": "Werkzeuge", "leitstand.tab.betrieb": "Betrieb", + "leitstand.tab.fluss": "Fluss", "leitstand.profilesFailed": "Agenten konnten nicht geladen werden", "leitstand.profilesHint": "Die Karten zeigen vorerst die Profil-Kennung.", "leitstand.agents": "Agenten", diff --git a/src/screens/leitstand/LeitstandRoute.test.tsx b/src/screens/leitstand/LeitstandRoute.test.tsx index 1fe3ba98..37261512 100644 --- a/src/screens/leitstand/LeitstandRoute.test.tsx +++ b/src/screens/leitstand/LeitstandRoute.test.tsx @@ -8,6 +8,7 @@ vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() vi.mock("../../components/TerminalView", () => ({ default: () => null })); import App from "../../App"; +import { de } from "../../i18n/de"; import { writeActiveProjectId } from "../../lib/activeProject"; import LeitstandRoute from "./LeitstandRoute"; @@ -158,6 +159,17 @@ describe("Leitstand route behind D1", () => { expect(container.querySelector(".fl-node")).toBeNull(); }); + it("does not claim an empty flow when the board cannot be read at /leitstand/fluss", async () => { + localStorage.setItem(FLAG_KEY, "1"); + window.location.hash = "#/leitstand/fluss"; + const base = mocks.invoke.getMockImplementation()!; + mocks.invoke.mockImplementation((command: string) => + command === "get_board_state" ? Promise.reject(new Error("backend down")) : base(command)); + render(); + expect(await screen.findByText(de["honest.offline"])).toBeInTheDocument(); + expect(screen.queryByText("Noch keine Agenten im Ablauf")).toBeNull(); + }); + it("reaches Fluss from Haupt by its tab link", async () => { localStorage.setItem(FLAG_KEY, "1"); render(); diff --git a/src/screens/leitstand/LeitstandRoute.tsx b/src/screens/leitstand/LeitstandRoute.tsx index 9087d888..b23c1743 100644 --- a/src/screens/leitstand/LeitstandRoute.tsx +++ b/src/screens/leitstand/LeitstandRoute.tsx @@ -9,28 +9,32 @@ import { useT } from "../../i18n/useT"; import type { AgentProfile } from "../../types"; import { LeitstandCards } from "./LeitstandCards"; import { BetriebPanel } from "./betrieb/BetriebPanel"; +import { FlowGraph } from "./flow/FlowGraph"; +import { layoutFlow } from "./flow/layout"; import { NeedsYou } from "./needs/NeedsYou"; import { TOOLS_PATH, trim } from "../../shell/mount/routing"; export const MAIN_PATH = "/leitstand"; export const CLASSIC_PATH = "/leitstand/klassisch"; export const BETRIEB_PATH = "/leitstand/betrieb"; +export const FLUSS_PATH = "/leitstand/fluss"; /** Relative ages ("seit 4 min") move on minute scale. */ const TICK_MS = 30_000; -/** The classic view and its persistent tools stay reachable beside Haupt and Betrieb. */ +/** The classic view and its persistent tools stay reachable beside Haupt, Fluss and Betrieb. */ export function LeitstandTabs({ path }: { path: string }) { const t = useT(); const tab = (target: string, label: string) => ( {label} ); - return ; + return ; } -/** The new Leitstand (V2-S01a): agent cards with filter chips and "Braucht dich" on Haupt, the read-only Betrieb panel beside it. */ +/** The new Leitstand (V2-S01a): agent cards with filter chips and "Braucht dich" on Haupt, the flow graph on Fluss, the read-only Betrieb panel beside it. */ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) { const betrieb = path === BETRIEB_PATH; + const fluss = path === FLUSS_PATH; const t = useT(); const [projectId, setProjectId] = useState(readActiveProjectId); // The picker and the old app publish a choice through the module's event. @@ -39,7 +43,7 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) return subscribeActiveProject(setProjectId); }, []); const board = useBoard(projectId, !betrieb); - const questions = useQuestions(projectId, !betrieb); + const questions = useQuestions(projectId, !betrieb && !fluss); const [profiles, setProfiles] = useState([]); const [profilesFailed, setProfilesFailed] = useState(false); const [now, setNow] = useState(() => Date.now() / 1000); @@ -66,6 +70,20 @@ export default function LeitstandRoute({ path = MAIN_PATH }: { path?: string }) ); } + if (fluss) { + const workers = board.cards.map((card) => card.worker); + const labels = Object.fromEntries(workers.map((w) => [w.id, { task: w.task, branch: w.branch }])); + return ( +
+

{t("nav.leitstand")}

+ {/* An unread or unreadable board must not claim "no agents". */} + {board.loading ? null : board.error !== null && workers.length === 0 + ?
+ : } +
+ ); + } + return (

{t("nav.leitstand")}

diff --git a/src/shell/mount/routing.ts b/src/shell/mount/routing.ts index c316163a..21052193 100644 --- a/src/shell/mount/routing.ts +++ b/src/shell/mount/routing.ts @@ -20,8 +20,8 @@ export const isTools = (path: string) => trim(path) === TOOLS_PATH; export const isTerminal = (path: string) => trim(path.split("?")[0]) === "/leitstand/terminal"; -/** The new Leitstand owns `/leitstand` (V2-S01a) and `/leitstand/betrieb` (V2-S01b); the rest below it stays the old view. */ -export const isNewLeitstand = (path: string) => ["/leitstand", "/leitstand/betrieb"].includes(trim(path)); +/** The new Leitstand owns `/leitstand` (V2-S01a), `/leitstand/betrieb` (V2-S01b) and `/leitstand/fluss` (COMP-3-3); the rest below it stays the old view. */ +export const isNewLeitstand = (path: string) => ["/leitstand", "/leitstand/betrieb", "/leitstand/fluss"].includes(trim(path)); /** The new Steuerung (V2-S05a) owns `/steuerung` and its tabs: Kontingente plus two honest placeholders. */ export const isNewSteuerung = (path: string) => base(trim(path)) === "/steuerung";