diff --git a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx index 3d71421c52..dda844277e 100644 --- a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx @@ -12,6 +12,7 @@ import { HistoryTab } from "./tabs/HistoryTab"; import { OrchestrationDrawer } from "./drawer/OrchestrationDrawer"; import { OrchestrationToolbar } from "./OrchestrationToolbar"; import { collectProviderKeys, filterSnapshot } from "./model/filterSnapshot"; +import { parseCsvSet, toggleCsv } from "./model/urlParams"; import type { OrchFilter } from "./model/filterSnapshot"; import { ORCH_STATES } from "./model/orchestrationTypes"; import type { OrchSource, OrchState } from "./model/orchestrationTypes"; @@ -22,25 +23,6 @@ type Tab = (typeof TABS)[number]; const VALID_STATES: ReadonlySet = new Set(ORCH_STATES); const VALID_SOURCES: ReadonlySet = new Set(["cloud-agent", "a2a", "conductor"]); -/** CSV → Set, dropping empty/invalid entries (`valid` omitted accepts any non-empty token). */ -function parseCsvSet(raw: string | null, valid?: ReadonlySet): Set { - const out = new Set(); - if (!raw) return out; - for (const v of raw.split(",")) { - if (!v) continue; - if (!valid || valid.has(v as T)) out.add(v as T); - } - return out; -} - -/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */ -function toggleCsv(current: ReadonlySet, value: T): string | null { - const next = new Set(current); - if (next.has(value)) next.delete(value); - else next.add(value); - return next.size > 0 ? [...next].sort().join(",") : null; -} - const TAB_KEY: Record = { agents: "tabAgents", routing: "tabRouting", @@ -133,6 +115,10 @@ export default function OrchestrationPageClient() { [collapsed, setParams] ); const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]); + const clearFilters = useCallback( + () => setParams({ q: null, state: null, source: null, provider: null }), + [setParams] + ); const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null; const onNodeClick = (id: string) => @@ -166,6 +152,8 @@ export default function OrchestrationPageClient() { onToggleCompleted={setShowCompleted} collapsed={collapsed} onToggleCollapse={onToggleCollapse} + filter={filter} + onClearFilters={clearFilters} /> )} {tab === "routing" && ( diff --git a/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx b/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx index 0b9c32925d..a190aac079 100644 --- a/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { isEmptyFilter } from "./model/filterSnapshot"; +import { toggleCsv } from "./model/urlParams"; import type { OrchFilter } from "./model/filterSnapshot"; import { ORCH_STATES } from "./model/orchestrationTypes"; import type { OrchSource, OrchState } from "./model/orchestrationTypes"; @@ -30,14 +31,6 @@ const SOURCE_KEY: Record<(typeof SOURCES)[number], string> = { const SEARCH_DEBOUNCE_MS = 300; -/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */ -function toggleCsv(current: ReadonlySet, value: T): string | null { - const next = new Set(current); - if (next.has(value)) next.delete(value); - else next.add(value); - return next.size > 0 ? [...next].sort().join(",") : null; -} - const chipClass = (active: boolean) => `text-[10px] px-2 py-0.5 rounded-full border whitespace-nowrap ${ active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted" @@ -99,16 +92,31 @@ export function OrchestrationToolbar({ [] ); + const cancelPendingSearch = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + const handleSearchChange = (v: string) => { setText(v); - if (timerRef.current) clearTimeout(timerRef.current); + cancelPendingSearch(); timerRef.current = setTimeout(() => setParams({ q: v || null }), SEARCH_DEBOUNCE_MS); }; + /** + * Every chip write goes through here so a debounce timer armed by a keystroke moments + * earlier is dropped first. Left pending it would fire ~300ms later with a `setParams` + * closed over the PRE-chip search params and silently revert the chip the user just + * clicked (the writer rebuilds the whole query string from `params`). + */ + const patchParams = (patch: Record) => { + cancelPendingSearch(); + setParams(patch); + }; + const handleClear = () => { setText(""); - if (timerRef.current) clearTimeout(timerRef.current); - setParams({ q: null, state: null, source: null, provider: null }); + patchParams({ q: null, state: null, source: null, provider: null }); }; return ( @@ -118,6 +126,7 @@ export function OrchestrationToolbar({ value={text} onChange={(e) => handleSearchChange(e.target.value)} placeholder={t("searchPlaceholder")} + aria-label={t("searchPlaceholder")} className="text-xs px-2 py-1 rounded border border-border bg-transparent min-w-[160px]" /> t(STATE_KEY[s])} - onToggle={(s) => setParams({ state: toggleCsv(filter.states, s) })} + onToggle={(s) => patchParams({ state: toggleCsv(filter.states, s) })} /> t(SOURCE_KEY[s])} - onToggle={(s) => setParams({ source: toggleCsv(filter.sources, s) })} + onToggle={(s) => patchParams({ source: toggleCsv(filter.sources, s) })} /> {providerKeys.length > 0 && ( p} - onToggle={(p) => setParams({ provider: toggleCsv(filter.providers, p) })} + onToggle={(p) => patchParams({ provider: toggleCsv(filter.providers, p) })} /> )} {!isEmptyFilter(filter) && ( diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts index c89f3aeb68..08262248ac 100644 --- a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts +++ b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts @@ -273,7 +273,8 @@ async function jsonRpcErrorCode(res: { async function performAction( req: { url: string; init: RequestInit } | null, - setActionError: (text: string) => void + setActionError: (text: string) => void, + clearError: () => void ): Promise { if (!req) return false; try { @@ -283,6 +284,10 @@ async function performAction( const code = await jsonRpcErrorCode(res); if (code !== undefined) throw new Error(`RPC ${code}`); } + // The banner is not sticky: a retry (or any later action) that works clears whatever + // detail/action error was on screen, so the drawer never shows a failure the operator + // already recovered from. + clearError(); return true; } catch (err) { setActionError(toSafeErrorText(err)); @@ -310,7 +315,7 @@ export function useDrawerDetail(node: OrchNode | null) { if (busy) return false; setBusy(true); try { - return await performAction(req, setActionError); + return await performAction(req, setActionError, () => setErrorState(null)); } finally { setBusy(false); } diff --git a/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx b/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx index 2dea90114f..44e419753b 100644 --- a/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx @@ -17,6 +17,13 @@ interface StatusEdgeData { state?: OrchState; active?: boolean; mirror?: boolean; + /** + * Particle budget gate, set by `orchestrationToFlow` (see `PARTICLE_EDGE_CAP`). `false` + * means the canvas has too many simultaneously active edges to animate them all — the edge + * still renders its active stroke, just without the 3 SMIL particles. Absent/`true` keeps + * the animation (so a caller that never sets it behaves exactly as before). + */ + particles?: boolean; } /** Mesma precedência do edgeStyle da v1: failed > active > succeeded > idle. */ @@ -52,6 +59,7 @@ function StatusEdgeImpl(props: EdgeProps) { style={{ ...s, ...(data.mirror ? { strokeDasharray: "6 4" } : {}) }} /> {data.active && + data.particles !== false && Array.from({ length: PARTICLES }, (_, i) => ( = { }; const X_GAP = 260; +/** + * Maximum simultaneously ACTIVE edges that still get the StatusEdge particle stream. Each + * animated edge runs 3 SMIL `` particles, so a busy canvas would otherwise pay + * hundreds of concurrent animations; past the cap every edge renders as a plain colored stroke. + */ +export const PARTICLE_EDGE_CAP = 40; + export interface OrchestrationToFlowOptions { collapsed?: ReadonlySet; } @@ -65,12 +72,18 @@ export function orchestrationToFlow( >, }; }); + const particles = visibleEdges.filter((e) => e.active).length <= PARTICLE_EDGE_CAP; const edges: Edge[] = visibleEdges.map((e) => ({ id: e.id, source: e.from, target: e.to, type: "status", - data: { state: stateOf.get(e.to), active: e.active, mirror: e.kind === "mirror" }, + data: { + state: stateOf.get(e.to), + active: e.active, + mirror: e.kind === "mirror", + particles, + }, })); const workIdsKey = visibleNodes diff --git a/src/app/(dashboard)/dashboard/orchestration/model/urlParams.ts b/src/app/(dashboard)/dashboard/orchestration/model/urlParams.ts new file mode 100644 index 0000000000..8065cde92e --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/model/urlParams.ts @@ -0,0 +1,34 @@ +/** + * CSV query-param helpers shared by the Orchestration page client (which parses `?state=` / + * `?source=` / `?provider=` / `?collapsed=` out of the URL) and the toolbar (which toggles them + * back in). Both used to carry their own private copy of `toggleCsv`; a single definition keeps + * the round-trip (parse → toggle → parse) consistent. Pure — never mutates its inputs. + */ + +/** + * CSV → Set. Each token is trimmed and empty tokens are dropped, so a hand-edited or + * shared URL like `?state=running, failed` parses the same as `?state=running,failed`. + * With `valid`, tokens outside that set are discarded (an unknown state chip in the URL + * must not survive into the filter). + */ +export function parseCsvSet(raw: string | null, valid?: ReadonlySet): Set { + const out = new Set(); + if (!raw) return out; + for (const token of raw.split(",")) { + const v = token.trim(); + if (!v) continue; + if (!valid || valid.has(v as T)) out.add(v as T); + } + return out; +} + +/** + * Toggle `value` in `current`, returning the next CSV — or `null` when the list becomes + * empty, so the caller's `setParams` drops the param from the URL instead of leaving `?state=`. + */ +export function toggleCsv(current: ReadonlySet, value: T): string | null { + const next = new Set(current); + if (next.has(value)) next.delete(value); + else next.add(value); + return next.size > 0 ? [...next].sort().join(",") : null; +} diff --git a/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx b/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx index 58a9cd19bd..c94710c163 100644 --- a/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx @@ -5,6 +5,8 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { FlowCanvas } from "@/shared/components/flow/FlowCanvas"; import { orchestrationToFlow } from "../model/orchestrationToFlow"; +import { EMPTY_FILTER, isEmptyFilter } from "../model/filterSnapshot"; +import type { OrchFilter } from "../model/filterSnapshot"; import type { OrchNode, OrchSnapshot, OrchSource } from "../model/orchestrationTypes"; import { OrchestratorNode } from "../nodes/OrchestratorNode"; import { SourceNode } from "../nodes/SourceNode"; @@ -34,6 +36,8 @@ export function AgentsTab({ onToggleCompleted, collapsed = EMPTY_COLLAPSED, onToggleCollapse, + filter = EMPTY_FILTER, + onClearFilters, }: { snapshot: OrchSnapshot; onNodeClick: (orchNodeId: string) => void; @@ -41,6 +45,8 @@ export function AgentsTab({ onToggleCompleted: (v: boolean) => void; collapsed?: ReadonlySet; onToggleCollapse?: (s: OrchSource) => void; + filter?: OrchFilter; + onClearFilters?: () => void; }) { const t = useTranslations("orchestration"); const { nodes, edges, fitKey } = useMemo( @@ -58,6 +64,21 @@ export function AgentsTab({ onNodeClick(node.id); }; + // An empty canvas means two very different things. With no filter active it is "nothing is + // running" and the setup CTAs are the right next step; under an ACTIVE filter the runs may + // well exist and simply not match, so pointing the operator at the setup pages would be wrong + // advice — offer to clear the filter instead. + if (!hasWork && !isEmptyFilter(filter)) { + return ( +
+

{t("noMatches")}

+ +
+ ); + } + if (!hasWork) { return (
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 61429ab690..059fe470de 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -14021,6 +14021,7 @@ "stateCancelled": "Cancelled", "overflowMore": "+{count} more", "emptyTitle": "Nothing running yet", + "noMatches": "No runs match these filters", "emptyCloudAgentCta": "Set up Cloud Agents", "emptyA2ACta": "Open A2A", "emptyConductorCta": "Open Conductor", diff --git a/tests/unit/ui/orchestrationDrawer.test.tsx b/tests/unit/ui/orchestrationDrawer.test.tsx index 9142d22f1e..dea67b3b5b 100644 --- a/tests/unit/ui/orchestrationDrawer.test.tsx +++ b/tests/unit/ui/orchestrationDrawer.test.tsx @@ -339,6 +339,55 @@ describe("OrchestrationDrawer", () => { cleanup(); }); + // Task B3.7 — a stale error banner must not survive an action that then worked. + it("clears the error banner when a retried action succeeds", async () => { + let failNext = true; + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + const ok = !failNext; + failNext = false; + return Promise.resolve({ + ok, + status: ok ? 200 : 500, + json: () => Promise.resolve({}), + }); + } + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ data: {} }) }); + }); + vi.stubGlobal("fetch", fetchMock); + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "waiting_approval", + label: "x", + }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + }); + const approve = () => + Array.from(c.querySelectorAll("button")).find((b) => + b.textContent?.includes("actionApprove") + ) as HTMLButtonElement; + + await act(async () => { + approve().click(); + await Promise.resolve(); + }); + expect(c.textContent).toContain("actionFailed"); + + await act(async () => { + approve().click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(c.textContent).not.toContain("actionFailed"); + cleanup(); + }); + it("disables approve/cancel while an action promise is pending, and re-enables once it settles", async () => { let resolvePost: ((v: unknown) => void) | undefined; const fetchMock = vi.fn((_url: string, init?: RequestInit) => { diff --git a/tests/unit/ui/orchestrationNodes.test.tsx b/tests/unit/ui/orchestrationNodes.test.tsx index f98429700e..9d9bf4455b 100644 --- a/tests/unit/ui/orchestrationNodes.test.tsx +++ b/tests/unit/ui/orchestrationNodes.test.tsx @@ -246,4 +246,33 @@ describe("StatusEdge", () => { expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(0); cleanup(); }); + + it("data.particles === false renders the stroke only, even while active (particle cap)", () => { + const props = { + ...baseProps, + data: { state: "running", active: true, mirror: false, particles: false }, + }; + const { c, cleanup } = render( + + + + ); + expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(0); + expect(c.querySelector("path")).toBeTruthy(); + cleanup(); + }); + + it("data.particles === true keeps the particles (explicit opt-in from orchestrationToFlow)", () => { + const props = { + ...baseProps, + data: { state: "running", active: true, mirror: false, particles: true }, + }; + const { c, cleanup } = render( + + + + ); + expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(3); + cleanup(); + }); }); diff --git a/tests/unit/ui/orchestrationPage.test.tsx b/tests/unit/ui/orchestrationPage.test.tsx index 9b473a6a05..16fa5627d5 100644 --- a/tests/unit/ui/orchestrationPage.test.tsx +++ b/tests/unit/ui/orchestrationPage.test.tsx @@ -94,6 +94,13 @@ function render(el: React.ReactElement) { }, }; } +/** Types into a controlled input the way React 19 sees it (native value setter + input event). */ +function typeInto(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + afterEach(() => { document.body.innerHTML = ""; replaceMock.mockClear(); @@ -280,9 +287,7 @@ describe("OrchestrationPageClient", () => { searchState.current = "tab=agents&node=cloud-agent:1"; const { c, cleanup } = render(); // Sanity: the page-level drawer is up before switching, open on the selected node. - expect((drawerCalls.at(-1) as { node: { id: string } | null }).node?.id).toBe( - "cloud-agent:1" - ); + expect((drawerCalls.at(-1) as { node: { id: string } | null }).node?.id).toBe("cloud-agent:1"); const historyTabButton = Array.from(c.querySelectorAll('[role="tab"]')).find( (el) => el.textContent === "tabHistory" @@ -308,4 +313,94 @@ describe("OrchestrationPageClient", () => { expect(drawerCalls.length).toBe(drawerCallsBefore); cleanup(); }); + + // Task B3.5 — AgentsTab can only tell "nothing running" from "filter matched nothing" if the + // page hands it the parsed filter plus a way to clear it. + it("hands AgentsTab the parsed filter and an onClearFilters that resets q/state/source/provider", () => { + searchState.current = "tab=agents&q=login&state=running"; + const { cleanup } = render(); + const props = agentsTabCalls.at(-1) as { + filter: { q: string; states: ReadonlySet }; + onClearFilters: () => void; + }; + expect(props.filter.q).toBe("login"); + expect([...props.filter.states]).toEqual(["running"]); + act(() => { + props.onClearFilters(); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + const [url] = replaceMock.mock.calls[0]; + expect(url).not.toContain("q="); + expect(url).not.toContain("state="); + cleanup(); + }); + + // Task B3.3 — a padded CSV param (`?state=running, failed`) must parse, not silently drop. + it("parses CSV params with surrounding whitespace", () => { + searchState.current = "tab=agents&state=running,%20failed"; + const { cleanup } = render(); + const props = agentsTabCalls.at(-1) as { filter: { states: ReadonlySet } }; + expect([...props.filter.states].sort()).toEqual(["failed", "running"]); + cleanup(); + }); + + // Task B3.2 — the search box needs its own accessible name; the placeholder alone is not one. + it("the search input carries an aria-label from i18n", () => { + searchState.current = "tab=agents"; + const { c, cleanup } = render(); + const input = c.querySelector('input[type="search"]') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.getAttribute("aria-label")).toBe("searchPlaceholder"); + cleanup(); + }); + + // Task B3.1 — typing then clicking a chip: the pending debounce timer must be dropped, or it + // fires 300ms later against the pre-chip params and silently reverts the chip. + it("clicking a chip cancels the pending search debounce instead of letting it overwrite the URL", () => { + vi.useFakeTimers(); + try { + searchState.current = "tab=agents"; + const { c, cleanup } = render(); + const input = c.querySelector('input[type="search"]') as HTMLInputElement; + act(() => { + typeInto(input, "log"); + }); + expect(replaceMock).toHaveBeenCalledTimes(0); + + const runningChip = Array.from(c.querySelectorAll("button")).find( + (el) => el.textContent === "stateRunning" + ) as HTMLButtonElement; + act(() => { + runningChip.click(); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + expect(replaceMock.mock.calls[0][0]).toContain("state=running"); + cleanup(); + } finally { + vi.useRealTimers(); + } + }); + + it("the debounced search still writes ?q= when no chip interrupts it", () => { + vi.useFakeTimers(); + try { + searchState.current = "tab=agents"; + const { c, cleanup } = render(); + const input = c.querySelector('input[type="search"]') as HTMLInputElement; + act(() => { + typeInto(input, "login"); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + expect(replaceMock.mock.calls[0][0]).toContain("q=login"); + cleanup(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/tests/unit/ui/orchestrationTabs.test.tsx b/tests/unit/ui/orchestrationTabs.test.tsx index b44ec79402..bee3349d99 100644 --- a/tests/unit/ui/orchestrationTabs.test.tsx +++ b/tests/unit/ui/orchestrationTabs.test.tsx @@ -212,6 +212,94 @@ describe("AgentsTab", () => { expect(onToggleCompleted).toHaveBeenCalledWith(true); cleanup(); }); + + // Task B3.5 — an empty canvas under an ACTIVE filter is "your filter matched nothing", + // not "you have nothing configured": the setup CTAs would be wrong advice there. + const emptySnap = { + nodes: [{ id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }], + edges: [], + sources: [], + generatedAt: "x", + }; + const activeFilter = { + q: "login", + states: new Set(), + sources: new Set(), + providers: new Set(), + }; + + it("no work node AND an active filter → noMatches + a clear-filters button, no setup CTAs", () => { + const onClearFilters = vi.fn(); + const { c, cleanup } = render( + {}} + showCompleted={false} + onToggleCompleted={() => {}} + filter={activeFilter as never} + onClearFilters={onClearFilters} + /> + ); + expect(c.textContent).toContain("noMatches"); + expect(c.textContent).not.toContain("emptyTitle"); + expect(c.textContent).not.toContain("emptyCloudAgentCta"); + const clearButton = Array.from(c.querySelectorAll("button")).find( + (el) => el.textContent === "clearFilters" + ) as HTMLButtonElement; + expect(clearButton).toBeTruthy(); + act(() => { + clearButton.click(); + }); + expect(onClearFilters).toHaveBeenCalledTimes(1); + cleanup(); + }); + + it("no work node and an EMPTY filter still shows the configuration CTAs", () => { + const { c, cleanup } = render( + {}} + showCompleted={false} + onToggleCompleted={() => {}} + filter={ + { + q: "", + states: new Set(), + sources: new Set(), + providers: new Set(), + } as never + } + onClearFilters={() => {}} + /> + ); + expect(c.textContent).toContain("emptyTitle"); + expect(c.textContent).toContain("emptyCloudAgentCta"); + expect(c.textContent).not.toContain("noMatches"); + cleanup(); + }); + + it("an active filter that still matches work nodes renders the canvas, not the empty state", () => { + const withWork = { + ...emptySnap, + nodes: [ + ...emptySnap.nodes, + { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "login flow" }, + ], + }; + const { c, cleanup } = render( + {}} + showCompleted={false} + onToggleCompleted={() => {}} + filter={activeFilter as never} + onClearFilters={() => {}} + /> + ); + expect(c.querySelector('[data-testid="flow-canvas"]')).toBeTruthy(); + expect(c.textContent).not.toContain("noMatches"); + cleanup(); + }); }); describe("OverviewTab", () => { diff --git a/tests/unit/ui/orchestrationToFlow.test.ts b/tests/unit/ui/orchestrationToFlow.test.ts index e4879301e7..73352a5784 100644 --- a/tests/unit/ui/orchestrationToFlow.test.ts +++ b/tests/unit/ui/orchestrationToFlow.test.ts @@ -1,7 +1,10 @@ /** Run: node --import tsx/esm --test tests/unit/ui/orchestrationToFlow.test.ts */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { orchestrationToFlow } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts"; +import { + PARTICLE_EDGE_CAP, + orchestrationToFlow, +} from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts"; import type { OrchSnapshot } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts"; const snap: OrchSnapshot = { @@ -65,17 +68,27 @@ describe("orchestrationToFlow", () => { assert.equal(ys.get("source:a2a"), 150); assert.equal(ys.get("a2a:t1"), 320); }); - it('edges carry type "status" and data.{state,active,mirror}; no animated/style leak', () => { + it('edges carry type "status" and data.{state,active,mirror,particles}; no animated/style leak', () => { const { edges } = orchestrationToFlow(snap); const activeEdge = edges.find((e) => e.id === "e2"); assert.equal(activeEdge?.type, "status"); - assert.deepEqual(activeEdge?.data, { state: "running", active: true, mirror: false }); + assert.deepEqual(activeEdge?.data, { + state: "running", + active: true, + mirror: false, + particles: true, + }); assert.equal((activeEdge as { animated?: boolean }).animated, undefined); assert.equal((activeEdge as { style?: unknown }).style, undefined); const edgeToFailed = edges.find((e) => e.id === "e3"); assert.equal(edgeToFailed?.type, "status"); - assert.deepEqual(edgeToFailed?.data, { state: "failed", active: false, mirror: false }); + assert.deepEqual(edgeToFailed?.data, { + state: "failed", + active: false, + mirror: false, + particles: true, + }); }); it("mirror edges carry data.mirror === true", () => { const mirrorSnap: OrchSnapshot = { @@ -146,3 +159,56 @@ describe("orchestrationToFlow", () => { assert.notEqual(k1, k2); }); }); + +/** + * Particle cap (task B3.6): above PARTICLE_EDGE_CAP simultaneously active edges the canvas + * would run 3 SMIL particles per edge, so `orchestrationToFlow` tells StatusEdge to render the + * plain stroke instead (`data.particles === false`). + */ +function busySnapshot(activeEdges: number): OrchSnapshot { + const nodes: OrchSnapshot["nodes"] = [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { id: "source:a2a", kind: "source", source: "a2a", label: "A2A" }, + ]; + const edges: OrchSnapshot["edges"] = []; + for (let i = 0; i < activeEdges; i++) { + const id = `a2a:t${String(i).padStart(3, "0")}`; + nodes.push({ id, kind: "work", source: "a2a", state: "running", label: id }); + edges.push({ id: `e${i}`, from: "source:a2a", to: id, kind: "owns", active: true }); + } + return { nodes, edges, sources: [], generatedAt: "2026-09-07T00:00:00Z" }; +} + +describe("orchestrationToFlow — particle cap", () => { + it("PARTICLE_EDGE_CAP is 40", () => { + assert.equal(PARTICLE_EDGE_CAP, 40); + }); + + it("keeps particles on at exactly the cap", () => { + const { edges } = orchestrationToFlow(busySnapshot(PARTICLE_EDGE_CAP)); + assert.equal(edges.length, PARTICLE_EDGE_CAP); + assert.ok(edges.every((e) => (e.data as { particles?: boolean }).particles === true)); + }); + + it("turns particles off for every edge once the cap is exceeded", () => { + const { edges } = orchestrationToFlow(busySnapshot(PARTICLE_EDGE_CAP + 1)); + assert.ok(edges.every((e) => (e.data as { particles?: boolean }).particles === false)); + }); + + it("counts only ACTIVE edges — 41 idle edges stay under the cap", () => { + const snapWithIdle = busySnapshot(PARTICLE_EDGE_CAP + 1); + const allIdle: OrchSnapshot = { + ...snapWithIdle, + edges: snapWithIdle.edges.map((e) => ({ ...e, active: false })), + }; + const { edges } = orchestrationToFlow(allIdle); + assert.ok(edges.every((e) => (e.data as { particles?: boolean }).particles === true)); + }); + + it("counts VISIBLE active edges only — collapsing the source drops it back under the cap", () => { + const { edges } = orchestrationToFlow(busySnapshot(PARTICLE_EDGE_CAP + 1), { + collapsed: new Set(["a2a"]), + }); + assert.equal(edges.length, 0); + }); +}); diff --git a/tests/unit/ui/orchestrationUrlParams.test.ts b/tests/unit/ui/orchestrationUrlParams.test.ts new file mode 100644 index 0000000000..88f3792235 --- /dev/null +++ b/tests/unit/ui/orchestrationUrlParams.test.ts @@ -0,0 +1,61 @@ +/** Run: node --import tsx/esm --test tests/unit/ui/orchestrationUrlParams.test.ts */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + parseCsvSet, + toggleCsv, +} from "../../../src/app/(dashboard)/dashboard/orchestration/model/urlParams.ts"; + +describe("parseCsvSet", () => { + it("returns an empty set for null/empty input", () => { + assert.deepEqual([...parseCsvSet(null)], []); + assert.deepEqual([...parseCsvSet("")], []); + }); + + it("trims each token so ' a2a, conductor ' parses like 'a2a,conductor'", () => { + assert.deepEqual([...parseCsvSet(" a2a, conductor ")].sort(), ["a2a", "conductor"]); + }); + + it("drops empty tokens produced by stray commas/whitespace", () => { + assert.deepEqual([...parseCsvSet("a2a,, ,conductor,")].sort(), ["a2a", "conductor"]); + }); + + it("drops values outside `valid` (after trimming, so a padded valid token survives)", () => { + const valid = new Set(["running", "failed"]); + assert.deepEqual([...parseCsvSet(" running , bogus ", valid)], ["running"]); + }); + + it("accepts any non-empty token when `valid` is omitted", () => { + assert.deepEqual([...parseCsvSet("devin, jules", undefined)].sort(), ["devin", "jules"]); + }); + + it("does not mutate the `valid` set it is given", () => { + const valid = new Set(["running"]); + parseCsvSet("running,bogus", valid); + assert.deepEqual([...valid], ["running"]); + }); +}); + +describe("toggleCsv", () => { + it("adds a missing value and returns a sorted CSV", () => { + assert.equal(toggleCsv(new Set(["running"]), "failed"), "failed,running"); + }); + + it("removes a present value", () => { + assert.equal(toggleCsv(new Set(["failed", "running"]), "failed"), "running"); + }); + + it("returns null when the list becomes empty (so the param leaves the URL)", () => { + assert.equal(toggleCsv(new Set(["running"]), "running"), null); + }); + + it("returns the single value when toggling into an empty set", () => { + assert.equal(toggleCsv(new Set(), "a2a"), "a2a"); + }); + + it("does not mutate the input set", () => { + const current = new Set(["running"]); + toggleCsv(current, "failed"); + assert.deepEqual([...current], ["running"]); + }); +});