fix(dashboard): canvas polish batch (#12392)

Seven pointwise fixes on the Orchestration Canvas, each covered by a test:

1. Debounce x chip race: every chip/clear write in OrchestrationToolbar now cancels
   the pending search timer first. Left armed, it fired ~300ms later with a setParams
   closed over the pre-chip query string and silently reverted the chip.
2. The search input carries an aria-label (searchPlaceholder) — the placeholder alone
   is not an accessible name.
3. parseCsvSet trims each token, so `?state=running, failed` parses like the unpadded
   form instead of dropping the padded value.
4. toggleCsv was duplicated in the toolbar and the page client; both now import the
   single definition from the new model/urlParams.ts (pure, never mutates its inputs).
5. AgentsTab tells "nothing running" apart from "the filter matched nothing": with an
   active filter and no work node it renders noMatches + a clear-filters button instead
   of the setup CTAs, which would be wrong advice there.
6. Particle cap: orchestrationToFlow stamps `particles` on every edge and turns it off
   above PARTICLE_EDGE_CAP (40) simultaneously active edges — StatusEdge then renders
   the colored stroke without its 3 SMIL particles per edge.
7. The drawer's error banner clears when an action succeeds, so a recovered failure
   does not stay on screen.

Only `noMatches` is added to en.json here; the other locales are task B4.

Refs #12392
This commit is contained in:
diegosouzapw
2026-09-07 12:08:01 -03:00
parent 2351a79192
commit 3eb16de689
14 changed files with 510 additions and 43 deletions

View File

@@ -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<OrchState> = new Set(ORCH_STATES);
const VALID_SOURCES: ReadonlySet<OrchSource> = new Set(["cloud-agent", "a2a", "conductor"]);
/** CSV → Set, dropping empty/invalid entries (`valid` omitted accepts any non-empty token). */
function parseCsvSet<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
const out = new Set<T>();
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<T extends string>(current: ReadonlySet<T>, 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<Tab, string> = {
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" && (

View File

@@ -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<T extends string>(current: ReadonlySet<T>, 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<string, string | null>) => {
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]"
/>
<ChipGroup
@@ -125,14 +134,14 @@ export function OrchestrationToolbar({
values={ORCH_STATES}
active={filter.states}
renderLabel={(s) => t(STATE_KEY[s])}
onToggle={(s) => setParams({ state: toggleCsv(filter.states, s) })}
onToggle={(s) => patchParams({ state: toggleCsv(filter.states, s) })}
/>
<ChipGroup
label={t("filterSources")}
values={SOURCES}
active={filter.sources}
renderLabel={(s) => t(SOURCE_KEY[s])}
onToggle={(s) => setParams({ source: toggleCsv(filter.sources, s) })}
onToggle={(s) => patchParams({ source: toggleCsv(filter.sources, s) })}
/>
{providerKeys.length > 0 && (
<ChipGroup
@@ -140,7 +149,7 @@ export function OrchestrationToolbar({
values={providerKeys}
active={filter.providers}
renderLabel={(p) => p}
onToggle={(p) => setParams({ provider: toggleCsv(filter.providers, p) })}
onToggle={(p) => patchParams({ provider: toggleCsv(filter.providers, p) })}
/>
)}
{!isEmptyFilter(filter) && (

View File

@@ -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<boolean> {
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);
}

View File

@@ -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) => (
<ellipse
key={i}

View File

@@ -11,6 +11,13 @@ const LAYER_Y: Record<OrchNodeKind, number> = {
};
const X_GAP = 260;
/**
* Maximum simultaneously ACTIVE edges that still get the StatusEdge particle stream. Each
* animated edge runs 3 SMIL `<animateMotion>` 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<OrchSource>;
}
@@ -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

View File

@@ -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<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
const out = new Set<T>();
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<T extends string>(current: ReadonlySet<T>, 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;
}

View File

@@ -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<OrchSource>;
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 (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
<p className="text-sm">{t("noMatches")}</p>
<button type="button" className="text-xs underline" onClick={() => onClearFilters?.()}>
{t("clearFilters")}
</button>
</div>
);
}
if (!hasWork) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">

View File

@@ -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",

View File

@@ -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(
<OrchestrationDrawer node={node as never} onClose={() => {}} 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) => {

View File

@@ -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(
<svg>
<StatusEdge {...(props as unknown as EdgeProps)} />
</svg>
);
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(
<svg>
<StatusEdge {...(props as unknown as EdgeProps)} />
</svg>
);
expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(3);
cleanup();
});
});

View File

@@ -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(<OrchestrationPageClient />);
// 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(<OrchestrationPageClient />);
const props = agentsTabCalls.at(-1) as {
filter: { q: string; states: ReadonlySet<string> };
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(<OrchestrationPageClient />);
const props = agentsTabCalls.at(-1) as { filter: { states: ReadonlySet<string> } };
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(<OrchestrationPageClient />);
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(<OrchestrationPageClient />);
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(<OrchestrationPageClient />);
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();
}
});
});

View File

@@ -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<string>(),
sources: new Set<string>(),
providers: new Set<string>(),
};
it("no work node AND an active filter → noMatches + a clear-filters button, no setup CTAs", () => {
const onClearFilters = vi.fn();
const { c, cleanup } = render(
<AgentsTab
snapshot={emptySnap as never}
onNodeClick={() => {}}
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(
<AgentsTab
snapshot={emptySnap as never}
onNodeClick={() => {}}
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(
<AgentsTab
snapshot={withWork as never}
onNodeClick={() => {}}
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", () => {

View File

@@ -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);
});
});

View File

@@ -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<string>(), "a2a"), "a2a");
});
it("does not mutate the input set", () => {
const current = new Set(["running"]);
toggleCsv(current, "failed");
assert.deepEqual([...current], ["running"]);
});
});