diff --git a/changelog.d/features/orchestration-canvas-fase2-quickwins.md b/changelog.d/features/orchestration-canvas-fase2-quickwins.md new file mode 100644 index 00000000000..a0185be59e1 --- /dev/null +++ b/changelog.d/features/orchestration-canvas-fase2-quickwins.md @@ -0,0 +1,11 @@ +- **feat(dashboard):** Orchestration canvas quick wins — search box plus state/source/provider + filter chips with a one-click clear, and per-source collapse/expand, all reflected in the URL + so a filtered/collapsed view is shareable and survives a refresh; the detail drawer gained a + "copy trace JSON" action and hardened error/empty-state and accessibility handling; the + Agents-tab edges now animate traveling particles along active (running) connections; and the + canvas node/edge status colors moved off fixed hex values onto theme-aware `--orch-status-*` + CSS custom properties, so they adapt correctly to light/dark mode. +- **chore(dashboard):** Orchestration UI hardening pass and the missing component/model test + coverage it called for — `OrchestratorNode`/`ActivityNode`/`OverflowNode` rendering, the + `?node=`/overflow-click page routing, the Agents-tab orchestrator-click no-op and + `showCompleted` toggle, and the overview kanban's done-column sort order (#12270, #12271). diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 7784599528c..978a5f13211 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -741,7 +741,7 @@ function formatComboEntryDisplay( return `${providerLabel}/${modelLabel}`; } -export default function CombosPage() { +function CombosPageContent() { const t = useTranslations("combos"); const tc = useTranslations("common"); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); @@ -1373,6 +1373,14 @@ export default function CombosPage() { ); } +export default function CombosPage() { + return ( + + + + ); +} + const COMBO_WIZARD_STEPS = [ { step: 1, diff --git a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx index 707e5ea5bb1..bf5df32cf36 100644 --- a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useLiveComboStatus } from "@/hooks/useLiveDashboard"; @@ -9,12 +9,49 @@ import { AgentsTab } from "./tabs/AgentsTab"; import { RoutingTab } from "./tabs/RoutingTab"; import { OverviewTab } from "./tabs/OverviewTab"; import { OrchestrationDrawer } from "./drawer/OrchestrationDrawer"; +import { OrchestrationToolbar } from "./OrchestrationToolbar"; +import { collectProviderKeys, filterSnapshot } from "./model/filterSnapshot"; +import type { OrchFilter } from "./model/filterSnapshot"; +import { ORCH_STATES } from "./model/orchestrationTypes"; +import type { OrchSource, OrchState } from "./model/orchestrationTypes"; const TABS = ["agents", "routing", "overview"] as const; type Tab = (typeof TABS)[number]; -export default function OrchestrationPageClient() { - const t = useTranslations("orchestration"); +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", + overview: "tabOverview", +}; + +/** + * The page's entire URL state (tab / selected node / filters / collapsed groups) plus the + * writer that patches it back into the query string. Pure derivation over + * `useSearchParams` — no state of its own, so the URL stays the single source of truth. + */ +function useOrchUrlState() { const router = useRouter(); const pathname = usePathname(); const params = useSearchParams(); @@ -22,7 +59,11 @@ export default function OrchestrationPageClient() { const tab: Tab = (TABS as readonly string[]).includes(params.get("tab") ?? "") ? (params.get("tab") as Tab) : "agents"; - const nodeId = params.get("node"); + const qParam = params.get("q") ?? ""; + const stateParam = params.get("state"); + const sourceParam = params.get("source"); + const providerParam = params.get("provider"); + const collapsedParam = params.get("collapsed"); const setParams = useCallback( (patch: Record) => { @@ -33,69 +74,108 @@ export default function OrchestrationPageClient() { [params, pathname, router] ); + const filter: OrchFilter = useMemo( + () => ({ + q: qParam, + states: parseCsvSet(stateParam, VALID_STATES), + sources: parseCsvSet(sourceParam, VALID_SOURCES), + providers: parseCsvSet(providerParam), + }), + [qParam, stateParam, sourceParam, providerParam] + ); + const collapsed = useMemo(() => parseCsvSet(collapsedParam, VALID_SOURCES), [collapsedParam]); + + return { tab, nodeId: params.get("node"), filter, collapsed, setParams }; +} + +/** The tab strip. Presentation only — selecting a tab writes it back to the URL. */ +function TabList({ + tab, + t, + onSelect, +}: { + tab: Tab; + t: ReturnType; + onSelect: (tab: Tab) => void; +}) { + return ( +
+ {TABS.map((tb) => ( + + ))} +
+ ); +} + +export default function OrchestrationPageClient() { + const t = useTranslations("orchestration"); + const { tab, nodeId, filter, collapsed, setParams } = useOrchUrlState(); + const { snapshot, showCompleted, setShowCompleted, refetch } = useOrchestrationSnapshot(); const { comboEvents, activeCombos, isConnected } = useLiveComboStatus(); const { providerHealth, connectionHealth } = useProviderBreakerHealth(); + const filtered = useMemo(() => filterSnapshot(snapshot, filter), [snapshot, filter]); + const providerKeys = useMemo(() => collectProviderKeys(snapshot), [snapshot]); + + const onToggleCollapse = useCallback( + (s: OrchSource) => setParams({ collapsed: toggleCsv(collapsed, s) }), + [collapsed, setParams] + ); + const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]); + const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null; const onNodeClick = (id: string) => id.startsWith("overflow:") ? setParams({ tab: "overview", node: null }) : setParams({ node: id }); - const TAB_KEY: Record = { - agents: "tabAgents", - routing: "tabRouting", - overview: "tabOverview", - }; - return (
-
- {TABS.map((tb) => ( - - ))} -
-
- {tab === "agents" && ( - - )} - {tab === "routing" && ( - - )} - {tab === "overview" && ( - setParams({ node: id })} - onSeeInGraph={(id) => setParams({ tab: "agents", node: id })} - /> + setParams({ tab: tb })} /> +
+ {(tab === "agents" || tab === "overview") && ( + )} +
+ {tab === "agents" && ( + + )} + {tab === "routing" && ( + + )} + {tab === "overview" && ( + setParams({ node: id })} + onSeeInGraph={(id) => setParams({ tab: "agents", node: id })} + /> + )} +
- setParams({ node: null })} - onActionDone={refetch} - /> +
); } diff --git a/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx b/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx new file mode 100644 index 00000000000..0b9c32925d2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/OrchestrationToolbar.tsx @@ -0,0 +1,157 @@ +"use client"; +/** + * Search input + filter chips for the Agents/Overview tabs — pure presentation over the URL + * params owned by OrchestrationPageClient (`q`/`state`/`source`/`provider`). No filtering logic + * lives here; it renders `filter` (an `OrchFilter` already parsed from the URL) and calls + * `setParams` to mutate it. Spec: task-a6-brief.md (2.3+2.4). + */ +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { isEmptyFilter } from "./model/filterSnapshot"; +import type { OrchFilter } from "./model/filterSnapshot"; +import { ORCH_STATES } from "./model/orchestrationTypes"; +import type { OrchSource, OrchState } from "./model/orchestrationTypes"; + +const SOURCES = ["cloud-agent", "a2a", "conductor"] as const satisfies readonly OrchSource[]; + +const STATE_KEY: Record = { + queued: "stateQueued", + running: "stateRunning", + waiting_approval: "stateWaitingApproval", + succeeded: "stateSucceeded", + failed: "stateFailed", + cancelled: "stateCancelled", +}; +const SOURCE_KEY: Record<(typeof SOURCES)[number], string> = { + "cloud-agent": "sourceCloudAgent", + a2a: "sourceA2A", + conductor: "sourceConductor", +}; + +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" + }`; + +/** + * One labeled row of toggle chips (states / sources / providers). Pure presentation: + * `active` drives the pressed style + `aria-pressed`, `onToggle` writes the URL param + * upstream. Extracted so the toolbar itself stays under the max-lines ratchet. + */ +function ChipGroup({ + label, + values, + active, + renderLabel, + onToggle, +}: { + label: string; + values: readonly T[]; + active: ReadonlySet; + renderLabel: (value: T) => string; + onToggle: (value: T) => void; +}) { + return ( +
+ {label} + {values.map((v) => ( + + ))} +
+ ); +} + +export function OrchestrationToolbar({ + filter, + providerKeys, + setParams, +}: { + filter: OrchFilter; + providerKeys: string[]; + setParams: (patch: Record) => void; +}) { + const t = useTranslations("orchestration"); + const [text, setText] = useState(filter.q); + const timerRef = useRef | null>(null); + + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [] + ); + + const handleSearchChange = (v: string) => { + setText(v); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setParams({ q: v || null }), SEARCH_DEBOUNCE_MS); + }; + + const handleClear = () => { + setText(""); + if (timerRef.current) clearTimeout(timerRef.current); + setParams({ q: null, state: null, source: null, provider: null }); + }; + + return ( +
+ handleSearchChange(e.target.value)} + placeholder={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) })} + /> + t(SOURCE_KEY[s])} + onToggle={(s) => setParams({ source: toggleCsv(filter.sources, s) })} + /> + {providerKeys.length > 0 && ( + p} + onToggle={(p) => setParams({ provider: toggleCsv(filter.providers, p) })} + /> + )} + {!isEmptyFilter(filter) && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx index 5c49f9d5777..39f5edcef0f 100644 --- a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx @@ -1,12 +1,35 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { StatusDot } from "@/shared/components/flow/StatusDot"; import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes"; import { useDrawerDetail } from "./useDrawerDetail"; +import type { DrawerError } from "./useDrawerDetail"; import type { CloudAgentTask } from "@/lib/cloudAgent/types"; import type { A2ATask } from "@/lib/a2a/taskManager"; +const TOAST_MS = 2500; + +/** Timeline normalized by source — the same data the Timeline component displays. */ +function normalizedTimeline(node: OrchNode, detail: unknown): unknown { + if (node.source === "cloud-agent") return (detail as CloudAgentTask | null)?.activities ?? []; + if (node.source === "a2a") return (detail as A2ATask | null)?.events ?? []; + return null; // conductor/overflow: the raw payload already is the trace +} + +/** Builds the copy-to-clipboard JSON payload for the drawer's "copy trace" action. */ +export function buildTraceJson(node: OrchNode, detail: unknown): string { + return JSON.stringify( + { + node: { id: node.id, source: node.source, state: node.state, label: node.label }, + timeline: normalizedTimeline(node, detail), + raw: detail ?? node.raw ?? null, + }, + null, + 2 + ); +} + type Translate = ReturnType; const STATE_KEY: Record = { @@ -72,18 +95,30 @@ function Timeline({ node, detail }: { node: OrchNode; detail: unknown }) { ); } -/** Header row: status dot, label/source/state, close button. */ +/** Header row: status dot, label/source/state, copy-trace + close buttons. */ function DrawerHeader({ node, + detail, state, t, onClose, + onToast, }: { node: OrchNode; + detail: unknown; state: OrchState; t: Translate; onClose: () => void; + onToast: (text: string) => void; }) { + const copyTrace = async () => { + try { + await navigator.clipboard.writeText(buildTraceJson(node, detail)); + onToast(t("actionDone")); + } catch { + onToast(t("actionFailed", { error: "clipboard" })); + } + }; return (
- +
); } +/** + * Narrows the loaded detail payload to the typed shape of the node's source — the + * non-matching one is always `null`, so each section can read its own shape safely. + */ +function narrowDetail( + node: OrchNode, + detail: unknown +): { ca: CloudAgentTask | null; a2a: A2ATask | null } { + return { + ca: node.source === "cloud-agent" ? (detail as CloudAgentTask | null) : null, + a2a: node.source === "a2a" ? (detail as A2ATask | null) : null, + }; +} + +/** Objective section: the agent prompt / first A2A message, falling back to the node labels. */ +function DrawerObjective({ + node, + ca, + a2a, + t, +}: { + node: OrchNode; + ca: CloudAgentTask | null; + a2a: A2ATask | null; + t: Translate; +}) { + return ( +
+

+ {ca?.prompt ?? a2a?.input?.messages[0]?.content ?? node.sublabel ?? node.label} +

+
+ ); +} + +/** Transient banners above the sections: toast, load/action error, loading placeholder. */ +function DrawerBanners({ + toast, + error, + errorKind, + isLoading, + t, +}: { + toast: string | null; + error: string | null; + errorKind: DrawerError["kind"] | null; + isLoading: boolean; + t: Translate; +}) { + return ( + <> + {toast &&
{toast}
} + {error && ( +
+ {t(errorKind === "detail" ? "detailFailed" : "actionFailed", { error })} +
+ )} + {isLoading &&
…
} + + ); +} + /** Cost/duration metrics section — omitted entirely when neither value is present. */ function DrawerMetrics({ node, @@ -160,37 +260,46 @@ function DrawerResult({ function DrawerActions({ canApprove, canCancel, + busy, approve, cancel, onActionDone, + onToast, t, }: { canApprove: boolean; canCancel: boolean; + busy: boolean; approve: () => Promise; cancel: () => Promise; onActionDone: () => void; + onToast: (text: string) => void; t: Translate; }) { if (!canApprove && !canCancel) return null; const run = async (fn: () => Promise) => { - if (await fn()) onActionDone(); + if (await fn()) { + onActionDone(); + onToast(t("actionDone")); + } }; return (
{canApprove && ( )} {canCancel && ( @@ -200,14 +309,42 @@ function DrawerActions({ ); } -/** Closes the drawer on Escape while `node` is set. */ +/** Closes the drawer on Escape while `node` is set. Rebinds by id, not by object + * identity, so a fresh `node` reference for the same task (e.g. a refetch) does not + * tear down and re-add the listener. */ function useCloseOnEscape(node: OrchNode | null, onClose: () => void) { + const nodeId = node?.id ?? null; useEffect(() => { - if (!node) return; + if (!nodeId) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [node, onClose]); + }, [nodeId, onClose]); +} + +/** + * Local, self-clearing toast state. `showToast` starts the timer synchronously in the + * same handler that sets the message (button onClick / async action callback) — never + * inside an effect body — so the only thing the unmount effect does is clear a pending + * timer, with no setState call of its own (keeps `react-hooks/set-state-in-effect` clean). + */ +function useDrawerToast() { + const [toast, setToast] = useState(null); + const timerRef = useRef | null>(null); + + const showToast = (text: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + setToast(text); + timerRef.current = setTimeout(() => setToast(null), TOAST_MS); + }; + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + return { toast, showToast }; } export function OrchestrationDrawer({ @@ -220,14 +357,14 @@ export function OrchestrationDrawer({ onActionDone: () => void; }) { const t = useTranslations("orchestration"); - const { detail, isLoading, error, canApprove, canCancel, approve, cancel } = + const { detail, isLoading, busy, error, errorKind, canApprove, canCancel, approve, cancel } = useDrawerDetail(node); useCloseOnEscape(node, onClose); + const { toast, showToast } = useDrawerToast(); if (!node) return null; const state = node.state ?? "queued"; - const ca = node.source === "cloud-agent" ? (detail as CloudAgentTask | null) : null; - const a2a = node.source === "a2a" ? (detail as A2ATask | null) : null; + const { ca, a2a } = narrowDetail(node, detail); return ( <> @@ -237,16 +374,24 @@ export function OrchestrationDrawer({ role="dialog" aria-label={node.label} > - + - {error &&
{t("actionFailed", { error })}
} - {isLoading &&
…
} + -
-

- {ca?.prompt ?? a2a?.input?.messages[0]?.content ?? node.sublabel ?? node.label} -

-
+
@@ -255,9 +400,11 @@ export function OrchestrationDrawer({ diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts index 1412e113008..33af4d3ab9b 100644 --- a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts +++ b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts @@ -81,6 +81,12 @@ function deriveActionAvailability(route: SourceRoute | null, node: OrchNode | nu return { canApprove, canCancel }; } +/** Origin-tagged detail error, so the drawer can pick `detailFailed` vs `actionFailed` honestly. */ +export interface DrawerError { + kind: "detail" | "action"; + text: string; +} + /** * Resets `detail`/`error`/`isLoading` during render when the selected node * identity changes — React's documented "adjust state when a prop changes" @@ -90,7 +96,7 @@ function useSyncedNodeIdentity( node: OrchNode | null, route: SourceRoute | null, setDetail: (d: unknown | null) => void, - setError: (e: string | null) => void, + setError: (e: DrawerError | null) => void, setIsLoading: (b: boolean) => void ) { const [syncedId, setSyncedId] = useState(undefined); @@ -114,7 +120,7 @@ function useFetchDetail( node: OrchNode | null, route: SourceRoute | null, setDetail: (d: unknown | null) => void, - setError: (e: string | null) => void, + setDetailError: (text: string) => void, setIsLoading: (b: boolean) => void ) { useEffect(() => { @@ -124,7 +130,7 @@ function useFetchDetail( .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))) .then((body) => setDetail(unwrapDetailBody(node.id, body))) .catch((err) => { - if (!controller.signal.aborted) setError(toSafeErrorText(err)); + if (!controller.signal.aborted) setDetailError(toSafeErrorText(err)); }) .finally(() => setIsLoading(false)); return () => controller.abort(); @@ -134,7 +140,7 @@ function useFetchDetail( async function performAction( req: { url: string; init: RequestInit } | null, - setError: (e: string | null) => void + setActionError: (text: string) => void ): Promise { if (!req) return false; try { @@ -142,7 +148,7 @@ async function performAction( if (!res.ok) throw new Error(`HTTP ${res.status}`); return true; } catch (err) { - setError(toSafeErrorText(err)); + setActionError(toSafeErrorText(err)); return false; } } @@ -150,21 +156,37 @@ async function performAction( export function useDrawerDetail(node: OrchNode | null) { const [detail, setDetail] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setErrorState] = useState(null); const route = node ? routeFor(node) : null; - useSyncedNodeIdentity(node, route, setDetail, setError, setIsLoading); - useFetchDetail(node, route, setDetail, setError, setIsLoading); + const setDetailError = (text: string) => setErrorState({ kind: "detail", text }); + const setActionError = (text: string) => setErrorState({ kind: "action", text }); + + useSyncedNodeIdentity(node, route, setDetail, setErrorState, setIsLoading); + useFetchDetail(node, route, setDetail, setDetailError, setIsLoading); const { canApprove, canCancel } = deriveActionAvailability(route, node); + const runAction = async (req: { url: string; init: RequestInit } | null): Promise => { + if (busy) return false; + setBusy(true); + try { + return await performAction(req, setActionError); + } finally { + setBusy(false); + } + }; + return { detail, isLoading, - error, + busy, + error: error?.text ?? null, + errorKind: error?.kind ?? null, canApprove, canCancel, - approve: () => performAction(route?.approveReq ?? null, setError), - cancel: () => performAction(route?.cancelReq ?? null, setError), + approve: () => runAction(route?.approveReq ?? null), + cancel: () => runAction(route?.cancelReq ?? null), }; } diff --git a/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx b/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx new file mode 100644 index 00000000000..2dea90114ff --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/edges/StatusEdge.tsx @@ -0,0 +1,83 @@ +/** + * Custom particle-stream edge for the Orchestration Canvas — replaces xyflow's built-in + * `animated: true` marching-ants (perf cost at scale, see + * .agents/skills/flow-studio/references/animated-edges.md §1) with the §3 "particle stream" + * recipe: staggered SMIL `` shapes traveling the edge's own bezier path. Two hard + * rules from that recipe (both real defects, kept verbatim): the opacity gate (`opacity="0"` + * + a paired ``) prevents a parked particle flashing at the SVG origin before its + * `begin` fires, and clock offsets must be plain seconds — `begin="id.begin"` syncbase + * references silently never fire once mounted inside React. + */ +"use client"; +import { memo } from "react"; +import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react"; +import { orchStateColor, type OrchState } from "../model/orchestrationTypes"; + +interface StatusEdgeData { + state?: OrchState; + active?: boolean; + mirror?: boolean; +} + +/** Mesma precedência do edgeStyle da v1: failed > active > succeeded > idle. */ +function strokeFor(d: StatusEdgeData): { stroke: string; strokeWidth: number; opacity: number } { + if (d.state === "failed") + return { stroke: orchStateColor("failed"), strokeWidth: 2, opacity: 0.85 }; + if (d.active) return { stroke: orchStateColor("succeeded"), strokeWidth: 2.5, opacity: 1 }; + if (d.state === "succeeded") + return { stroke: orchStateColor("succeeded"), strokeWidth: 1.5, opacity: 0.4 }; + return { stroke: "var(--color-text-muted)", strokeWidth: 1, opacity: 0.3 }; +} + +const PARTICLES = 3; +const DUR = 2.4; + +function StatusEdgeImpl(props: EdgeProps) { + const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = props; + const data = (props.data ?? {}) as StatusEdgeData; + const [path] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + const s = strokeFor(data); + return ( + <> + + {data.active && + Array.from({ length: PARTICLES }, (_, i) => ( + + + + + ))} + + ); +} + +export const StatusEdge = memo(StatusEdgeImpl); +StatusEdge.displayName = "StatusEdge"; diff --git a/src/app/(dashboard)/dashboard/orchestration/hooks/useOrchestrationSnapshot.ts b/src/app/(dashboard)/dashboard/orchestration/hooks/useOrchestrationSnapshot.ts index 6de9367b216..fea94f73d15 100644 --- a/src/app/(dashboard)/dashboard/orchestration/hooks/useOrchestrationSnapshot.ts +++ b/src/app/(dashboard)/dashboard/orchestration/hooks/useOrchestrationSnapshot.ts @@ -31,6 +31,29 @@ async function fetchJson(url: string, signal: AbortSignal): Promise { return res.json() as Promise; } +/** + * A cheap structural fingerprint of the fields that actually affect rendering. + * `polledAt`/`raw` change on every 5s poll even when nothing meaningful moved, + * which would otherwise re-mint every node/edge array each tick and defeat the + * `React.memo` on the canvas node components. Exported for the test. + */ +export function snapshotContentKey(s: OrchSnapshot): string { + return JSON.stringify([ + s.nodes.map((n) => [ + n.id, + n.state, + n.updatedAt, + n.label, + n.sublabel, + n.cost, + n.counts, + n.sourceIssue, + ]), + s.edges.map((e) => [e.id, e.active]), + s.sources, + ]); +} + /** Builds the 3-source status list from a `Promise.allSettled` triple. */ function buildSourceStatuses( ca: PromiseSettledResult<{ data: CloudAgentTask[] }>, @@ -153,5 +176,19 @@ export function useOrchestrationSnapshot() { [raw, statuses, showCompleted, polledAt] ); - return { snapshot, isLoading, showCompleted, setShowCompleted, refetch }; + // `polledAt` advances every poll tick and re-mints every node/edge array in + // `snapshot` above even when nothing meaningful changed, which would defeat + // the canvas node components' `React.memo`. Render-time-sync idiom (same + // pattern as `useSyncedNodeIdentity` in `useDrawerDetail.ts`): only adopt the + // freshly computed snapshot when its content key actually differs, so + // `stableSnapshot` keeps referential identity across no-op ticks. + const [syncedKey, setSyncedKey] = useState(""); + const [stableSnapshot, setStableSnapshot] = useState(snapshot); + const key = snapshotContentKey(snapshot); + if (key !== syncedKey) { + setSyncedKey(key); + setStableSnapshot(snapshot); + } + + return { snapshot: stableSnapshot, isLoading, showCompleted, setShowCompleted, refetch }; } diff --git a/src/app/(dashboard)/dashboard/orchestration/model/filterSnapshot.ts b/src/app/(dashboard)/dashboard/orchestration/model/filterSnapshot.ts new file mode 100644 index 00000000000..98999e4e54c --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/model/filterSnapshot.ts @@ -0,0 +1,98 @@ +/** + * Pure client-side filter over an OrchSnapshot — full-text search + state/source/provider chips. + * No React, no side effects. Spec: _tasks/superpowers/specs/2026-08-30-orchestration-canvas-design.md §1/2.4 + * + * Only `work` nodes are tested against the filter dimensions. `activity` nodes always follow + * their parent work node (id = `${workId}:activity`) — they survive iff the parent does. + * `orchestrator` / `source` / `overflow` nodes are always kept. SourceNode `counts` (and + * overflow `droppedByState`) are NOT recalculated here — they keep showing the TRUE totals + * even while the filter hides nodes from the canvas; only visibility is affected. + */ +import type { OrchNode, OrchSnapshot, OrchSource, OrchState } from "./orchestrationTypes"; + +export interface OrchFilter { + q: string; + states: ReadonlySet; + sources: ReadonlySet; + providers: ReadonlySet; +} + +export const EMPTY_FILTER: OrchFilter = { + q: "", + states: new Set(), + sources: new Set(), + providers: new Set(), +}; + +export function isEmptyFilter(f: OrchFilter): boolean { + return f.q === "" && f.states.size === 0 && f.sources.size === 0 && f.providers.size === 0; +} + +/** + * The provider identity of a work node, or `null` when its source has no provider concept + * (a2a, routing) or the raw payload doesn't carry one. + */ +export function nodeProviderKey(node: OrchNode): string | null { + if (node.source === "cloud-agent") { + return (node.raw as { providerId?: string } | undefined)?.providerId ?? null; + } + if (node.source === "conductor") { + return (node.raw as { runner?: string | null } | undefined)?.runner ?? null; + } + return null; +} + +/** Distinct non-null provider keys among the snapshot's work nodes, sorted. */ +export function collectProviderKeys(snap: OrchSnapshot): string[] { + const keys = new Set(); + for (const n of snap.nodes) { + if (n.kind !== "work") continue; + const key = nodeProviderKey(n); + if (key !== null) keys.add(key); + } + return [...keys].sort(); +} + +function matchesWork(node: OrchNode, f: OrchFilter): boolean { + if (f.q) { + const haystack = `${node.label} ${node.sublabel ?? ""} ${node.id}`.toLowerCase(); + if (!haystack.includes(f.q.toLowerCase())) return false; + } + if (f.states.size > 0 && (!node.state || !f.states.has(node.state))) return false; + if (f.sources.size > 0 && (!node.source || !f.sources.has(node.source))) return false; + if (f.providers.size > 0) { + const key = nodeProviderKey(node); + if (key === null || !f.providers.has(key)) return false; + } + return true; +} + +const ACTIVITY_SUFFIX = ":activity"; + +function activityParentId(id: string): string { + return id.endsWith(ACTIVITY_SUFFIX) ? id.slice(0, -ACTIVITY_SUFFIX.length) : id; +} + +/** + * Filters a snapshot down to the nodes/edges matching `f` (all non-empty dimensions AND + * together). Returns `snap` itself (same reference) when `f` is empty, so callers can memoize + * on the previous result instead of re-rendering on every keystroke of a cleared search box. + */ +export function filterSnapshot(snap: OrchSnapshot, f: OrchFilter): OrchSnapshot { + if (isEmptyFilter(f)) return snap; + + const workSurvivors = new Set(); + for (const n of snap.nodes) { + if (n.kind === "work" && matchesWork(n, f)) workSurvivors.add(n.id); + } + + const nodes = snap.nodes.filter((n) => { + if (n.kind === "work") return workSurvivors.has(n.id); + if (n.kind === "activity") return workSurvivors.has(activityParentId(n.id)); + return true; // orchestrator, source, overflow always survive + }); + const survivingIds = new Set(nodes.map((n) => n.id)); + const edges = snap.edges.filter((e) => survivingIds.has(e.from) && survivingIds.has(e.to)); + + return { ...snap, nodes, edges }; +} diff --git a/src/app/(dashboard)/dashboard/orchestration/model/mergeSnapshot.ts b/src/app/(dashboard)/dashboard/orchestration/model/mergeSnapshot.ts index 3f879c40f88..6b5022f69a8 100644 --- a/src/app/(dashboard)/dashboard/orchestration/model/mergeSnapshot.ts +++ b/src/app/(dashboard)/dashboard/orchestration/model/mergeSnapshot.ts @@ -112,7 +112,9 @@ function overflowNodeForSource( // Additive: lets overviewProjection fold true per-state totals into its // counters even though these nodes no longer render on the canvas // (operator ruling — spec governs, counters must show TRUE totals). - droppedByState: counts, + // Spread into a fresh object — sharing the `counts` reference is a mutation + // footgun (a caller mutating either field silently corrupts the other). + droppedByState: { ...counts }, }; } @@ -177,6 +179,8 @@ function buildRootAndSourceEdges( source: s.source, label: s.source, sublabel: s.offline ? "offline" : "error", + sourceIssue: s.offline ? "offline" : "error", + staleSince: s.staleSince, }); sourceIds.add(`source:${s.source}`); } diff --git a/src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts b/src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts index 1c3971cd874..30bc1ff64cb 100644 --- a/src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts +++ b/src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts @@ -1,7 +1,6 @@ /** OrchSnapshot → @xyflow nodes/edges with a deterministic shallow 3-layer layout. Pure. */ import type { Edge, Node } from "@xyflow/react"; -import { edgeStyle } from "@/shared/components/flow/edgeStyles"; -import type { OrchNodeKind, OrchSnapshot } from "./orchestrationTypes"; +import type { OrchNodeKind, OrchSnapshot, OrchSource } from "./orchestrationTypes"; const LAYER_Y: Record = { orchestrator: 0, @@ -12,13 +11,36 @@ const LAYER_Y: Record = { }; const X_GAP = 260; -export function orchestrationToFlow(snap: OrchSnapshot): { +export interface OrchestrationToFlowOptions { + collapsed?: ReadonlySet; +} + +export function orchestrationToFlow( + snap: OrchSnapshot, + opts?: OrchestrationToFlowOptions +): { nodes: Node[]; edges: Edge[]; fitKey: string; } { + const collapsed = opts?.collapsed; + const hasCollapsed = !!collapsed && collapsed.size > 0; + + // Drop work/activity/overflow nodes whose source is collapsed BEFORE layout, so the + // remaining nodes recenter into their layer instead of leaving gaps. + const visibleNodes = hasCollapsed + ? snap.nodes.filter((n) => { + if (n.kind !== "work" && n.kind !== "activity" && n.kind !== "overflow") return true; + return !(n.source && collapsed!.has(n.source)); + }) + : snap.nodes; + const visibleIds = hasCollapsed ? new Set(visibleNodes.map((n) => n.id)) : null; + const visibleEdges = visibleIds + ? snap.edges.filter((e) => visibleIds.has(e.from) && visibleIds.has(e.to)) + : snap.edges; + const byLayer = new Map(); - for (const n of [...snap.nodes].sort((a, b) => a.id.localeCompare(b.id))) { + for (const n of [...visibleNodes].sort((a, b) => a.id.localeCompare(b.id))) { const y = LAYER_Y[n.kind]; const ids = byLayer.get(y) ?? []; ids.push(n.id); @@ -30,29 +52,34 @@ export function orchestrationToFlow(snap: OrchSnapshot): { ids.forEach((id, i) => pos.set(id, { x: i * X_GAP - width / 2, y })); } - const stateOf = new Map(snap.nodes.map((n) => [n.id, n.state])); - const nodes: Node[] = snap.nodes.map((n) => ({ - id: n.id, - type: n.kind, - position: pos.get(n.id)!, - data: n as unknown as Record, - })); - const edges: Edge[] = snap.edges.map((e) => { - const target = stateOf.get(e.to); - const style = edgeStyle(e.active, false, target === "failed", target === "succeeded"); + const stateOf = new Map(visibleNodes.map((n) => [n.id, n.state])); + const nodes: Node[] = visibleNodes.map((n) => { + const isCollapsedSource = n.kind === "source" && !!n.source && !!collapsed?.has(n.source); return { - id: e.id, - source: e.from, - target: e.to, - animated: e.active, - style: e.kind === "mirror" ? { ...style, strokeDasharray: "6 4" } : style, + id: n.id, + type: n.kind, + position: pos.get(n.id)!, + data: (isCollapsedSource ? { ...n, collapsed: true } : n) as unknown as Record< + string, + unknown + >, }; }); + 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" }, + })); - const fitKey = snap.nodes + const workIdsKey = visibleNodes .filter((n) => n.kind === "work") .map((n) => n.id) .sort() .join("|"); + const fitKey = hasCollapsed + ? `${workIdsKey}::collapsed=${[...collapsed!].sort().join(",")}` + : workIdsKey; return { nodes, edges, fitKey }; } diff --git a/src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts b/src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts index 8c8426a5720..0920e36d2fb 100644 --- a/src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts +++ b/src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts @@ -2,12 +2,14 @@ * Pure domain vocabulary for the Orchestration Canvas — no React, no side effects. * Spec: _tasks/superpowers/specs/2026-08-30-orchestration-canvas-design.md */ -import { STATUS_HEX } from "@/shared/constants/statusColors"; export type OrchState = "queued" | "running" | "waiting_approval" | "succeeded" | "failed" | "cancelled"; export type OrchSource = "cloud-agent" | "a2a" | "conductor" | "routing"; export type OrchNodeKind = "orchestrator" | "source" | "work" | "activity" | "overflow"; +// SourceNode only: why a source placeholder was materialized — replaces the +// magic-string comparison against `sublabel` ("error"/"offline") with a typed union. +export type SourceIssue = "error" | "offline"; export interface OrchNode { id: string; // `${source}:${sourceId}` for work nodes @@ -28,6 +30,16 @@ export interface OrchNode { droppedByState?: Partial>; mirrorOf?: string; raw?: unknown; + // SourceNode only: set to `true` by orchestrationToFlow's `opts.collapsed` when this + // source is currently collapsed by the operator. Never set on any other node kind. + collapsed?: boolean; + // SourceNode only: set by mergeSnapshot's buildRootAndSourceEdges placeholder for a + // failed/offline source. `sublabel` still carries the same value for display compat. + sourceIssue?: SourceIssue; + // SourceNode only: ISO timestamp mirrored from the originating SourceStatus.staleSince + // (set only for `sourceIssue === "error"` placeholders — buildSourceStatuses never sets + // it for the `offline` case). Feeds SourceNode's `sourceStale` ICU message. + staleSince?: string; } export interface OrchEdge { @@ -62,17 +74,25 @@ export const ORCH_STATES = [ "cancelled", ] as const satisfies readonly OrchState[]; -const STATE_HEX: Record = { - queued: STATUS_HEX.muted, - running: STATUS_HEX.warning, - waiting_approval: STATUS_HEX.approval, - succeeded: STATUS_HEX.success, - failed: STATUS_HEX.error, - cancelled: STATUS_HEX.muted, +// Theme-aware CSS custom properties (light values in `:root`, dark values in `.dark` +// of src/app/globals.css) — replaces the previous fixed STATUS_HEX lookup so the +// canvas status colors adapt to the active theme instead of always rendering dark-mode hex. +const STATE_VAR: Record = { + queued: "var(--orch-status-muted)", + running: "var(--orch-status-warning)", + waiting_approval: "var(--orch-status-approval)", + succeeded: "var(--orch-status-success)", + failed: "var(--orch-status-error)", + cancelled: "var(--orch-status-muted)", }; export function orchStateColor(state: OrchState): string { - return STATE_HEX[state]; + return STATE_VAR[state]; +} + +/** Fundo de badge com alpha — hex+"20" não funciona com var(); color-mix sim. */ +export function orchStateBadgeBg(state: OrchState): string { + return `color-mix(in srgb, ${STATE_VAR[state]} 13%, transparent)`; } export const STALE_COMPLETED_MS = 600_000; // completed >10 min ago drop out of the live view diff --git a/src/app/(dashboard)/dashboard/orchestration/nodes/ActivityNode.tsx b/src/app/(dashboard)/dashboard/orchestration/nodes/ActivityNode.tsx index 485f500bbe7..7a88aea7a84 100644 --- a/src/app/(dashboard)/dashboard/orchestration/nodes/ActivityNode.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/nodes/ActivityNode.tsx @@ -18,3 +18,4 @@ function ActivityNodeImpl({ data }: { data: OrchNode }) { } export const ActivityNode = memo(ActivityNodeImpl); +ActivityNode.displayName = "ActivityNode"; diff --git a/src/app/(dashboard)/dashboard/orchestration/nodes/OrchestratorNode.tsx b/src/app/(dashboard)/dashboard/orchestration/nodes/OrchestratorNode.tsx index 35849849ab3..0916eab7ee0 100644 --- a/src/app/(dashboard)/dashboard/orchestration/nodes/OrchestratorNode.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/nodes/OrchestratorNode.tsx @@ -17,3 +17,4 @@ function OrchestratorNodeImpl({ data }: { data: OrchNode }) { } export const OrchestratorNode = memo(OrchestratorNodeImpl); +OrchestratorNode.displayName = "OrchestratorNode"; diff --git a/src/app/(dashboard)/dashboard/orchestration/nodes/OverflowNode.tsx b/src/app/(dashboard)/dashboard/orchestration/nodes/OverflowNode.tsx index 24d3a975b1c..2d0995bdf56 100644 --- a/src/app/(dashboard)/dashboard/orchestration/nodes/OverflowNode.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/nodes/OverflowNode.tsx @@ -27,3 +27,4 @@ function OverflowNodeImpl({ data }: { data: OrchNode }) { } export const OverflowNode = memo(OverflowNodeImpl); +OverflowNode.displayName = "OverflowNode"; diff --git a/src/app/(dashboard)/dashboard/orchestration/nodes/SourceNode.tsx b/src/app/(dashboard)/dashboard/orchestration/nodes/SourceNode.tsx index b0b4dcad848..e1c8b671a9c 100644 --- a/src/app/(dashboard)/dashboard/orchestration/nodes/SourceNode.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/nodes/SourceNode.tsx @@ -2,7 +2,12 @@ import { memo } from "react"; import { Handle, Position } from "@xyflow/react"; import { useTranslations } from "next-intl"; -import { ORCH_STATES, orchStateColor, type OrchNode } from "../model/orchestrationTypes"; +import { + ORCH_STATES, + orchStateColor, + orchStateBadgeBg, + type OrchNode, +} from "../model/orchestrationTypes"; const HANDLE = "!bg-transparent !border-0 !w-0 !h-0"; const LABEL_KEY: Record = { "cloud-agent": "sourceCloudAgent", @@ -12,18 +17,29 @@ const LABEL_KEY: Record = { function SourceNodeImpl({ data }: { data: OrchNode }) { const t = useTranslations("orchestration"); - const stale = data.sublabel === "error"; // set by mergeSnapshot for failed sources + const stale = data.sourceIssue === "error"; // set by mergeSnapshot for failed sources + const collapsed = !!data.collapsed; // set by orchestrationToFlow's opts.collapsed const label = data.source && LABEL_KEY[data.source] ? t(LABEL_KEY[data.source]) : data.label; + // Formatting a prop, not sampling the clock during render (react-hooks/purity) — + // `data.staleSince` is a snapshot value set once by mergeSnapshot, not `Date.now()`. + const since = + data.staleSince && Number.isFinite(Date.parse(data.staleSince)) + ? new Date(data.staleSince).toLocaleTimeString() + : "—"; return (
+ {collapsed ? "▸" : "▾"} {stale && ⚠} {label}
- {data.sublabel === "offline" && ( + {stale &&
{t("sourceStale", { since })}
} + {data.sourceIssue === "offline" && (
{t("sourceOffline")}
)}
@@ -31,7 +47,7 @@ function SourceNodeImpl({ data }: { data: OrchNode }) { {data.counts?.[s]} @@ -44,3 +60,4 @@ function SourceNodeImpl({ data }: { data: OrchNode }) { } export const SourceNode = memo(SourceNodeImpl); +SourceNode.displayName = "SourceNode"; diff --git a/src/app/(dashboard)/dashboard/orchestration/nodes/WorkNode.tsx b/src/app/(dashboard)/dashboard/orchestration/nodes/WorkNode.tsx index ed7d28cdfd4..4f678f7e508 100644 --- a/src/app/(dashboard)/dashboard/orchestration/nodes/WorkNode.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/nodes/WorkNode.tsx @@ -43,3 +43,4 @@ function WorkNodeImpl({ data }: { data: OrchNode }) { } export const WorkNode = memo(WorkNodeImpl); +WorkNode.displayName = "WorkNode"; diff --git a/src/app/(dashboard)/dashboard/orchestration/page.tsx b/src/app/(dashboard)/dashboard/orchestration/page.tsx index b86f67ede08..bc674c12755 100644 --- a/src/app/(dashboard)/dashboard/orchestration/page.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/page.tsx @@ -1,7 +1,16 @@ -import type { Metadata } from "next"; +import { Suspense } from "react"; +import { getTranslations } from "next-intl/server"; import OrchestrationPageClient from "./OrchestrationPageClient"; -export const metadata: Metadata = { title: "Orchestration — OmniRoute" }; +export async function generateMetadata() { + const t = await getTranslations("orchestration"); + return { title: t("title"), description: t("description") }; +} + export default function OrchestrationPage() { - return ; + return ( + + + + ); } diff --git a/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx b/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx index 189f258d76e..58a9cd19bdc 100644 --- a/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab.tsx @@ -1,16 +1,17 @@ "use client"; import { useMemo } from "react"; -import type { NodeTypes, NodeMouseHandler } from "@xyflow/react"; +import type { NodeTypes, EdgeTypes, NodeMouseHandler } from "@xyflow/react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { FlowCanvas } from "@/shared/components/flow/FlowCanvas"; import { orchestrationToFlow } from "../model/orchestrationToFlow"; -import type { OrchSnapshot } from "../model/orchestrationTypes"; +import type { OrchNode, OrchSnapshot, OrchSource } from "../model/orchestrationTypes"; import { OrchestratorNode } from "../nodes/OrchestratorNode"; import { SourceNode } from "../nodes/SourceNode"; import { WorkNode } from "../nodes/WorkNode"; import { ActivityNode } from "../nodes/ActivityNode"; import { OverflowNode } from "../nodes/OverflowNode"; +import { StatusEdge } from "../edges/StatusEdge"; const NODE_TYPES: NodeTypes = { orchestrator: OrchestratorNode as never, @@ -19,22 +20,40 @@ const NODE_TYPES: NodeTypes = { activity: ActivityNode as never, overflow: OverflowNode as never, }; +const EDGE_TYPES: EdgeTypes = { status: StatusEdge as never }; + +// Stable empty-set reference — avoids re-minting a Set every render when the caller +// doesn't pass `collapsed` (e.g. pre-A6 callers/tests), so orchestrationToFlow's memo +// doesn't invalidate on every render. +const EMPTY_COLLAPSED: ReadonlySet = new Set(); export function AgentsTab({ snapshot, onNodeClick, showCompleted, onToggleCompleted, + collapsed = EMPTY_COLLAPSED, + onToggleCollapse, }: { snapshot: OrchSnapshot; onNodeClick: (orchNodeId: string) => void; showCompleted: boolean; onToggleCompleted: (v: boolean) => void; + collapsed?: ReadonlySet; + onToggleCollapse?: (s: OrchSource) => void; }) { const t = useTranslations("orchestration"); - const { nodes, edges, fitKey } = useMemo(() => orchestrationToFlow(snapshot), [snapshot]); + const { nodes, edges, fitKey } = useMemo( + () => orchestrationToFlow(snapshot, { collapsed }), + [snapshot, collapsed] + ); const hasWork = snapshot.nodes.some((n) => n.kind === "work"); const handleClick: NodeMouseHandler = (_e, node) => { + if (node.type === "source") { + const source = (node.data as unknown as OrchNode).source; + if (source) onToggleCollapse?.(source); + return; + } if (node.type === "work" || node.type === "activity" || node.type === "overflow") onNodeClick(node.id); }; @@ -71,6 +90,7 @@ export function AgentsTab({ nodes={nodes} edges={edges} nodeTypes={NODE_TYPES} + edgeTypes={EDGE_TYPES} fitKey={fitKey} onNodeClick={handleClick} className="h-full" diff --git a/src/app/(dashboard)/dashboard/orchestration/tabs/OverviewTab.tsx b/src/app/(dashboard)/dashboard/orchestration/tabs/OverviewTab.tsx index 912882b0a45..bceca883a08 100644 --- a/src/app/(dashboard)/dashboard/orchestration/tabs/OverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/tabs/OverviewTab.tsx @@ -22,6 +22,7 @@ const STATE_KEY: Record = { const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); function formatElapsed(ms: number): string { + if (!Number.isFinite(ms)) return "—"; const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 8290c229edd..a65478dd2a1 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, Suspense } from "react"; import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components"; import { AGGREGATOR_PROVIDER_IDS, @@ -211,7 +211,7 @@ async function loadOauthEnvRepairStatus(): Promise<{ } } -export default function ProvidersPage() { +function ProvidersPageContent() { const router = useRouter(); const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); @@ -1895,6 +1895,14 @@ export default function ProvidersPage() { ); } +export default function ProvidersPage() { + return ( + + + + ); +} + // ─── Provider Test Results View (mirrors combo TestResultsView) ────────────── function ProviderTestResultsView({ results }: { results: ProviderBatchTestResults }) { diff --git a/src/app/(dashboard)/dashboard/radar/setup/page.tsx b/src/app/(dashboard)/dashboard/radar/setup/page.tsx index fde7d36d4ef..30841612386 100644 --- a/src/app/(dashboard)/dashboard/radar/setup/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/setup/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, Suspense } from "react"; import { useLocale, useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; @@ -48,7 +48,7 @@ function resolveText(text: RadarLocalizedText, locale: string): string { // Component // --------------------------------------------------------------------------- -export default function RadarSetupPage() { +function RadarSetupPageContent() { const t = useTranslations("radarSetupPage"); const locale = useLocale(); const searchParams = useSearchParams(); @@ -290,3 +290,11 @@ export default function RadarSetupPage() {
); } + +export default function RadarSetupPage() { + return ( + + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 1e2d79deef2..1d2229e3552 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -48,6 +48,13 @@ --color-success: #22c55e; --color-warning: #f59e0b; + /* Orchestration status tokens — light values (AA sobre fundo claro) */ + --orch-status-success: #15803d; + --orch-status-warning: #b45309; + --orch-status-error: #b91c1c; + --orch-status-muted: #6b7280; + --orch-status-approval: #6d28d9; + /* Traffic lights */ --color-traffic-red: #ff5f56; --color-traffic-yellow: #ffbd2e; @@ -130,6 +137,13 @@ --color-text-primary: #e6e6ef; --color-text-muted: #a1a1aa; + /* Orchestration status tokens — dark values (previous STATUS_HEX constants) */ + --orch-status-success: #22c55e; + --orch-status-warning: #f59e0b; + --orch-status-error: #ef4444; + --orch-status-muted: #6b7280; + --orch-status-approval: #8b5cf6; + /* Fumadocs theme mapping */ --color-fd-background: #0b0e14; --color-fd-foreground: #e6e6ef; @@ -826,4 +840,7 @@ html[dir="rtl"] :where(.material-symbols-outlined, .traffic-lights, .react-flow) animation: none; stroke-dasharray: none; } + .orchestration-canvas .orch-edge-particle { + display: none; + } } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index a7eb476cefe..2bc8cf6797b 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13935,11 +13935,18 @@ "tabRouting": "التوجيه", "tabOverview": "نظرة عامة", "showCompleted": "إظهار المكتمل", + "searchPlaceholder": "البحث في المهام…", + "filterStates": "الحالات", + "filterSources": "المصادر", + "filterProviders": "المزوّدون", + "clearFilters": "مسح عوامل التصفية", "sourceCloudAgent": "وكيل سحابي", "sourceA2A": "A2A", "sourceConductor": "موصل", "sourceStale": "قديم منذ {since}", "sourceOffline": "غير متصل", + "sourceCollapse": "طيّ", + "sourceExpand": "توسيع", "stateQueued": "في الانتظار", "stateRunning": "قيد التشغيل", "stateWaitingApproval": "بانتظار الموافقة", @@ -13956,11 +13963,14 @@ "drawerMetrics": "المقاييس", "drawerResult": "النتيجة", "drawerActions": "الإجراءات", + "drawerClose": "إغلاق", + "copyTrace": "نسخ أثر التتبع (JSON)", "actionApprove": "الموافقة على الخطة", "actionCancel": "إلغاء", "actionSeeInGraph": "عرض في الرسم البياني", "actionDone": "تم تطبيق الإجراء", "actionFailed": "فشل الإجراء: {error}", + "detailFailed": "فشل تحميل التفاصيل: {error}", "mirroredInA2A": "معكوس في A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 5f96480171d..8ccd7f43d51 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13935,11 +13935,18 @@ "tabRouting": "Marşrutlaşdırma", "tabOverview": "İcmal", "showCompleted": "Tamamlananları göstər", + "searchPlaceholder": "Tapşırıqları axtar…", + "filterStates": "Statuslar", + "filterSources": "Mənbələr", + "filterProviders": "Provayderlər", + "clearFilters": "Filtrləri təmizlə", "sourceCloudAgent": "Bulud agenti", "sourceA2A": "A2A", "sourceConductor": "İdarəçi", "sourceStale": "{since} tarixindən köhnəlib", "sourceOffline": "Oflayn", + "sourceCollapse": "Yığ", + "sourceExpand": "Aç", "stateQueued": "Növbədə", "stateRunning": "İşləyir", "stateWaitingApproval": "Təsdiq gözləyir", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metriklər", "drawerResult": "Nəticə", "drawerActions": "Əməliyyatlar", + "drawerClose": "Bağla", + "copyTrace": "İzləmə JSON-unu kopyala", "actionApprove": "Planı təsdiqlə", "actionCancel": "Ləğv et", "actionSeeInGraph": "Qrafikdə bax", "actionDone": "Əməliyyat tətbiq edildi", "actionFailed": "Əməliyyat uğursuz oldu: {error}", + "detailFailed": "Detallar yüklənmədi: {error}", "mirroredInA2A": "A2A-da əks olunub" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 32929ff42fd..c2f66e715db 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13935,11 +13935,18 @@ "tabRouting": "Маршрутизиране", "tabOverview": "Общ преглед", "showCompleted": "Показване на завършените", + "searchPlaceholder": "Търсене на задачи…", + "filterStates": "Състояния", + "filterSources": "Източници", + "filterProviders": "Доставчици", + "clearFilters": "Изчистване на филтрите", "sourceCloudAgent": "Облачен агент", "sourceA2A": "A2A", "sourceConductor": "Кондуктор", "sourceStale": "Остаряло от {since}", "sourceOffline": "Офлайн", + "sourceCollapse": "Свиване", + "sourceExpand": "Разгъване", "stateQueued": "На опашка", "stateRunning": "Изпълнява се", "stateWaitingApproval": "Изчаква одобрение", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Метрики", "drawerResult": "Резултат", "drawerActions": "Действия", + "drawerClose": "Затваряне", + "copyTrace": "Копиране на трасето (JSON)", "actionApprove": "Одобри плана", "actionCancel": "Отказ", "actionSeeInGraph": "Виж в графа", "actionDone": "Действието е приложено", "actionFailed": "Действието се провали: {error}", + "detailFailed": "Неуспешно зареждане на детайлите: {error}", "mirroredInA2A": "Отразено в A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 331c2236851..87644a1d2d3 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13935,11 +13935,18 @@ "tabRouting": "রাউটিং", "tabOverview": "সংক্ষিপ্ত বিবরণ", "showCompleted": "সম্পন্ন দেখান", + "searchPlaceholder": "কাজ অনুসন্ধান করুন…", + "filterStates": "অবস্থা", + "filterSources": "উৎস", + "filterProviders": "প্রদানকারী", + "clearFilters": "ফিল্টার সাফ করুন", "sourceCloudAgent": "ক্লাউড এজেন্ট", "sourceA2A": "A2A", "sourceConductor": "কন্ডাক্টর", "sourceStale": "{since} থেকে পুরনো", "sourceOffline": "অফলাইন", + "sourceCollapse": "সংকুচিত করুন", + "sourceExpand": "প্রসারিত করুন", "stateQueued": "সারিতে", "stateRunning": "চলছে", "stateWaitingApproval": "অনুমোদনের অপেক্ষায়", @@ -13956,11 +13963,14 @@ "drawerMetrics": "মেট্রিক্স", "drawerResult": "ফলাফল", "drawerActions": "কার্যক্রম", + "drawerClose": "বন্ধ করুন", + "copyTrace": "ট্রেস JSON কপি করুন", "actionApprove": "পরিকল্পনা অনুমোদন করুন", "actionCancel": "বাতিল করুন", "actionSeeInGraph": "গ্রাফে দেখুন", "actionDone": "কার্যক্রম প্রয়োগ করা হয়েছে", "actionFailed": "কার্যক্রম ব্যর্থ হয়েছে: {error}", + "detailFailed": "বিবরণ লোড করতে ব্যর্থ: {error}", "mirroredInA2A": "A2A-তে প্রতিফলিত" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 006c276ee31..e94d2035837 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13935,11 +13935,18 @@ "tabRouting": "Směrování", "tabOverview": "Přehled", "showCompleted": "Zobrazit dokončené", + "searchPlaceholder": "Hledat úlohy…", + "filterStates": "Stavy", + "filterSources": "Zdroje", + "filterProviders": "Poskytovatelé", + "clearFilters": "Vymazat filtry", "sourceCloudAgent": "Cloudový agent", "sourceA2A": "A2A", "sourceConductor": "Konduktor", "sourceStale": "Neaktuální od {since}", "sourceOffline": "Offline", + "sourceCollapse": "Sbalit", + "sourceExpand": "Rozbalit", "stateQueued": "Ve frontě", "stateRunning": "Běží", "stateWaitingApproval": "Čeká na schválení", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metriky", "drawerResult": "Výsledek", "drawerActions": "Akce", + "drawerClose": "Zavřít", + "copyTrace": "Kopírovat trasování (JSON)", "actionApprove": "Schválit plán", "actionCancel": "Zrušit", "actionSeeInGraph": "Zobrazit v grafu", "actionDone": "Akce provedena", "actionFailed": "Akce selhala: {error}", + "detailFailed": "Nepodařilo se načíst podrobnosti: {error}", "mirroredInA2A": "Zrcadleno v A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 904a3a3147b..e7cd53d6347 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routing", "tabOverview": "Oversigt", "showCompleted": "Vis fuldførte", + "searchPlaceholder": "Søg i opgaver…", + "filterStates": "Tilstande", + "filterSources": "Kilder", + "filterProviders": "Udbydere", + "clearFilters": "Ryd filtre", "sourceCloudAgent": "Cloud-agent", "sourceA2A": "A2A", "sourceConductor": "Konduktør", "sourceStale": "Forældet siden {since}", "sourceOffline": "Offline", + "sourceCollapse": "Fold sammen", + "sourceExpand": "Fold ud", "stateQueued": "I kø", "stateRunning": "Kører", "stateWaitingApproval": "Afventer godkendelse", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Målinger", "drawerResult": "Resultat", "drawerActions": "Handlinger", + "drawerClose": "Luk", + "copyTrace": "Kopiér sporing (JSON)", "actionApprove": "Godkend plan", "actionCancel": "Annuller", "actionSeeInGraph": "Se i graf", "actionDone": "Handling udført", "actionFailed": "Handling mislykkedes: {error}", + "detailFailed": "Kunne ikke indlæse detaljer: {error}", "mirroredInA2A": "Spejlet i A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 090162fbda9..72f7d99b782 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13942,11 +13942,18 @@ "tabRouting": "Routing", "tabOverview": "Übersicht", "showCompleted": "Abgeschlossene anzeigen", + "searchPlaceholder": "Aufgaben durchsuchen…", + "filterStates": "Status", + "filterSources": "Quellen", + "filterProviders": "Anbieter", + "clearFilters": "Filter zurücksetzen", "sourceCloudAgent": "Cloud-Agent", "sourceA2A": "A2A", "sourceConductor": "Leiter", "sourceStale": "Veraltet seit {since}", "sourceOffline": "Offline", + "sourceCollapse": "Einklappen", + "sourceExpand": "Ausklappen", "stateQueued": "In Warteschlange", "stateRunning": "Läuft", "stateWaitingApproval": "Wartet auf Genehmigung", @@ -13963,11 +13970,14 @@ "drawerMetrics": "Metriken", "drawerResult": "Ergebnis", "drawerActions": "Aktionen", + "drawerClose": "Schließen", + "copyTrace": "Trace-JSON kopieren", "actionApprove": "Plan genehmigen", "actionCancel": "Abbrechen", "actionSeeInGraph": "Im Graph anzeigen", "actionDone": "Aktion angewendet", "actionFailed": "Aktion fehlgeschlagen: {error}", + "detailFailed": "Details konnten nicht geladen werden: {error}", "mirroredInA2A": "In A2A gespiegelt" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index aa7f379693f..6ab642e492a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13939,11 +13939,18 @@ "tabRouting": "Routing", "tabOverview": "Overview", "showCompleted": "Show completed", + "searchPlaceholder": "Search tasks…", + "filterStates": "States", + "filterSources": "Sources", + "filterProviders": "Providers", + "clearFilters": "Clear filters", "sourceCloudAgent": "Cloud Agent", "sourceA2A": "A2A", "sourceConductor": "Conductor", "sourceStale": "Stale since {since}", "sourceOffline": "Offline", + "sourceCollapse": "Collapse", + "sourceExpand": "Expand", "stateQueued": "Queued", "stateRunning": "Running", "stateWaitingApproval": "Waiting approval", @@ -13960,11 +13967,14 @@ "drawerMetrics": "Metrics", "drawerResult": "Result", "drawerActions": "Actions", + "drawerClose": "Close", + "copyTrace": "Copy trace JSON", "actionApprove": "Approve plan", "actionCancel": "Cancel", "actionSeeInGraph": "See in graph", "actionDone": "Action applied", "actionFailed": "Action failed: {error}", + "detailFailed": "Failed to load details: {error}", "mirroredInA2A": "Mirrored in A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 4953d0469f5..9549997df26 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13935,11 +13935,18 @@ "tabRouting": "Enrutamiento", "tabOverview": "Resumen", "showCompleted": "Mostrar completados", + "searchPlaceholder": "Buscar tareas…", + "filterStates": "Estados", + "filterSources": "Fuentes", + "filterProviders": "Proveedores", + "clearFilters": "Borrar filtros", "sourceCloudAgent": "Agente en la nube", "sourceA2A": "A2A", "sourceConductor": "Conductor", "sourceStale": "Obsoleto desde {since}", "sourceOffline": "Sin conexión", + "sourceCollapse": "Contraer", + "sourceExpand": "Expandir", "stateQueued": "En cola", "stateRunning": "En ejecución", "stateWaitingApproval": "Esperando aprobación", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Métricas", "drawerResult": "Resultado", "drawerActions": "Acciones", + "drawerClose": "Cerrar", + "copyTrace": "Copiar traza JSON", "actionApprove": "Aprobar plan", "actionCancel": "Cancelar", "actionSeeInGraph": "Ver en el gráfico", "actionDone": "Acción aplicada", "actionFailed": "Error en la acción: {error}", + "detailFailed": "No se pudieron cargar los detalles: {error}", "mirroredInA2A": "Reflejado en A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 2a256b9f45f..6b1c59f5cb3 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13935,11 +13935,18 @@ "tabRouting": "مسیریابی", "tabOverview": "نمای کلی", "showCompleted": "نمایش تکمیل‌شده‌ها", + "searchPlaceholder": "جستجوی وظایف…", + "filterStates": "وضعیت‌ها", + "filterSources": "منابع", + "filterProviders": "ارائه‌دهندگان", + "clearFilters": "پاک کردن فیلترها", "sourceCloudAgent": "ایجنت ابری", "sourceA2A": "A2A", "sourceConductor": "هدایتگر", "sourceStale": "منسوخ از {since}", "sourceOffline": "آفلاین", + "sourceCollapse": "جمع کردن", + "sourceExpand": "باز کردن", "stateQueued": "در صف", "stateRunning": "در حال اجرا", "stateWaitingApproval": "در انتظار تأیید", @@ -13956,11 +13963,14 @@ "drawerMetrics": "معیارها", "drawerResult": "نتیجه", "drawerActions": "اقدامات", + "drawerClose": "بستن", + "copyTrace": "کپی ردیابی JSON", "actionApprove": "تأیید طرح", "actionCancel": "لغو", "actionSeeInGraph": "مشاهده در نمودار", "actionDone": "اقدام اعمال شد", "actionFailed": "اقدام ناموفق بود: {error}", + "detailFailed": "بارگذاری جزئیات ناموفق بود: {error}", "mirroredInA2A": "بازتاب‌یافته در A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index a31c9d8d22a..88a45e500ca 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13935,11 +13935,18 @@ "tabRouting": "Reititys", "tabOverview": "Yleiskatsaus", "showCompleted": "Näytä valmiit", + "searchPlaceholder": "Hae tehtäviä…", + "filterStates": "Tilat", + "filterSources": "Lähteet", + "filterProviders": "Palveluntarjoajat", + "clearFilters": "Tyhjennä suodattimet", "sourceCloudAgent": "Pilviagentti", "sourceA2A": "A2A", "sourceConductor": "Konduktori", "sourceStale": "Vanhentunut alkaen {since}", "sourceOffline": "Offline-tilassa", + "sourceCollapse": "Pienennä", + "sourceExpand": "Laajenna", "stateQueued": "Jonossa", "stateRunning": "Käynnissä", "stateWaitingApproval": "Odottaa hyväksyntää", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Mittarit", "drawerResult": "Tulos", "drawerActions": "Toiminnot", + "drawerClose": "Sulje", + "copyTrace": "Kopioi jäljitys-JSON", "actionApprove": "Hyväksy suunnitelma", "actionCancel": "Peruuta", "actionSeeInGraph": "Näytä kaaviossa", "actionDone": "Toiminto suoritettu", "actionFailed": "Toiminto epäonnistui: {error}", + "detailFailed": "Tietojen lataus epäonnistui: {error}", "mirroredInA2A": "Peilattu A2A:ssa" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c06cd1f9835..19dab1efca5 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routage", "tabOverview": "Vue d'ensemble", "showCompleted": "Afficher les terminés", + "searchPlaceholder": "Rechercher des tâches…", + "filterStates": "États", + "filterSources": "Sources", + "filterProviders": "Fournisseurs", + "clearFilters": "Effacer les filtres", "sourceCloudAgent": "Agent cloud", "sourceA2A": "A2A", "sourceConductor": "Conducteur", "sourceStale": "Obsolète depuis {since}", "sourceOffline": "Hors ligne", + "sourceCollapse": "Réduire", + "sourceExpand": "Développer", "stateQueued": "En file d'attente", "stateRunning": "En cours", "stateWaitingApproval": "En attente d'approbation", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Métriques", "drawerResult": "Résultat", "drawerActions": "Actions", + "drawerClose": "Fermer", + "copyTrace": "Copier la trace JSON", "actionApprove": "Approuver le plan", "actionCancel": "Annuler", "actionSeeInGraph": "Voir dans le graphe", "actionDone": "Action appliquée", "actionFailed": "Échec de l'action : {error}", + "detailFailed": "Échec du chargement des détails : {error}", "mirroredInA2A": "Reflété dans A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 439a71e4204..b20437b2a4e 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13935,11 +13935,18 @@ "tabRouting": "રાઉટિંગ", "tabOverview": "ઝાંખી", "showCompleted": "પૂર્ણ થયેલા બતાવો", + "searchPlaceholder": "કાર્યો શોધો…", + "filterStates": "સ્થિતિઓ", + "filterSources": "સ્રોતો", + "filterProviders": "પ્રદાતાઓ", + "clearFilters": "ફિલ્ટર્સ સાફ કરો", "sourceCloudAgent": "ક્લાઉડ એજન્ટ", "sourceA2A": "A2A", "sourceConductor": "સંચાલક", "sourceStale": "{since} થી જૂનું", "sourceOffline": "ઑફલાઇન", + "sourceCollapse": "સંકુચિત કરો", + "sourceExpand": "વિસ્તૃત કરો", "stateQueued": "કતારમાં", "stateRunning": "ચાલી રહ્યું છે", "stateWaitingApproval": "મંજૂરીની રાહ જોઈ રહ્યું છે", @@ -13956,11 +13963,14 @@ "drawerMetrics": "મેટ્રિક્સ", "drawerResult": "પરિણામ", "drawerActions": "ક્રિયાઓ", + "drawerClose": "બંધ કરો", + "copyTrace": "ટ્રેસ JSON કૉપિ કરો", "actionApprove": "યોજના મંજૂર કરો", "actionCancel": "રદ કરો", "actionSeeInGraph": "ગ્રાફમાં જુઓ", "actionDone": "ક્રિયા લાગુ કરાઈ", "actionFailed": "ક્રિયા નિષ્ફળ: {error}", + "detailFailed": "વિગતો લોડ કરવામાં નિષ્ફળ: {error}", "mirroredInA2A": "A2A માં પ્રતિબિંબિત" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index fd5329cd56c..e3702ef32e7 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13935,11 +13935,18 @@ "tabRouting": "ניתוב", "tabOverview": "סקירה כללית", "showCompleted": "הצג הושלמו", + "searchPlaceholder": "חיפוש משימות…", + "filterStates": "מצבים", + "filterSources": "מקורות", + "filterProviders": "ספקים", + "clearFilters": "ניקוי מסננים", "sourceCloudAgent": "סוכן ענן", "sourceA2A": "A2A", "sourceConductor": "מנחה", "sourceStale": "לא עדכני מאז {since}", "sourceOffline": "לא מקוון", + "sourceCollapse": "כיווץ", + "sourceExpand": "הרחבה", "stateQueued": "בתור", "stateRunning": "פועל", "stateWaitingApproval": "ממתין לאישור", @@ -13956,11 +13963,14 @@ "drawerMetrics": "מדדים", "drawerResult": "תוצאה", "drawerActions": "פעולות", + "drawerClose": "סגירה", + "copyTrace": "העתקת מעקב JSON", "actionApprove": "אשר תוכנית", "actionCancel": "בטל", "actionSeeInGraph": "הצג בגרף", "actionDone": "הפעולה בוצעה", "actionFailed": "הפעולה נכשלה: {error}", + "detailFailed": "טעינת הפרטים נכשלה: {error}", "mirroredInA2A": "משוקף ב-A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 59528d6a017..818e449c1c3 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13935,11 +13935,18 @@ "tabRouting": "रूटिंग", "tabOverview": "अवलोकन", "showCompleted": "पूर्ण दिखाएं", + "searchPlaceholder": "कार्य खोजें…", + "filterStates": "स्थितियाँ", + "filterSources": "स्रोत", + "filterProviders": "प्रदाता", + "clearFilters": "फ़िल्टर साफ़ करें", "sourceCloudAgent": "क्लाउड एजेंट", "sourceA2A": "A2A", "sourceConductor": "संवहनकर्ता", "sourceStale": "{since} से पुराना", "sourceOffline": "ऑफ़लाइन", + "sourceCollapse": "संक्षिप्त करें", + "sourceExpand": "विस्तृत करें", "stateQueued": "कतार में", "stateRunning": "चल रहा है", "stateWaitingApproval": "स्वीकृति की प्रतीक्षा में", @@ -13956,11 +13963,14 @@ "drawerMetrics": "मेट्रिक्स", "drawerResult": "परिणाम", "drawerActions": "कार्रवाइयां", + "drawerClose": "बंद करें", + "copyTrace": "ट्रेस JSON कॉपी करें", "actionApprove": "योजना स्वीकृत करें", "actionCancel": "रद्द करें", "actionSeeInGraph": "ग्राफ़ में देखें", "actionDone": "कार्रवाई लागू की गई", "actionFailed": "कार्रवाई विफल: {error}", + "detailFailed": "विवरण लोड करने में विफल: {error}", "mirroredInA2A": "A2A में प्रतिबिंबित" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index c8ddffece7f..eb8efd58ef0 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13935,11 +13935,18 @@ "tabRouting": "Útválasztás", "tabOverview": "Áttekintés", "showCompleted": "Befejezettek megjelenítése", + "searchPlaceholder": "Feladatok keresése…", + "filterStates": "Állapotok", + "filterSources": "Források", + "filterProviders": "Szolgáltatók", + "clearFilters": "Szűrők törlése", "sourceCloudAgent": "Felhőügynök", "sourceA2A": "A2A", "sourceConductor": "Vezető", "sourceStale": "Elavult ekkor óta: {since}", "sourceOffline": "Offline", + "sourceCollapse": "Összecsukás", + "sourceExpand": "Kibontás", "stateQueued": "Sorban áll", "stateRunning": "Fut", "stateWaitingApproval": "Jóváhagyásra vár", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metrikák", "drawerResult": "Eredmény", "drawerActions": "Műveletek", + "drawerClose": "Bezárás", + "copyTrace": "Nyomkövetési JSON másolása", "actionApprove": "Terv jóváhagyása", "actionCancel": "Mégse", "actionSeeInGraph": "Megtekintés a grafikonon", "actionDone": "Művelet végrehajtva", "actionFailed": "A művelet sikertelen: {error}", + "detailFailed": "A részletek betöltése sikertelen: {error}", "mirroredInA2A": "Tükrözve az A2A-ban" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index a627c88cc64..a42bb31f6a5 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13935,11 +13935,18 @@ "tabRouting": "Perutean", "tabOverview": "Ikhtisar", "showCompleted": "Tampilkan yang selesai", + "searchPlaceholder": "Cari tugas…", + "filterStates": "Status", + "filterSources": "Sumber", + "filterProviders": "Penyedia", + "clearFilters": "Hapus filter", "sourceCloudAgent": "Agen Cloud", "sourceA2A": "A2A", "sourceConductor": "Konduktor", "sourceStale": "Kedaluwarsa sejak {since}", "sourceOffline": "Offline", + "sourceCollapse": "Ciutkan", + "sourceExpand": "Perluas", "stateQueued": "Dalam antrean", "stateRunning": "Berjalan", "stateWaitingApproval": "Menunggu persetujuan", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metrik", "drawerResult": "Hasil", "drawerActions": "Tindakan", + "drawerClose": "Tutup", + "copyTrace": "Salin trace JSON", "actionApprove": "Setujui rencana", "actionCancel": "Batal", "actionSeeInGraph": "Lihat di grafik", "actionDone": "Tindakan diterapkan", "actionFailed": "Tindakan gagal: {error}", + "detailFailed": "Gagal memuat detail: {error}", "mirroredInA2A": "Dicerminkan di A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 688c4e4b71d..a84a2f59ea4 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -13935,11 +13935,18 @@ "tabRouting": "Perutean", "tabOverview": "Ikhtisar", "showCompleted": "Tampilkan yang selesai", + "searchPlaceholder": "Cari tugas…", + "filterStates": "Status", + "filterSources": "Sumber", + "filterProviders": "Penyedia", + "clearFilters": "Hapus filter", "sourceCloudAgent": "Agen Cloud", "sourceA2A": "A2A", "sourceConductor": "Konduktor", "sourceStale": "Kedaluwarsa sejak {since}", "sourceOffline": "Offline", + "sourceCollapse": "Ciutkan", + "sourceExpand": "Perluas", "stateQueued": "Dalam antrean", "stateRunning": "Berjalan", "stateWaitingApproval": "Menunggu persetujuan", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metrik", "drawerResult": "Hasil", "drawerActions": "Tindakan", + "drawerClose": "Tutup", + "copyTrace": "Salin trace JSON", "actionApprove": "Setujui rencana", "actionCancel": "Batal", "actionSeeInGraph": "Lihat di grafik", "actionDone": "Tindakan diterapkan", "actionFailed": "Tindakan gagal: {error}", + "detailFailed": "Gagal memuat detail: {error}", "mirroredInA2A": "Dicerminkan di A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 26564386041..88748dd4920 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routing", "tabOverview": "Panoramica", "showCompleted": "Mostra completati", + "searchPlaceholder": "Cerca attività…", + "filterStates": "Stati", + "filterSources": "Origini", + "filterProviders": "Fornitori", + "clearFilters": "Cancella filtri", "sourceCloudAgent": "Agente cloud", "sourceA2A": "A2A", "sourceConductor": "Conduttore", "sourceStale": "Non aggiornato dal {since}", "sourceOffline": "Offline", + "sourceCollapse": "Comprimi", + "sourceExpand": "Espandi", "stateQueued": "In coda", "stateRunning": "In esecuzione", "stateWaitingApproval": "In attesa di approvazione", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metriche", "drawerResult": "Risultato", "drawerActions": "Azioni", + "drawerClose": "Chiudi", + "copyTrace": "Copia trace JSON", "actionApprove": "Approva piano", "actionCancel": "Annulla", "actionSeeInGraph": "Visualizza nel grafico", "actionDone": "Azione applicata", "actionFailed": "Azione non riuscita: {error}", + "detailFailed": "Impossibile caricare i dettagli: {error}", "mirroredInA2A": "Rispecchiato in A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 52b0510bf26..daa5041fd6b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13935,11 +13935,18 @@ "tabRouting": "ルーティング", "tabOverview": "概要", "showCompleted": "完了を表示", + "searchPlaceholder": "タスクを検索…", + "filterStates": "状態", + "filterSources": "ソース", + "filterProviders": "プロバイダー", + "clearFilters": "フィルターをクリア", "sourceCloudAgent": "クラウドエージェント", "sourceA2A": "A2A", "sourceConductor": "指揮者", "sourceStale": "{since}から更新なし", "sourceOffline": "オフライン", + "sourceCollapse": "折りたたむ", + "sourceExpand": "展開する", "stateQueued": "キュー待ち", "stateRunning": "実行中", "stateWaitingApproval": "承認待ち", @@ -13956,11 +13963,14 @@ "drawerMetrics": "メトリクス", "drawerResult": "結果", "drawerActions": "アクション", + "drawerClose": "閉じる", + "copyTrace": "トレースJSONをコピー", "actionApprove": "プランを承認", "actionCancel": "キャンセル", "actionSeeInGraph": "グラフで表示", "actionDone": "アクションを適用しました", "actionFailed": "アクションが失敗しました: {error}", + "detailFailed": "詳細の読み込みに失敗しました: {error}", "mirroredInA2A": "A2Aにミラーリング" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ab1e255363a..5a95e797ee3 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13935,11 +13935,18 @@ "tabRouting": "라우팅", "tabOverview": "개요", "showCompleted": "완료 항목 표시", + "searchPlaceholder": "작업 검색…", + "filterStates": "상태", + "filterSources": "소스", + "filterProviders": "공급자", + "clearFilters": "필터 지우기", "sourceCloudAgent": "클라우드 에이전트", "sourceA2A": "A2A", "sourceConductor": "지휘자", "sourceStale": "{since}부터 오래됨", "sourceOffline": "오프라인", + "sourceCollapse": "접기", + "sourceExpand": "펼치기", "stateQueued": "대기 중", "stateRunning": "실행 중", "stateWaitingApproval": "승인 대기 중", @@ -13956,11 +13963,14 @@ "drawerMetrics": "지표", "drawerResult": "결과", "drawerActions": "작업", + "drawerClose": "닫기", + "copyTrace": "추적 JSON 복사", "actionApprove": "계획 승인", "actionCancel": "취소", "actionSeeInGraph": "그래프에서 보기", "actionDone": "작업이 적용됨", "actionFailed": "작업 실패: {error}", + "detailFailed": "세부정보를 불러오지 못했습니다: {error}", "mirroredInA2A": "A2A에 미러링됨" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index cd797308887..40e61f6f06d 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13935,11 +13935,18 @@ "tabRouting": "राउटिंग", "tabOverview": "आढावा", "showCompleted": "पूर्ण झालेले दाखवा", + "searchPlaceholder": "कार्ये शोधा…", + "filterStates": "स्थिती", + "filterSources": "स्रोत", + "filterProviders": "प्रदाता", + "clearFilters": "फिल्टर साफ करा", "sourceCloudAgent": "क्लाउड एजंट", "sourceA2A": "A2A", "sourceConductor": "संवहनकर्ता", "sourceStale": "{since} पासून जुने", "sourceOffline": "ऑफलाइन", + "sourceCollapse": "संकुचित करा", + "sourceExpand": "विस्तृत करा", "stateQueued": "रांगेत", "stateRunning": "सुरू आहे", "stateWaitingApproval": "मंजुरीची वाट पाहत आहे", @@ -13956,11 +13963,14 @@ "drawerMetrics": "मेट्रिक्स", "drawerResult": "निकाल", "drawerActions": "क्रिया", + "drawerClose": "बंद करा", + "copyTrace": "ट्रेस JSON कॉपी करा", "actionApprove": "योजना मंजूर करा", "actionCancel": "रद्द करा", "actionSeeInGraph": "आलेखात पहा", "actionDone": "क्रिया लागू केली", "actionFailed": "क्रिया अयशस्वी: {error}", + "detailFailed": "तपशील लोड करण्यात अयशस्वी: {error}", "mirroredInA2A": "A2A मध्ये प्रतिबिंबित" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 7935db042dd..87d11ce69e5 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13935,11 +13935,18 @@ "tabRouting": "Penghalaan", "tabOverview": "Gambaran Keseluruhan", "showCompleted": "Tunjukkan yang selesai", + "searchPlaceholder": "Cari tugas…", + "filterStates": "Status", + "filterSources": "Sumber", + "filterProviders": "Pembekal", + "clearFilters": "Kosongkan penapis", "sourceCloudAgent": "Ejen Awan", "sourceA2A": "A2A", "sourceConductor": "Pengendali", "sourceStale": "Lapuk sejak {since}", "sourceOffline": "Luar talian", + "sourceCollapse": "Kuncupkan", + "sourceExpand": "Kembangkan", "stateQueued": "Dalam giliran", "stateRunning": "Berjalan", "stateWaitingApproval": "Menunggu kelulusan", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metrik", "drawerResult": "Keputusan", "drawerActions": "Tindakan", + "drawerClose": "Tutup", + "copyTrace": "Salin JSON jejak", "actionApprove": "Luluskan pelan", "actionCancel": "Batal", "actionSeeInGraph": "Lihat dalam graf", "actionDone": "Tindakan digunakan", "actionFailed": "Tindakan gagal: {error}", + "detailFailed": "Gagal memuatkan butiran: {error}", "mirroredInA2A": "Dicerminkan dalam A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index aa8228ea9aa..116d61ff25b 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routering", "tabOverview": "Overzicht", "showCompleted": "Voltooide items tonen", + "searchPlaceholder": "Taken zoeken…", + "filterStates": "Statussen", + "filterSources": "Bronnen", + "filterProviders": "Aanbieders", + "clearFilters": "Filters wissen", "sourceCloudAgent": "Cloud Agent", "sourceA2A": "A2A", "sourceConductor": "Conductor", "sourceStale": "Verouderd sinds {since}", "sourceOffline": "Offline", + "sourceCollapse": "Inklappen", + "sourceExpand": "Uitklappen", "stateQueued": "In wachtrij", "stateRunning": "Actief", "stateWaitingApproval": "Wacht op goedkeuring", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Statistieken", "drawerResult": "Resultaat", "drawerActions": "Acties", + "drawerClose": "Sluiten", + "copyTrace": "Trace-JSON kopiëren", "actionApprove": "Plan goedkeuren", "actionCancel": "Annuleren", "actionSeeInGraph": "Bekijk in grafiek", "actionDone": "Actie toegepast", "actionFailed": "Actie mislukt: {error}", + "detailFailed": "Details laden mislukt: {error}", "mirroredInA2A": "Weergegeven in A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index d75c7f1bf0e..02732fee099 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13935,11 +13935,18 @@ "tabRouting": "Ruting", "tabOverview": "Oversikt", "showCompleted": "Vis fullførte", + "searchPlaceholder": "Søk i oppgaver…", + "filterStates": "Tilstander", + "filterSources": "Kilder", + "filterProviders": "Leverandører", + "clearFilters": "Fjern filtre", "sourceCloudAgent": "Skyagent", "sourceA2A": "A2A", "sourceConductor": "Konduktør", "sourceStale": "Utdatert siden {since}", "sourceOffline": "Frakoblet", + "sourceCollapse": "Fold sammen", + "sourceExpand": "Fold ut", "stateQueued": "I kø", "stateRunning": "Kjører", "stateWaitingApproval": "Venter på godkjenning", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Målinger", "drawerResult": "Resultat", "drawerActions": "Handlinger", + "drawerClose": "Lukk", + "copyTrace": "Kopiér spor (JSON)", "actionApprove": "Godkjenn plan", "actionCancel": "Avbryt", "actionSeeInGraph": "Se i grafen", "actionDone": "Handling utført", "actionFailed": "Handling mislyktes: {error}", + "detailFailed": "Kunne ikke laste inn detaljer: {error}", "mirroredInA2A": "Speilet i A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index bcbd969b122..6d15e78d9f3 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routing", "tabOverview": "Pangkalahatang-ideya", "showCompleted": "Ipakita ang mga natapos na", + "searchPlaceholder": "Maghanap ng mga gawain…", + "filterStates": "Mga Katayuan", + "filterSources": "Mga Pinagmulan", + "filterProviders": "Mga Provider", + "clearFilters": "I-clear ang mga filter", "sourceCloudAgent": "Cloud Agent", "sourceA2A": "A2A", "sourceConductor": "Konduktor", "sourceStale": "Luma na simula {since}", "sourceOffline": "Offline", + "sourceCollapse": "I-collapse", + "sourceExpand": "I-expand", "stateQueued": "Nakapila", "stateRunning": "Tumatakbo", "stateWaitingApproval": "Naghihintay ng Pag-apruba", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Mga Sukatan", "drawerResult": "Resulta", "drawerActions": "Mga Aksyon", + "drawerClose": "Isara", + "copyTrace": "Kopyahin ang trace JSON", "actionApprove": "Aprubahan ang plano", "actionCancel": "Kanselahin", "actionSeeInGraph": "Tingnan sa graph", "actionDone": "Naisagawa ang aksyon", "actionFailed": "Nabigo ang aksyon: {error}", + "detailFailed": "Nabigong i-load ang mga detalye: {error}", "mirroredInA2A": "Naka-mirror sa A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 0fee784ce9b..37a3bb82881 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routing", "tabOverview": "Przegląd", "showCompleted": "Pokaż ukończone", + "searchPlaceholder": "Szukaj zadań…", + "filterStates": "Stany", + "filterSources": "Źródła", + "filterProviders": "Dostawcy", + "clearFilters": "Wyczyść filtry", "sourceCloudAgent": "Agent chmurowy", "sourceA2A": "A2A", "sourceConductor": "Konduktor", "sourceStale": "Nieaktualne od {since}", "sourceOffline": "Offline", + "sourceCollapse": "Zwiń", + "sourceExpand": "Rozwiń", "stateQueued": "W kolejce", "stateRunning": "Uruchomiony", "stateWaitingApproval": "Oczekuje na zatwierdzenie", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metryki", "drawerResult": "Wynik", "drawerActions": "Akcje", + "drawerClose": "Zamknij", + "copyTrace": "Kopiuj JSON śladu", "actionApprove": "Zatwierdź plan", "actionCancel": "Anuluj", "actionSeeInGraph": "Zobacz na grafie", "actionDone": "Akcja wykonana", "actionFailed": "Akcja nie powiodła się: {error}", + "detailFailed": "Nie udało się wczytać szczegółów: {error}", "mirroredInA2A": "Odzwierciedlone w A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index df8baf6d4c2..ea713d2d9b9 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13943,11 +13943,18 @@ "tabRouting": "Roteamento", "tabOverview": "Visão geral", "showCompleted": "Mostrar concluídos", + "searchPlaceholder": "Buscar tarefas…", + "filterStates": "Estados", + "filterSources": "Origens", + "filterProviders": "Provedores", + "clearFilters": "Limpar filtros", "sourceCloudAgent": "Cloud Agent", "sourceA2A": "A2A", "sourceConductor": "Conductor", "sourceStale": "Sem atualização desde {since}", "sourceOffline": "Offline", + "sourceCollapse": "Recolher", + "sourceExpand": "Expandir", "stateQueued": "Na fila", "stateRunning": "Em execução", "stateWaitingApproval": "Aguardando aprovação", @@ -13964,11 +13971,14 @@ "drawerMetrics": "Métricas", "drawerResult": "Resultado", "drawerActions": "Ações", + "drawerClose": "Fechar", + "copyTrace": "Copiar trace em JSON", "actionApprove": "Aprovar plano", "actionCancel": "Cancelar", "actionSeeInGraph": "Ver no grafo", "actionDone": "Ação aplicada", "actionFailed": "Ação falhou: {error}", + "detailFailed": "Falha ao carregar detalhes: {error}", "mirroredInA2A": "Espelhado no A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index b2dc76d48af..98c9123e7bb 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13935,11 +13935,18 @@ "tabRouting": "Encaminhamento", "tabOverview": "Visão Geral", "showCompleted": "Mostrar concluídos", + "searchPlaceholder": "Pesquisar tarefas…", + "filterStates": "Estados", + "filterSources": "Origens", + "filterProviders": "Provedores", + "clearFilters": "Limpar filtros", "sourceCloudAgent": "Agente na Nuvem", "sourceA2A": "A2A", "sourceConductor": "Condutor", "sourceStale": "Desatualizado desde {since}", "sourceOffline": "Offline", + "sourceCollapse": "Recolher", + "sourceExpand": "Expandir", "stateQueued": "Em fila", "stateRunning": "Em execução", "stateWaitingApproval": "A aguardar aprovação", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Métricas", "drawerResult": "Resultado", "drawerActions": "Ações", + "drawerClose": "Fechar", + "copyTrace": "Copiar trace em JSON", "actionApprove": "Aprovar plano", "actionCancel": "Cancelar", "actionSeeInGraph": "Ver no grafo", "actionDone": "Ação aplicada", "actionFailed": "Falha na ação: {error}", + "detailFailed": "Falha ao carregar detalhes: {error}", "mirroredInA2A": "Espelhado no A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index c501a42e062..75b1d13a765 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13935,11 +13935,18 @@ "tabRouting": "Rutare", "tabOverview": "Prezentare generală", "showCompleted": "Afișează cele finalizate", + "searchPlaceholder": "Caută sarcini…", + "filterStates": "Stări", + "filterSources": "Surse", + "filterProviders": "Furnizori", + "clearFilters": "Golește filtrele", "sourceCloudAgent": "Agent Cloud", "sourceA2A": "A2A", "sourceConductor": "Conductor", "sourceStale": "Perimat din {since}", "sourceOffline": "Offline", + "sourceCollapse": "Restrânge", + "sourceExpand": "Extinde", "stateQueued": "În așteptare", "stateRunning": "În execuție", "stateWaitingApproval": "Așteaptă aprobare", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metrici", "drawerResult": "Rezultat", "drawerActions": "Acțiuni", + "drawerClose": "Închide", + "copyTrace": "Copiază JSON-ul de urmărire", "actionApprove": "Aprobă planul", "actionCancel": "Anulează", "actionSeeInGraph": "Vezi în grafic", "actionDone": "Acțiune aplicată", "actionFailed": "Acțiune eșuată: {error}", + "detailFailed": "Încărcarea detaliilor a eșuat: {error}", "mirroredInA2A": "Reflectat în A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 9bdea2f86e4..cf7ccd6e6b6 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13935,11 +13935,18 @@ "tabRouting": "Маршрутизация", "tabOverview": "Обзор", "showCompleted": "Показать завершённые", + "searchPlaceholder": "Поиск задач…", + "filterStates": "Состояния", + "filterSources": "Источники", + "filterProviders": "Провайдеры", + "clearFilters": "Сбросить фильтры", "sourceCloudAgent": "Облачный агент", "sourceA2A": "A2A", "sourceConductor": "Кондуктор", "sourceStale": "Устарело с {since}", "sourceOffline": "Не в сети", + "sourceCollapse": "Свернуть", + "sourceExpand": "Развернуть", "stateQueued": "В очереди", "stateRunning": "Выполняется", "stateWaitingApproval": "Ожидает подтверждения", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Метрики", "drawerResult": "Результат", "drawerActions": "Действия", + "drawerClose": "Закрыть", + "copyTrace": "Скопировать трассировку JSON", "actionApprove": "Утвердить план", "actionCancel": "Отмена", "actionSeeInGraph": "Посмотреть на графе", "actionDone": "Действие применено", "actionFailed": "Действие не удалось: {error}", + "detailFailed": "Не удалось загрузить детали: {error}", "mirroredInA2A": "Отражено в A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index ced4e9ae740..254eb1c471b 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13935,11 +13935,18 @@ "tabRouting": "Smerovanie", "tabOverview": "Prehľad", "showCompleted": "Zobraziť dokončené", + "searchPlaceholder": "Hľadať úlohy…", + "filterStates": "Stavy", + "filterSources": "Zdroje", + "filterProviders": "Poskytovatelia", + "clearFilters": "Vymazať filtre", "sourceCloudAgent": "Cloudový agent", "sourceA2A": "A2A", "sourceConductor": "Konduktor", "sourceStale": "Zastarané od {since}", "sourceOffline": "Offline", + "sourceCollapse": "Zbaliť", + "sourceExpand": "Rozbaliť", "stateQueued": "V rade", "stateRunning": "Beží", "stateWaitingApproval": "Čaká na schválenie", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metriky", "drawerResult": "Výsledok", "drawerActions": "Akcie", + "drawerClose": "Zavrieť", + "copyTrace": "Kopírovať trasovanie JSON", "actionApprove": "Schváliť plán", "actionCancel": "Zrušiť", "actionSeeInGraph": "Zobraziť v grafe", "actionDone": "Akcia použitá", "actionFailed": "Akcia zlyhala: {error}", + "detailFailed": "Nepodarilo sa načítať podrobnosti: {error}", "mirroredInA2A": "Zrkadlené v A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index f42dc76f6e6..ce3a61077be 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13935,11 +13935,18 @@ "tabRouting": "Routing", "tabOverview": "Översikt", "showCompleted": "Visa slutförda", + "searchPlaceholder": "Sök uppgifter…", + "filterStates": "Tillstånd", + "filterSources": "Källor", + "filterProviders": "Leverantörer", + "clearFilters": "Rensa filter", "sourceCloudAgent": "Molnagent", "sourceA2A": "A2A", "sourceConductor": "Ledare", "sourceStale": "Föråldrad sedan {since}", "sourceOffline": "Offline", + "sourceCollapse": "Fäll ihop", + "sourceExpand": "Fäll ut", "stateQueued": "Köad", "stateRunning": "Körs", "stateWaitingApproval": "Väntar på godkännande", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Mätvärden", "drawerResult": "Resultat", "drawerActions": "Åtgärder", + "drawerClose": "Stäng", + "copyTrace": "Kopiera spårnings-JSON", "actionApprove": "Godkänn plan", "actionCancel": "Avbryt", "actionSeeInGraph": "Visa i grafen", "actionDone": "Åtgärd tillämpad", "actionFailed": "Åtgärden misslyckades: {error}", + "detailFailed": "Det gick inte att läsa in detaljer: {error}", "mirroredInA2A": "Speglad i A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index f777e7e65fa..98493aed96b 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13935,11 +13935,18 @@ "tabRouting": "Uelekezaji", "tabOverview": "Muhtasari", "showCompleted": "Onyesha vilivyokamilika", + "searchPlaceholder": "Tafuta kazi…", + "filterStates": "Hali", + "filterSources": "Vyanzo", + "filterProviders": "Watoa huduma", + "clearFilters": "Futa vichujio", "sourceCloudAgent": "Wakala wa Wingu", "sourceA2A": "A2A", "sourceConductor": "Msimamizi", "sourceStale": "Imepitwa na wakati tangu {since}", "sourceOffline": "Nje ya mtandao", + "sourceCollapse": "Kunja", + "sourceExpand": "Panua", "stateQueued": "Kwenye foleni", "stateRunning": "Inaendesha", "stateWaitingApproval": "Inasubiri idhini", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Vipimo", "drawerResult": "Matokeo", "drawerActions": "Vitendo", + "drawerClose": "Funga", + "copyTrace": "Nakili JSON ya ufuatiliaji", "actionApprove": "Idhinisha mpango", "actionCancel": "Ghairi", "actionSeeInGraph": "Ona kwenye grafu", "actionDone": "Kitendo kimetumika", "actionFailed": "Kitendo kimeshindwa: {error}", + "detailFailed": "Imeshindwa kupakia maelezo: {error}", "mirroredInA2A": "Kimeakisiwa katika A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index f7b4d3e6822..4c25ed53077 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13935,11 +13935,18 @@ "tabRouting": "ரூட்டிங்", "tabOverview": "மேலோட்டம்", "showCompleted": "முடிந்தவற்றைக் காட்டு", + "searchPlaceholder": "பணிகளைத் தேடு…", + "filterStates": "நிலைகள்", + "filterSources": "மூலங்கள்", + "filterProviders": "வழங்குநர்கள்", + "clearFilters": "வடிப்பான்களை அழி", "sourceCloudAgent": "கிளவுட் ஏஜென்ட்", "sourceA2A": "A2A", "sourceConductor": "கட்டுப்படுத்தி", "sourceStale": "{since} முதல் பழையது", "sourceOffline": "ஆஃப்லைன்", + "sourceCollapse": "சுருக்கு", + "sourceExpand": "விரிவாக்கு", "stateQueued": "வரிசையில்", "stateRunning": "இயங்குகிறது", "stateWaitingApproval": "ஒப்புதலுக்காகக் காத்திருக்கிறது", @@ -13956,11 +13963,14 @@ "drawerMetrics": "அளவீடுகள்", "drawerResult": "முடிவு", "drawerActions": "செயல்கள்", + "drawerClose": "மூடு", + "copyTrace": "ட்ரேஸ் JSON-ஐ நகலெடு", "actionApprove": "திட்டத்தை ஒப்புதல் அளி", "actionCancel": "ரத்துசெய்", "actionSeeInGraph": "வரைபடத்தில் பார்", "actionDone": "செயல் பயன்படுத்தப்பட்டது", "actionFailed": "செயல் தோல்வியடைந்தது: {error}", + "detailFailed": "விவரங்களை ஏற்ற முடியவில்லை: {error}", "mirroredInA2A": "A2A இல் பிரதிபலிக்கப்பட்டது" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 40b346360d7..05f9deccf7b 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13935,11 +13935,18 @@ "tabRouting": "రూటింగ్", "tabOverview": "అవలోకనం", "showCompleted": "పూర్తయినవి చూపించు", + "searchPlaceholder": "పనులను వెతకండి…", + "filterStates": "స్థితులు", + "filterSources": "మూలాలు", + "filterProviders": "ప్రదాతలు", + "clearFilters": "ఫిల్టర్‌లను క్లియర్ చేయి", "sourceCloudAgent": "క్లౌడ్ ఏజెంట్", "sourceA2A": "A2A", "sourceConductor": "కండక్టర్", "sourceStale": "{since} నుండి పాతది", "sourceOffline": "ఆఫ్‌లైన్", + "sourceCollapse": "కుదించు", + "sourceExpand": "విస్తరించు", "stateQueued": "క్యూలో ఉంది", "stateRunning": "నడుస్తోంది", "stateWaitingApproval": "ఆమోదం కోసం వేచి ఉంది", @@ -13956,11 +13963,14 @@ "drawerMetrics": "మెట్రిక్స్", "drawerResult": "ఫలితం", "drawerActions": "చర్యలు", + "drawerClose": "మూసివేయి", + "copyTrace": "ట్రేస్ JSON కాపీ చేయి", "actionApprove": "ప్రణాళికను ఆమోదించండి", "actionCancel": "రద్దు చేయండి", "actionSeeInGraph": "గ్రాఫ్‌లో చూడండి", "actionDone": "చర్య వర్తింపజేయబడింది", "actionFailed": "చర్య విఫలమైంది: {error}", + "detailFailed": "వివరాలను లోడ్ చేయడంలో విఫలమైంది: {error}", "mirroredInA2A": "A2Aలో ప్రతిబింబించబడింది" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 5d6451770ca..7b4deb50271 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13935,11 +13935,18 @@ "tabRouting": "การกำหนดเส้นทาง", "tabOverview": "ภาพรวม", "showCompleted": "แสดงที่เสร็จสิ้นแล้ว", + "searchPlaceholder": "ค้นหางาน…", + "filterStates": "สถานะ", + "filterSources": "แหล่งที่มา", + "filterProviders": "ผู้ให้บริการ", + "clearFilters": "ล้างตัวกรอง", "sourceCloudAgent": "เอเจนต์คลาวด์", "sourceA2A": "A2A", "sourceConductor": "ตัวนำ", "sourceStale": "ไม่มีการอัปเดตตั้งแต่ {since}", "sourceOffline": "ออฟไลน์", + "sourceCollapse": "ย่อ", + "sourceExpand": "ขยาย", "stateQueued": "อยู่ในคิว", "stateRunning": "กำลังทำงาน", "stateWaitingApproval": "รอการอนุมัติ", @@ -13956,11 +13963,14 @@ "drawerMetrics": "เมตริก", "drawerResult": "ผลลัพธ์", "drawerActions": "การดำเนินการ", + "drawerClose": "ปิด", + "copyTrace": "คัดลอก JSON การติดตาม", "actionApprove": "อนุมัติแผน", "actionCancel": "ยกเลิก", "actionSeeInGraph": "ดูในกราฟ", "actionDone": "ดำเนินการเรียบร้อยแล้ว", "actionFailed": "การดำเนินการล้มเหลว: {error}", + "detailFailed": "โหลดรายละเอียดไม่สำเร็จ: {error}", "mirroredInA2A": "สะท้อนใน A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index c62cc8a0422..9aad233c382 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13935,11 +13935,18 @@ "tabRouting": "Yönlendirme", "tabOverview": "Genel Bakış", "showCompleted": "Tamamlananları göster", + "searchPlaceholder": "Görevlerde ara…", + "filterStates": "Durumlar", + "filterSources": "Kaynaklar", + "filterProviders": "Sağlayıcılar", + "clearFilters": "Filtreleri temizle", "sourceCloudAgent": "Bulut Ajanı", "sourceA2A": "A2A", "sourceConductor": "İletken", "sourceStale": "{since} tarihinden beri güncel değil", "sourceOffline": "Çevrimdışı", + "sourceCollapse": "Daralt", + "sourceExpand": "Genişlet", "stateQueued": "Sırada", "stateRunning": "Çalışıyor", "stateWaitingApproval": "Onay bekliyor", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Metrikler", "drawerResult": "Sonuç", "drawerActions": "Eylemler", + "drawerClose": "Kapat", + "copyTrace": "İzleme JSON'ını kopyala", "actionApprove": "Planı onayla", "actionCancel": "İptal et", "actionSeeInGraph": "Grafikte gör", "actionDone": "İşlem uygulandı", "actionFailed": "İşlem başarısız: {error}", + "detailFailed": "Ayrıntılar yüklenemedi: {error}", "mirroredInA2A": "A2A'da yansıtıldı" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 452055bb79f..0c613b106d1 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13935,11 +13935,18 @@ "tabRouting": "Маршрутизація", "tabOverview": "Огляд", "showCompleted": "Показати завершені", + "searchPlaceholder": "Пошук завдань…", + "filterStates": "Стани", + "filterSources": "Джерела", + "filterProviders": "Провайдери", + "clearFilters": "Очистити фільтри", "sourceCloudAgent": "Хмарний агент", "sourceA2A": "A2A", "sourceConductor": "Кондуктор", "sourceStale": "Немає оновлень з {since}", "sourceOffline": "Офлайн", + "sourceCollapse": "Згорнути", + "sourceExpand": "Розгорнути", "stateQueued": "У черзі", "stateRunning": "Виконується", "stateWaitingApproval": "Очікує затвердження", @@ -13956,11 +13963,14 @@ "drawerMetrics": "Метрики", "drawerResult": "Результат", "drawerActions": "Дії", + "drawerClose": "Закрити", + "copyTrace": "Копіювати трасування JSON", "actionApprove": "Затвердити план", "actionCancel": "Скасувати", "actionSeeInGraph": "Переглянути на графі", "actionDone": "Дію виконано", "actionFailed": "Дія не виконана: {error}", + "detailFailed": "Не вдалося завантажити деталі: {error}", "mirroredInA2A": "Відображено в A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 8d9fc93d4a7..62b1900f1c9 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13935,11 +13935,18 @@ "tabRouting": "روٹنگ", "tabOverview": "جائزہ", "showCompleted": "مکمل شدہ دکھائیں", + "searchPlaceholder": "کاموں کو تلاش کریں…", + "filterStates": "حالتیں", + "filterSources": "ذرائع", + "filterProviders": "فراہم کنندگان", + "clearFilters": "فلٹرز صاف کریں", "sourceCloudAgent": "کلاؤڈ ایجنٹ", "sourceA2A": "A2A", "sourceConductor": "کنڈکٹر", "sourceStale": "{since} سے تازہ نہیں", "sourceOffline": "آف لائن", + "sourceCollapse": "سکیڑیں", + "sourceExpand": "پھیلائیں", "stateQueued": "قطار میں", "stateRunning": "چل رہا ہے", "stateWaitingApproval": "منظوری کا منتظر", @@ -13956,11 +13963,14 @@ "drawerMetrics": "میٹرکس", "drawerResult": "نتیجہ", "drawerActions": "اقدامات", + "drawerClose": "بند کریں", + "copyTrace": "ٹریس JSON کاپی کریں", "actionApprove": "منصوبہ منظور کریں", "actionCancel": "منسوخ کریں", "actionSeeInGraph": "گراف میں دیکھیں", "actionDone": "اقدام لاگو ہو گیا", "actionFailed": "اقدام ناکام: {error}", + "detailFailed": "تفصیلات لوڈ کرنے میں ناکامی: {error}", "mirroredInA2A": "A2A میں مطابقت شدہ" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 1c016d98d7b..513c547bdbd 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13943,11 +13943,18 @@ "tabRouting": "Định tuyến", "tabOverview": "Tổng quan", "showCompleted": "Hiển thị đã hoàn tất", + "searchPlaceholder": "Tìm kiếm tác vụ…", + "filterStates": "Trạng thái", + "filterSources": "Nguồn", + "filterProviders": "Nhà cung cấp", + "clearFilters": "Xóa bộ lọc", "sourceCloudAgent": "Tác nhân đám mây", "sourceA2A": "A2A", "sourceConductor": "Người dẫn dắt", "sourceStale": "Không cập nhật từ {since}", "sourceOffline": "Ngoại tuyến", + "sourceCollapse": "Thu gọn", + "sourceExpand": "Mở rộng", "stateQueued": "Đang xếp hàng", "stateRunning": "Đang chạy", "stateWaitingApproval": "Chờ phê duyệt", @@ -13964,11 +13971,14 @@ "drawerMetrics": "Chỉ số", "drawerResult": "Kết quả", "drawerActions": "Hành động", + "drawerClose": "Đóng", + "copyTrace": "Sao chép JSON theo dõi", "actionApprove": "Phê duyệt kế hoạch", "actionCancel": "Hủy", "actionSeeInGraph": "Xem trong sơ đồ", "actionDone": "Đã áp dụng hành động", "actionFailed": "Hành động thất bại: {error}", + "detailFailed": "Không tải được chi tiết: {error}", "mirroredInA2A": "Được phản chiếu trong A2A" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0d423362755..1cecd5e1e82 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13935,11 +13935,18 @@ "tabRouting": "路由", "tabOverview": "概览", "showCompleted": "显示已完成", + "searchPlaceholder": "搜索任务…", + "filterStates": "状态", + "filterSources": "来源", + "filterProviders": "提供者", + "clearFilters": "清除筛选", "sourceCloudAgent": "云代理", "sourceA2A": "A2A", "sourceConductor": "导体", "sourceStale": "自 {since} 起未更新", "sourceOffline": "离线", + "sourceCollapse": "折叠", + "sourceExpand": "展开", "stateQueued": "已排队", "stateRunning": "运行中", "stateWaitingApproval": "等待批准", @@ -13956,11 +13963,14 @@ "drawerMetrics": "指标", "drawerResult": "结果", "drawerActions": "操作", + "drawerClose": "关闭", + "copyTrace": "复制追踪 JSON", "actionApprove": "批准计划", "actionCancel": "取消", "actionSeeInGraph": "在图中查看", "actionDone": "操作已应用", "actionFailed": "操作失败:{error}", + "detailFailed": "加载详情失败:{error}", "mirroredInA2A": "已在 A2A 中镜像" }, "cliproxyProviderExposure": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 08bf741f137..738e15e6bfe 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13935,11 +13935,18 @@ "tabRouting": "路由", "tabOverview": "總覽", "showCompleted": "顯示已完成", + "searchPlaceholder": "搜尋任務…", + "filterStates": "狀態", + "filterSources": "來源", + "filterProviders": "提供者", + "clearFilters": "清除篩選", "sourceCloudAgent": "雲端代理", "sourceA2A": "A2A", "sourceConductor": "導體", "sourceStale": "自 {since} 起未更新", "sourceOffline": "離線", + "sourceCollapse": "收合", + "sourceExpand": "展開", "stateQueued": "佇列中", "stateRunning": "執行中", "stateWaitingApproval": "等待核准", @@ -13956,11 +13963,14 @@ "drawerMetrics": "指標", "drawerResult": "結果", "drawerActions": "動作", + "drawerClose": "關閉", + "copyTrace": "複製追蹤 JSON", "actionApprove": "核准計畫", "actionCancel": "取消", "actionSeeInGraph": "在圖中檢視", "actionDone": "動作已套用", "actionFailed": "動作失敗:{error}", + "detailFailed": "載入詳細資料失敗:{error}", "mirroredInA2A": "已在 A2A 中鏡射" }, "cliproxyProviderExposure": { diff --git a/tests/unit/ui/orchestrationDrawer.test.tsx b/tests/unit/ui/orchestrationDrawer.test.tsx index 5082b98fd0d..9142d22f1e2 100644 --- a/tests/unit/ui/orchestrationDrawer.test.tsx +++ b/tests/unit/ui/orchestrationDrawer.test.tsx @@ -8,7 +8,10 @@ vi.mock("next-intl", () => ({ v ? `${k}:${JSON.stringify(v)}` : k, })); -import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer"; +import { + OrchestrationDrawer, + buildTraceJson, +} from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer"; function render(el: React.ReactElement) { const c = document.createElement("div"); @@ -240,4 +243,205 @@ describe("OrchestrationDrawer", () => { expect(link).toBeTruthy(); cleanup(); }); + + it("close button aria-label comes from i18n (drawerClose, not the literal 'close')", () => { + const node = { id: "overflow:1", kind: "overflow", state: "running", label: "x", raw: {} }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + expect(c.querySelector('[aria-label="drawerClose"]')).toBeTruthy(); + expect(c.querySelector('[aria-label="close"]')).toBeNull(); + cleanup(); + }); + + it("copy trace button copies buildTraceJson output to the clipboard and shows the actionDone toast", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + const node = { + id: "overflow:1", + kind: "overflow", + state: "running", + label: "x", + raw: { a: 1 }, + }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + const btn = c.querySelector('[aria-label="copyTrace"]') as HTMLButtonElement; + expect(btn).toBeTruthy(); + await act(async () => { + btn.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toBe(buildTraceJson(node as never, node.raw)); + expect(c.textContent).toContain("actionDone"); + cleanup(); + }); + + it("copy trace shows actionFailed:clipboard toast when the clipboard write rejects", async () => { + const writeText = vi.fn(() => Promise.reject(new Error("denied"))); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + const node = { id: "overflow:1", kind: "overflow", state: "running", label: "x", raw: {} }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + const btn = c.querySelector('[aria-label="copyTrace"]') as HTMLButtonElement; + await act(async () => { + btn.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(c.textContent).toContain("actionFailed"); + expect(c.textContent).toContain("clipboard"); + cleanup(); + }); + + it("shows detailFailed for a fetch error and actionFailed for a subsequent action error", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) }); + } + return Promise.reject(new Error("network down")); + }); + 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(); + }); + expect(c.textContent).toContain("detailFailed"); + expect(c.textContent).not.toContain("actionFailed"); + + const btn = Array.from(c.querySelectorAll("button")).find((b) => + b.textContent?.includes("actionApprove") + ) as HTMLButtonElement; + await act(async () => { + btn.click(); + await Promise.resolve(); + }); + expect(c.textContent).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) => { + if (init?.method === "POST") { + return new Promise((resolve) => { + resolvePost = resolve; + }); + } + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { + id: "t1", + status: "awaiting_approval", + activities: [], + prompt: "", + providerId: "devin", + source: { repoName: "r", repoUrl: "https://x" }, + options: {}, + createdAt: "x", + updatedAt: "y", + }, + }), + }); + }); + 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 approveBtn = Array.from(c.querySelectorAll("button")).find((b) => + b.textContent?.includes("actionApprove") + ) as HTMLButtonElement; + const cancelBtn = Array.from(c.querySelectorAll("button")).find((b) => + b.textContent?.includes("actionCancel") + ) as HTMLButtonElement; + expect(approveBtn.disabled).toBe(false); + expect(cancelBtn.disabled).toBe(false); + + await act(async () => { + approveBtn.click(); + await Promise.resolve(); + }); + expect(approveBtn.disabled).toBe(true); + expect(cancelBtn.disabled).toBe(true); + + await act(async () => { + resolvePost?.({ ok: true, json: () => Promise.resolve({ data: {} }) }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(approveBtn.disabled).toBe(false); + expect(cancelBtn.disabled).toBe(false); + cleanup(); + }); +}); + +describe("buildTraceJson", () => { + it("normalizes cloud-agent timeline from detail.activities and includes node identity + raw", () => { + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "running", + label: "x", + }; + const detail = { activities: [{ id: "a1", type: "plan", content: "c" }] }; + const parsed = JSON.parse(buildTraceJson(node as never, detail)); + expect(parsed).toEqual({ + node: { id: "cloud-agent:t1", source: "cloud-agent", state: "running", label: "x" }, + timeline: detail.activities, + raw: detail, + }); + }); + + it("normalizes a2a timeline from detail.events", () => { + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const detail = { events: [{ state: "working", timestamp: "t" }] }; + const parsed = JSON.parse(buildTraceJson(node as never, detail)); + expect(parsed.timeline).toEqual(detail.events); + }); + + it("uses a null timeline and falls back to node.raw for conductor/overflow sources", () => { + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "running", + label: "x", + raw: { foo: "bar" }, + }; + const parsed = JSON.parse(buildTraceJson(node as never, null)); + expect(parsed.timeline).toBeNull(); + expect(parsed.raw).toEqual({ foo: "bar" }); + }); }); diff --git a/tests/unit/ui/orchestrationFilter.test.ts b/tests/unit/ui/orchestrationFilter.test.ts new file mode 100644 index 00000000000..0936abb2c37 --- /dev/null +++ b/tests/unit/ui/orchestrationFilter.test.ts @@ -0,0 +1,244 @@ +/** + * tests/unit/ui/orchestrationFilter.test.ts + * Run: node --import tsx/esm --test tests/unit/ui/orchestrationFilter.test.ts + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + EMPTY_FILTER, + filterSnapshot, + isEmptyFilter, + nodeProviderKey, + collectProviderKeys, + type OrchFilter, +} from "../../../src/app/(dashboard)/dashboard/orchestration/model/filterSnapshot.ts"; +import type { + OrchNode, + OrchSnapshot, +} from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts"; + +function filterWith(over: Partial): OrchFilter { + return { ...EMPTY_FILTER, ...over }; +} + +// Fixture: orchestrator + 3 sources + works: +// cloud-agent:1 (label "fix login bug", sublabel "jules", raw {providerId:"jules"}, running) +// + cloud-agent:1:activity (activity follows its parent work node) +// a2a:2 (label "smart-routing", state succeeded, raw {}) +// conductor:task:3 (label "deploy", raw {runner:"vm-9"}, state failed) +// overflow:cloud-agent (always survives; counts/droppedByState must stay untouched) +const caNode: OrchNode = { + id: "cloud-agent:1", + kind: "work", + source: "cloud-agent", + state: "running", + label: "fix login bug", + sublabel: "jules", + raw: { providerId: "jules" }, +}; +const caActivity: OrchNode = { + id: "cloud-agent:1:activity", + kind: "activity", + source: "cloud-agent", + label: "npm test", +}; +const a2aNode: OrchNode = { + id: "a2a:2", + kind: "work", + source: "a2a", + state: "succeeded", + label: "smart-routing", + raw: {}, +}; +const condNode: OrchNode = { + id: "conductor:task:3", + kind: "work", + source: "conductor", + state: "failed", + label: "deploy", + raw: { runner: "vm-9" }, +}; +const overflowCounts = { failed: 3 }; +const overflowNode: OrchNode = { + id: "overflow:cloud-agent", + kind: "overflow", + source: "cloud-agent", + label: "+3 more", + counts: overflowCounts, + droppedByState: overflowCounts, +}; +const sourceCloudAgent: OrchNode = { + id: "source:cloud-agent", + kind: "source", + source: "cloud-agent", + label: "cloud-agent", + counts: { running: 1 }, +}; +const sourceA2A: OrchNode = { + id: "source:a2a", + kind: "source", + source: "a2a", + label: "a2a", + counts: { succeeded: 1 }, +}; +const sourceConductor: OrchNode = { + id: "source:conductor", + kind: "source", + source: "conductor", + label: "conductor", + counts: { failed: 1 }, +}; +const orchestratorNode: OrchNode = { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }; + +const snap: OrchSnapshot = { + nodes: [ + orchestratorNode, + sourceCloudAgent, + sourceA2A, + sourceConductor, + caNode, + caActivity, + a2aNode, + condNode, + overflowNode, + ], + edges: [ + { + id: "e:orchestrator→source:cloud-agent", + from: "orchestrator", + to: "source:cloud-agent", + kind: "owns", + active: false, + }, + { + id: "e:orchestrator→source:a2a", + from: "orchestrator", + to: "source:a2a", + kind: "owns", + active: false, + }, + { + id: "e:orchestrator→source:conductor", + from: "orchestrator", + to: "source:conductor", + kind: "owns", + active: false, + }, + { + id: "e:source:cloud-agent→cloud-agent:1", + from: "source:cloud-agent", + to: "cloud-agent:1", + kind: "owns", + active: true, + }, + { + id: "e:cloud-agent:1→cloud-agent:1:activity", + from: "cloud-agent:1", + to: "cloud-agent:1:activity", + kind: "owns", + active: true, + }, + { + id: "e:source:a2a→a2a:2", + from: "source:a2a", + to: "a2a:2", + kind: "owns", + active: false, + }, + { + id: "e:source:conductor→conductor:task:3", + from: "source:conductor", + to: "conductor:task:3", + kind: "owns", + active: false, + }, + { + id: "e:source:cloud-agent→overflow:cloud-agent", + from: "source:cloud-agent", + to: "overflow:cloud-agent", + kind: "owns", + active: false, + }, + ], + sources: [ + { source: "cloud-agent", ok: true }, + { source: "a2a", ok: true }, + { source: "conductor", ok: true }, + ], + generatedAt: "2026-09-01T00:00:00Z", +}; + +test("empty filter returns the same reference", () => { + assert.equal(filterSnapshot(snap, EMPTY_FILTER), snap); + assert.equal(isEmptyFilter(EMPTY_FILTER), true); +}); + +test("q matches label case-insensitively and drops non-matching works + their activities", () => { + const out = filterSnapshot(snap, filterWith({ q: "SMART" })); + const ids = out.nodes.map((n) => n.id); + assert.ok(ids.includes("a2a:2")); + assert.ok(!ids.includes("cloud-agent:1"), "non-matching work dropped"); + assert.ok(!ids.includes("cloud-agent:1:activity"), "its activity is dropped along with it"); + assert.ok(!ids.includes("conductor:task:3"), "non-matching work dropped"); +}); + +test("q matching a work keeps its activity node and the edges between them", () => { + const out = filterSnapshot(snap, filterWith({ q: "login" })); + const ids = out.nodes.map((n) => n.id); + assert.ok(ids.includes("cloud-agent:1")); + assert.ok(ids.includes("cloud-agent:1:activity")); + assert.ok( + out.edges.some((e) => e.from === "cloud-agent:1" && e.to === "cloud-agent:1:activity") + ); +}); + +test("state chip keeps only matching works", () => { + const out = filterSnapshot(snap, filterWith({ states: new Set(["failed"]) })); + const works = out.nodes.filter((n) => n.kind === "work").map((n) => n.id); + assert.deepEqual(works, ["conductor:task:3"]); + assert.ok(!out.nodes.some((n) => n.id === "cloud-agent:1:activity")); +}); + +test("source chip keeps only matching works", () => { + const out = filterSnapshot(snap, filterWith({ sources: new Set(["a2a"]) })); + const works = out.nodes.filter((n) => n.kind === "work").map((n) => n.id); + assert.deepEqual(works, ["a2a:2"]); +}); + +test("provider chip matches nodeProviderKey", () => { + assert.equal(nodeProviderKey(caNode), "jules"); + assert.equal(nodeProviderKey(a2aNode), null); + assert.equal(nodeProviderKey(condNode), "vm-9"); + + const out = filterSnapshot(snap, filterWith({ providers: new Set(["jules"]) })); + const works = out.nodes.filter((n) => n.kind === "work").map((n) => n.id); + assert.deepEqual(works, ["cloud-agent:1"]); +}); + +test("dimensions AND together", () => { + // "deploy" matches conductor:task:3's label, but that node is state "failed" — asking + // for state "running" too must yield no work survivors even though q alone would match. + const out = filterSnapshot(snap, filterWith({ q: "deploy", states: new Set(["running"]) })); + assert.equal(out.nodes.filter((n) => n.kind === "work").length, 0); +}); + +test("source/orchestrator/overflow nodes always survive; counts untouched", () => { + // A query that matches nothing at all — every work node is dropped. + const out = filterSnapshot(snap, filterWith({ q: "nothing-matches-this" })); + assert.equal(out.nodes.filter((n) => n.kind === "work").length, 0); + + assert.ok(out.nodes.some((n) => n.id === "orchestrator")); + const sources = out.nodes.filter((n) => n.kind === "source"); + assert.equal(sources.length, 3); + const survivedSourceCloudAgent = out.nodes.find((n) => n.id === "source:cloud-agent"); + assert.equal(survivedSourceCloudAgent?.counts, sourceCloudAgent.counts); + + const survivedOverflow = out.nodes.find((n) => n.id === "overflow:cloud-agent"); + assert.ok(survivedOverflow, "overflow node must survive even with zero matching works"); + assert.equal(survivedOverflow?.counts, overflowNode.counts); + assert.equal(survivedOverflow?.droppedByState, overflowNode.droppedByState); +}); + +test("collectProviderKeys returns sorted distinct non-null keys", () => { + assert.deepEqual(collectProviderKeys(snap), ["jules", "vm-9"]); +}); diff --git a/tests/unit/ui/orchestrationModel.test.ts b/tests/unit/ui/orchestrationModel.test.ts index 08a728e29a4..a653220852f 100644 --- a/tests/unit/ui/orchestrationModel.test.ts +++ b/tests/unit/ui/orchestrationModel.test.ts @@ -7,8 +7,8 @@ import assert from "node:assert/strict"; import { ORCH_STATES, orchStateColor, + orchStateBadgeBg, } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts"; -import { STATUS_HEX } from "../../../src/shared/constants/statusColors.ts"; import { fromCloudAgent } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts"; import type { CloudAgentTask } from "../../../src/lib/cloudAgent/types.ts"; import { fromA2A } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromA2A.ts"; @@ -22,20 +22,30 @@ import { } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts"; describe("orchestrationTypes", () => { - it("covers all six states with a color each", () => { + it("covers all six states with a theme-aware CSS var color each", () => { assert.equal(ORCH_STATES.length, 6); for (const s of ORCH_STATES) { - assert.match(orchStateColor(s), /^#[0-9a-f]{6}$/i, s); + assert.match(orchStateColor(s), /^var\(--orch-status-[a-z]+\)$/, s); } }); - it("waiting_approval maps to the new STATUS_HEX.approval violet", () => { - assert.equal(orchStateColor("waiting_approval"), STATUS_HEX.approval); - assert.equal(STATUS_HEX.approval, "#8b5cf6"); + it("waiting_approval maps to the approval token", () => { + assert.equal(orchStateColor("waiting_approval"), "var(--orch-status-approval)"); }); it("running maps to warning, succeeded to success, failed to error", () => { - assert.equal(orchStateColor("running"), STATUS_HEX.warning); - assert.equal(orchStateColor("succeeded"), STATUS_HEX.success); - assert.equal(orchStateColor("failed"), STATUS_HEX.error); + assert.equal(orchStateColor("running"), "var(--orch-status-warning)"); + assert.equal(orchStateColor("succeeded"), "var(--orch-status-success)"); + assert.equal(orchStateColor("failed"), "var(--orch-status-error)"); + }); + it("queued and cancelled both map to the muted token", () => { + assert.equal(orchStateColor("queued"), "var(--orch-status-muted)"); + assert.equal(orchStateColor("cancelled"), "var(--orch-status-muted)"); + }); + it("orchStateBadgeBg produces a color-mix over the matching state token", () => { + for (const s of ORCH_STATES) { + const bg = orchStateBadgeBg(s); + assert.match(bg, /^color-mix\(in srgb, var\(--orch-status-[a-z]+\) 13%, transparent\)$/, s); + assert.ok(bg.includes(orchStateColor(s)), `${s} badge bg should embed its own token`); + } }); }); @@ -326,6 +336,45 @@ describe("mergeSnapshot", () => { ) ); }); + it("failed source placeholder carries the typed sourceIssue union alongside sublabel", () => { + const errSrc = [{ source: "conductor" as const, ok: false }]; + const errSnap = mergeSnapshot({ cloudAgent: empty, a2a: empty, conductor: empty }, errSrc, { + now: NOW, + }); + const errNode = errSnap.nodes.find((n) => n.id === "source:conductor"); + assert.equal(errNode?.sourceIssue, "error"); + assert.equal(errNode?.sublabel, "error"); + + const offlineSrc = [{ source: "conductor" as const, ok: true, offline: true }]; + const offlineSnap = mergeSnapshot( + { cloudAgent: empty, a2a: empty, conductor: empty }, + offlineSrc, + { now: NOW } + ); + const offlineNode = offlineSnap.nodes.find((n) => n.id === "source:conductor"); + assert.equal(offlineNode?.sourceIssue, "offline"); + assert.equal(offlineNode?.sublabel, "offline"); + }); + it("failed source placeholder carries staleSince from the SourceStatus (undefined when the status has none)", () => { + const staleSince = "2026-09-01T12:00:00.000Z"; + const errSrc = [{ source: "conductor" as const, ok: false, staleSince }]; + const errSnap = mergeSnapshot({ cloudAgent: empty, a2a: empty, conductor: empty }, errSrc, { + now: NOW, + }); + const errNode = errSnap.nodes.find((n) => n.id === "source:conductor"); + assert.equal(errNode?.staleSince, staleSince); + + // buildSourceStatuses never sets staleSince for the offline case — mergeSnapshot must + // not invent one, so the placeholder node's staleSince stays undefined. + const offlineSrc = [{ source: "conductor" as const, ok: true, offline: true }]; + const offlineSnap = mergeSnapshot( + { cloudAgent: empty, a2a: empty, conductor: empty }, + offlineSrc, + { now: NOW } + ); + const offlineNode = offlineSnap.nodes.find((n) => n.id === "source:conductor"); + assert.equal(offlineNode?.staleSince, undefined); + }); it("overflow node carries droppedByState with the per-state counts of dropped work nodes", () => { const many = Array.from({ length: MAX_WORK_NODES + 5 }, (_, i) => caTask({ @@ -343,6 +392,25 @@ describe("mergeSnapshot", () => { assert.ok(overflow, "overflow node expected"); assert.deepEqual(overflow?.droppedByState, { failed: 5 }); }); + it("droppedByState is not a shared reference with counts — mutating counts after merge leaves it untouched", () => { + const many = Array.from({ length: MAX_WORK_NODES + 5 }, (_, i) => + caTask({ + id: `dbs${i}`, + status: i < MAX_WORK_NODES ? "running" : "failed", + updatedAt: new Date(NOW - i * 1000).toISOString(), + }) + ); + const snap = mergeSnapshot( + { cloudAgent: fromCloudAgent(many), a2a: empty, conductor: empty }, + OK_SOURCES, + { now: NOW } + ); + const overflow = snap.nodes.find((n) => n.id === "overflow:cloud-agent"); + assert.ok(overflow, "overflow node expected"); + const before = { ...overflow?.droppedByState }; + if (overflow?.counts) overflow.counts.failed = 999; + assert.deepEqual(overflow?.droppedByState, before, "droppedByState must not alias counts"); + }); it("drops a stale terminal Conductor task older than STALE_COMPLETED_MS unless showCompleted", () => { const staleSnap: FleetSnapshot = { ...baseSnap, diff --git a/tests/unit/ui/orchestrationNodes.test.tsx b/tests/unit/ui/orchestrationNodes.test.tsx index c69ec4971d9..f98429700e3 100644 --- a/tests/unit/ui/orchestrationNodes.test.tsx +++ b/tests/unit/ui/orchestrationNodes.test.tsx @@ -19,8 +19,13 @@ vi.mock("next-intl", () => ({ v ? `${k}:${JSON.stringify(v)}` : k, })); +import type { EdgeProps } from "@xyflow/react"; import { WorkNode } from "@/app/(dashboard)/dashboard/orchestration/nodes/WorkNode"; import { SourceNode } from "@/app/(dashboard)/dashboard/orchestration/nodes/SourceNode"; +import { OrchestratorNode } from "@/app/(dashboard)/dashboard/orchestration/nodes/OrchestratorNode"; +import { ActivityNode } from "@/app/(dashboard)/dashboard/orchestration/nodes/ActivityNode"; +import { OverflowNode } from "@/app/(dashboard)/dashboard/orchestration/nodes/OverflowNode"; +import { StatusEdge } from "@/app/(dashboard)/dashboard/orchestration/edges/StatusEdge"; function render(el: React.ReactElement) { const c = document.createElement("div"); @@ -55,16 +60,190 @@ describe("orchestration nodes", () => { expect(c.querySelector("[aria-label]")).toBeTruthy(); cleanup(); }); - it("SourceNode with sublabel=error shows the warning marker", () => { + it("SourceNode with sourceIssue=error shows the warning marker", () => { const data = { id: "source:a2a", kind: "source", source: "a2a", label: "A2A", sublabel: "error", + sourceIssue: "error", }; const { c, cleanup } = render(); expect(c.textContent).toContain("⚠"); cleanup(); }); + + it("SourceNode with sourceIssue=error and a parseable staleSince renders the formatted time in sourceStale", () => { + const staleSince = "2026-09-01T12:34:56.000Z"; + const data = { + id: "source:a2a", + kind: "source", + source: "a2a", + label: "A2A", + sublabel: "error", + sourceIssue: "error", + staleSince, + }; + const { c, cleanup } = render(); + // Mocked next-intl `t` returns `${key}:${JSON.stringify(values)}` when values are passed — + // asserts the component actually forwards `{ since }`, not just the raw key. + const since = new Date(staleSince).toLocaleTimeString(); + expect(c.textContent).toContain(`sourceStale:${JSON.stringify({ since })}`); + cleanup(); + }); + + it("SourceNode with sourceIssue=error and no staleSince falls back to an em dash", () => { + const data = { + id: "source:a2a", + kind: "source", + source: "a2a", + label: "A2A", + sublabel: "error", + sourceIssue: "error", + }; + const { c, cleanup } = render(); + expect(c.textContent).toContain(`sourceStale:${JSON.stringify({ since: "—" })}`); + cleanup(); + }); + + it("SourceNode with sourceIssue=error and an unparseable staleSince also falls back to an em dash", () => { + const data = { + id: "source:a2a", + kind: "source", + source: "a2a", + label: "A2A", + sublabel: "error", + sourceIssue: "error", + staleSince: "not-a-date", + }; + const { c, cleanup } = render(); + expect(c.textContent).toContain(`sourceStale:${JSON.stringify({ since: "—" })}`); + cleanup(); + }); + + it("all 5 memo'd orchestration node components have a displayName", () => { + expect(WorkNode.displayName).toBe("WorkNode"); + expect(SourceNode.displayName).toBe("SourceNode"); + expect(OrchestratorNode.displayName).toBe("OrchestratorNode"); + expect(ActivityNode.displayName).toBe("ActivityNode"); + expect(OverflowNode.displayName).toBe("OverflowNode"); + }); + + it("OrchestratorNode renders the label as text content and as aria-label", () => { + const data = { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }; + const { c, cleanup } = render(); + expect(c.textContent).toContain("OmniRoute"); + expect(c.querySelector('[aria-label="OmniRoute"]')).toBeTruthy(); + cleanup(); + }); + + it("ActivityNode renders label + sublabel and exposes the label as aria-label", () => { + const data = { + id: "cloud-agent:t1:activity", + kind: "activity", + source: "cloud-agent", + label: "npm test", + sublabel: "command", + }; + const { c, cleanup } = render(); + expect(c.textContent).toContain("npm test"); + expect(c.textContent).toContain("command"); + expect(c.querySelector('[aria-label="npm test"]')).toBeTruthy(); + cleanup(); + }); + + it("OverflowNode renders the overflowMore count in text + aria-label and a badge per non-zero state", () => { + const data = { + id: "overflow:cloud-agent", + kind: "overflow", + source: "cloud-agent", + label: "+7 more", + counts: { running: 5, failed: 2, queued: 0 }, + }; + const { c, cleanup } = render(); + // Mocked next-intl `t` returns `${key}:${JSON.stringify(values)}` — asserts the + // component forwards the summed total (5 + 2 = 7), not a raw/stale count. + const expectedText = `overflowMore:${JSON.stringify({ count: 7 })}`; + expect(c.textContent).toContain(expectedText); + const labelled = c.querySelector("[aria-label]"); + expect(labelled?.getAttribute("aria-label")).toBe(expectedText); + // One badge span per state with a non-zero count (running, failed) — queued (0) omitted. + const badges = Array.from(c.querySelectorAll("div.flex.gap-1 span")); + expect(badges.length).toBe(2); + expect(badges.map((b) => b.textContent)).toEqual(["5", "2"]); + cleanup(); + }); + + it("OverflowNode with no counts renders a zero total and no per-state badges", () => { + const data = { id: "overflow:a2a", kind: "overflow", source: "a2a", label: "+0 more" }; + const { c, cleanup } = render(); + expect(c.textContent).toContain(`overflowMore:${JSON.stringify({ count: 0 })}`); + expect(c.querySelectorAll("div.flex.gap-1 span").length).toBe(0); + cleanup(); + }); + + it("SourceNode shows a collapse caret + aria-expanded + title reflecting !data.collapsed", () => { + const expandedData = { id: "source:a2a", kind: "source", source: "a2a", label: "A2A" }; + const r1 = render(); + const expandedEl = r1.c.querySelector("[aria-expanded]"); + expect(expandedEl?.getAttribute("aria-expanded")).toBe("true"); + expect(expandedEl?.getAttribute("title")).toBe("sourceCollapse"); + expect(r1.c.textContent).toContain("▾"); + r1.cleanup(); + + const collapsedData = { ...expandedData, collapsed: true }; + const r2 = render(); + const collapsedEl = r2.c.querySelector("[aria-expanded]"); + expect(collapsedEl?.getAttribute("aria-expanded")).toBe("false"); + expect(collapsedEl?.getAttribute("title")).toBe("sourceExpand"); + expect(r2.c.textContent).toContain("▸"); + r2.cleanup(); + }); +}); + +describe("StatusEdge", () => { + const baseProps = { + id: "e1", + source: "a", + target: "b", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + sourcePosition: "bottom", + targetPosition: "top", + }; + + it("data.active renders PARTICLES ellipses, each starting opacity=0 with a paired reveal", () => { + const props = { ...baseProps, data: { state: "running", active: true, mirror: false } }; + const { c, cleanup } = render( + + + + ); + const ellipses = c.querySelectorAll("ellipse.orch-edge-particle"); + expect(ellipses.length).toBe(3); + ellipses.forEach((ellipse) => { + expect(ellipse.getAttribute("opacity")).toBe("0"); + const set = ellipse.querySelector("set"); + expect(set).toBeTruthy(); + expect(set?.getAttribute("attributeName")).toBe("opacity"); + expect(set?.getAttribute("to")).toBe("1"); + expect(set?.getAttribute("fill")).toBe("freeze"); + expect(ellipse.querySelector("animateMotion")).toBeTruthy(); + }); + cleanup(); + }); + + it("without data.active there is no particle ellipse", () => { + const props = { ...baseProps, data: { state: "succeeded", active: false, mirror: false } }; + const { c, cleanup } = render( + + + + ); + expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(0); + cleanup(); + }); }); diff --git a/tests/unit/ui/orchestrationPage.test.tsx b/tests/unit/ui/orchestrationPage.test.tsx index 95f69535419..299dd2efada 100644 --- a/tests/unit/ui/orchestrationPage.test.tsx +++ b/tests/unit/ui/orchestrationPage.test.tsx @@ -9,18 +9,20 @@ vi.mock("next-intl", () => ({ })); const replaceMock = vi.fn(); +const searchState = { current: "tab=overview" }; vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams("tab=overview"), + useSearchParams: () => new URLSearchParams(searchState.current), useRouter: () => ({ replace: replaceMock }), usePathname: () => "/dashboard/orchestration", })); -const snapshot = { +const DEFAULT_SNAPSHOT = { nodes: [{ id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }], edges: [], sources: [], generatedAt: "x", }; +let snapshot: typeof DEFAULT_SNAPSHOT = DEFAULT_SNAPSHOT; const setShowCompletedMock = vi.fn(); const refetchMock = vi.fn(); vi.mock("@/app/(dashboard)/dashboard/orchestration/hooks/useOrchestrationSnapshot", () => ({ @@ -45,14 +47,30 @@ vi.mock("@/hooks/useProviderBreakerHealth", () => ({ useProviderBreakerHealth: () => ({ providerHealth: {}, connectionHealth: {} }), })); +const agentsTabCalls: Record[] = []; vi.mock("@/app/(dashboard)/dashboard/orchestration/tabs/AgentsTab", () => ({ - AgentsTab: () =>
, + AgentsTab: (props: Record) => { + agentsTabCalls.push(props); + return
; + }, })); vi.mock("@/app/(dashboard)/dashboard/orchestration/tabs/RoutingTab", () => ({ RoutingTab: () =>
, })); +const overviewTabCalls: Record[] = []; vi.mock("@/app/(dashboard)/dashboard/orchestration/tabs/OverviewTab", () => ({ - OverviewTab: () =>
, + OverviewTab: (props: Record) => { + overviewTabCalls.push(props); + return
; + }, +})); + +const drawerCalls: Record[] = []; +vi.mock("@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer", () => ({ + OrchestrationDrawer: (props: Record) => { + drawerCalls.push(props); + return
; + }, })); import OrchestrationPageClient from "@/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient"; @@ -73,6 +91,11 @@ function render(el: React.ReactElement) { afterEach(() => { document.body.innerHTML = ""; replaceMock.mockClear(); + searchState.current = "tab=overview"; + snapshot = DEFAULT_SNAPSHOT; + agentsTabCalls.length = 0; + overviewTabCalls.length = 0; + drawerCalls.length = 0; }); describe("OrchestrationPageClient", () => { @@ -99,4 +122,135 @@ describe("OrchestrationPageClient", () => { expect(opts).toEqual({ scroll: false }); cleanup(); }); + + it("?q=login filters the snapshot passed to OverviewTab down to matching work nodes", () => { + snapshot = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { + id: "cloud-agent:1", + kind: "work", + source: "cloud-agent", + state: "running", + label: "login flow fix", + }, + { + id: "a2a:2", + kind: "work", + source: "a2a", + state: "failed", + label: "unrelated task", + }, + ], + edges: [], + sources: [], + generatedAt: "x", + } as never; + searchState.current = "tab=overview&q=login"; + const { cleanup } = render(); + const lastProps = overviewTabCalls.at(-1) as { snapshot: typeof DEFAULT_SNAPSHOT }; + const ids = lastProps.snapshot.nodes.map((n) => n.id); + expect(ids).toContain("cloud-agent:1"); + expect(ids).not.toContain("a2a:2"); + cleanup(); + }); + + it("clicking a state chip in the toolbar sets ?state= via router.replace", () => { + searchState.current = "tab=agents"; + const { c, cleanup } = render(); + const runningChip = Array.from(c.querySelectorAll("button")).find( + (el) => el.textContent === "stateRunning" + ) as HTMLButtonElement; + expect(runningChip).toBeTruthy(); + act(() => { + runningChip.click(); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + const [url] = replaceMock.mock.calls[0]; + expect(url).toContain("state=running"); + cleanup(); + }); + + it("toggling a collapse from AgentsTab writes ?collapsed= via router.replace", () => { + searchState.current = "tab=agents"; + const { cleanup } = render(); + const props = agentsTabCalls.at(-1) as { onToggleCollapse: (s: string) => void }; + act(() => { + props.onToggleCollapse("a2a"); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + const [url] = replaceMock.mock.calls[0]; + expect(url).toContain("collapsed=a2a"); + cleanup(); + }); + + it("shows a clear-filters button only when the filter is non-empty, and it resets q/state/source/provider", () => { + searchState.current = "tab=agents"; + const r1 = render(); + expect( + Array.from(r1.c.querySelectorAll("button")).find((el) => el.textContent === "clearFilters") + ).toBeFalsy(); + r1.cleanup(); + + searchState.current = + "tab=agents&q=login&state=running&source=a2a&provider=devin&collapsed=a2a"; + const r2 = render(); + const clearButton = Array.from(r2.c.querySelectorAll("button")).find( + (el) => el.textContent === "clearFilters" + ) as HTMLButtonElement; + expect(clearButton).toBeTruthy(); + act(() => { + clearButton.click(); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + const [url] = replaceMock.mock.calls[0]; + expect(url).not.toContain("q="); + expect(url).not.toContain("state="); + expect(url).not.toContain("source="); + expect(url).not.toContain("provider="); + expect(url).toContain("collapsed=a2a"); + r2.cleanup(); + }); + + it("?node= opens the drawer with the matching node; removing the param closes it", () => { + snapshot = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { + id: "cloud-agent:1", + kind: "work", + source: "cloud-agent", + state: "running", + label: "task A", + }, + ], + edges: [], + sources: [], + generatedAt: "x", + } as never; + + searchState.current = "tab=agents&node=cloud-agent:1"; + const r1 = render(); + expect((drawerCalls.at(-1) as { node: { id: string } | null }).node?.id).toBe("cloud-agent:1"); + r1.cleanup(); + + searchState.current = "tab=agents"; + const r2 = render(); + expect((drawerCalls.at(-1) as { node: { id: string } | null }).node).toBeNull(); + r2.cleanup(); + }); + + it("clicking an overflow node (via AgentsTab's onNodeClick) navigates to ?tab=overview and clears ?node", () => { + searchState.current = "tab=agents&node=cloud-agent:1"; + const { cleanup } = render(); + const props = agentsTabCalls.at(-1) as { onNodeClick: (id: string) => void }; + act(() => { + props.onNodeClick("overflow:cloud-agent"); + }); + expect(replaceMock).toHaveBeenCalledTimes(1); + const [url] = replaceMock.mock.calls[0]; + expect(url).toContain("tab=overview"); + expect(url).not.toContain("node="); + cleanup(); + }); }); diff --git a/tests/unit/ui/orchestrationTabs.test.tsx b/tests/unit/ui/orchestrationTabs.test.tsx index ce17e6fb162..b44ec79402e 100644 --- a/tests/unit/ui/orchestrationTabs.test.tsx +++ b/tests/unit/ui/orchestrationTabs.test.tsx @@ -89,6 +89,129 @@ describe("AgentsTab", () => { expect(r2.c.querySelector(".orchestration-canvas")).toBeTruthy(); r2.cleanup(); }); + + it("passes the collapsed set to orchestrationToFlow, dropping that source's work nodes", () => { + const snap = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { id: "source:a2a", kind: "source", source: "a2a", label: "A2A" }, + { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "a2a task" }, + { + id: "cloud-agent:1", + kind: "work", + source: "cloud-agent", + state: "running", + label: "ca task", + }, + ], + edges: [], + sources: [], + generatedAt: "x", + }; + const { cleanup } = render( + {}} + showCompleted={false} + onToggleCompleted={() => {}} + collapsed={new Set(["a2a"])} + onToggleCollapse={() => {}} + /> + ); + const nodes = flowProps.at(-1)?.nodes as Array<{ id: string }>; + expect(nodes.some((n) => n.id === "a2a:1")).toBe(false); + expect(nodes.some((n) => n.id === "cloud-agent:1")).toBe(true); + cleanup(); + }); + + it("clicking a source node calls onToggleCollapse with its source, not onNodeClick", () => { + const toggle = vi.fn(); + const onNodeClick = vi.fn(); + const snap = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "a2a task" }, + ], + edges: [], + sources: [], + generatedAt: "x", + }; + const { cleanup } = render( + {}} + collapsed={new Set()} + onToggleCollapse={toggle} + /> + ); + const handleClick = flowProps.at(-1)?.onNodeClick as (e: unknown, node: unknown) => void; + handleClick(undefined, { id: "source:a2a", type: "source", data: { source: "a2a" } }); + expect(toggle).toHaveBeenCalledWith("a2a"); + expect(onNodeClick).not.toHaveBeenCalled(); + cleanup(); + }); + + it("clicking the orchestrator node is a no-op — neither onNodeClick nor onToggleCollapse fires", () => { + const toggle = vi.fn(); + const onNodeClick = vi.fn(); + const snap = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "a2a task" }, + ], + edges: [], + sources: [], + generatedAt: "x", + }; + const { cleanup } = render( + {}} + collapsed={new Set()} + onToggleCollapse={toggle} + /> + ); + const handleClick = flowProps.at(-1)?.onNodeClick as (e: unknown, node: unknown) => void; + handleClick(undefined, { id: "orchestrator", type: "orchestrator", data: {} }); + expect(toggle).not.toHaveBeenCalled(); + expect(onNodeClick).not.toHaveBeenCalled(); + cleanup(); + }); + + it("toggling the showCompleted checkbox calls onToggleCompleted with the new checked value", () => { + const onToggleCompleted = vi.fn(); + const snap = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "a2a task" }, + ], + edges: [], + sources: [], + generatedAt: "x", + }; + const { c, cleanup } = render( + {}} + showCompleted={false} + onToggleCompleted={onToggleCompleted} + /> + ); + const checkbox = c.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox).toBeTruthy(); + expect(checkbox.checked).toBe(false); + act(() => { + // React listens to the native "click" event for checkboxes to trigger the + // synthetic onChange (same pattern as engineConfigForm.test.tsx). + checkbox.click(); + }); + expect(onToggleCompleted).toHaveBeenCalledWith(true); + cleanup(); + }); }); describe("OverviewTab", () => { @@ -138,6 +261,35 @@ describe("OverviewTab", () => { expect(clicked).toBe("cloud-agent:1"); cleanup(); }); + it("formatElapsed guards against an unparseable startedAt and renders an em dash", () => { + const snapBadStart = { + ...snap, + nodes: [ + ...snap.nodes, + { + id: "a2a:bad", + kind: "work", + source: "a2a", + state: "running", + label: "task bad start", + startedAt: "not-a-date", + }, + ], + }; + const { c, cleanup } = render( + {}} + onSeeInGraph={() => {}} + /> + ); + const card = Array.from(c.querySelectorAll("[data-orch-card]")).find((el) => + el.textContent?.includes("task bad start") + ); + expect(card?.textContent).toContain("—"); + cleanup(); + }); }); describe("RoutingTab", () => { diff --git a/tests/unit/ui/orchestrationToFlow.test.ts b/tests/unit/ui/orchestrationToFlow.test.ts index e3796adcb5f..e4879301e78 100644 --- a/tests/unit/ui/orchestrationToFlow.test.ts +++ b/tests/unit/ui/orchestrationToFlow.test.ts @@ -20,6 +20,38 @@ const snap: OrchSnapshot = { generatedAt: "2026-08-30T12:00:00Z", }; +const multiSourceSnap: OrchSnapshot = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { id: "source:a2a", kind: "source", source: "a2a", label: "A2A" }, + { id: "a2a:t1", kind: "work", source: "a2a", state: "running", label: "smart-routing" }, + { + id: "a2a:t1:activity", + kind: "activity", + source: "a2a", + state: "running", + label: "thinking", + }, + { id: "source:cloud-agent", kind: "source", source: "cloud-agent", label: "Cloud Agent" }, + { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "queued", + label: "build", + }, + ], + edges: [ + { id: "e1", from: "orchestrator", to: "source:a2a", kind: "owns", active: false }, + { id: "e2", from: "source:a2a", to: "a2a:t1", kind: "owns", active: true }, + { id: "e3", from: "a2a:t1", to: "a2a:t1:activity", kind: "owns", active: true }, + { id: "e4", from: "orchestrator", to: "source:cloud-agent", kind: "owns", active: false }, + { id: "e5", from: "source:cloud-agent", to: "cloud-agent:t1", kind: "owns", active: false }, + ], + sources: [], + generatedAt: "2026-08-30T12:00:00Z", +}; + describe("orchestrationToFlow", () => { it("puts each kind on its own Y layer and is deterministic", () => { const a = orchestrationToFlow(snap); @@ -33,11 +65,32 @@ describe("orchestrationToFlow", () => { assert.equal(ys.get("source:a2a"), 150); assert.equal(ys.get("a2a:t1"), 320); }); - it("active edge is animated; edge to failed work is red", () => { + it('edges carry type "status" and data.{state,active,mirror}; no animated/style leak', () => { const { edges } = orchestrationToFlow(snap); - assert.equal(edges.find((e) => e.id === "e2")?.animated, true); - const failedEdge = edges.find((e) => e.id === "e3"); - assert.equal((failedEdge?.style as { stroke?: string })?.stroke, "#ef4444"); + const activeEdge = edges.find((e) => e.id === "e2"); + assert.equal(activeEdge?.type, "status"); + assert.deepEqual(activeEdge?.data, { state: "running", active: true, mirror: false }); + 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 }); + }); + it("mirror edges carry data.mirror === true", () => { + const mirrorSnap: OrchSnapshot = { + ...snap, + edges: [ + ...snap.edges, + { id: "e4", from: "a2a:t1", to: "source:a2a", kind: "mirror", active: false }, + ], + }; + const { edges } = orchestrationToFlow(mirrorSnap); + const mirrorEdge = edges.find((e) => e.id === "e4"); + assert.equal(mirrorEdge?.type, "status"); + assert.equal((mirrorEdge?.data as { mirror?: boolean })?.mirror, true); + const ownsEdge = edges.find((e) => e.id === "e2"); + assert.equal((ownsEdge?.data as { mirror?: boolean })?.mirror, false); }); it("fitKey only tracks the set of work ids", () => { const k1 = orchestrationToFlow(snap).fitKey; @@ -53,4 +106,43 @@ describe("orchestrationToFlow", () => { }; assert.notEqual(orchestrationToFlow(nodeRemoved).fitKey, k1); }); + + it("opts omitted preserves current behavior (all nodes/edges kept, no collapsed data)", () => { + const { nodes, edges, fitKey } = orchestrationToFlow(multiSourceSnap); + assert.equal(nodes.length, multiSourceSnap.nodes.length); + assert.equal(edges.length, multiSourceSnap.edges.length); + assert.ok(!fitKey.includes("::collapsed=")); + const sourceA2a = nodes.find((n) => n.id === "source:a2a"); + assert.equal((sourceA2a?.data as { collapsed?: boolean }).collapsed, undefined); + }); + + it("collapsing a source removes its work/activity nodes and their edges, keeps other sources", () => { + const { nodes, edges } = orchestrationToFlow(multiSourceSnap, { + collapsed: new Set(["a2a"]), + }); + const ids = nodes.map((n) => n.id).sort(); + assert.deepEqual(ids, ["cloud-agent:t1", "orchestrator", "source:a2a", "source:cloud-agent"]); + const edgeIds = edges.map((e) => e.id).sort(); + assert.deepEqual(edgeIds, ["e1", "e4", "e5"]); + }); + + it("SourceNode for a collapsed source carries data.collapsed === true; others do not", () => { + const { nodes } = orchestrationToFlow(multiSourceSnap, { collapsed: new Set(["a2a"]) }); + const sourceA2a = nodes.find((n) => n.id === "source:a2a"); + const sourceCloudAgent = nodes.find((n) => n.id === "source:cloud-agent"); + assert.equal((sourceA2a?.data as { collapsed?: boolean }).collapsed, true); + assert.equal((sourceCloudAgent?.data as { collapsed?: boolean }).collapsed, undefined); + }); + + it("fitKey changes when the collapsed set changes and is stable otherwise", () => { + const base = orchestrationToFlow(multiSourceSnap).fitKey; + const k1 = orchestrationToFlow(multiSourceSnap, { collapsed: new Set(["a2a"]) }).fitKey; + const k1Again = orchestrationToFlow(multiSourceSnap, { collapsed: new Set(["a2a"]) }).fitKey; + const k2 = orchestrationToFlow(multiSourceSnap, { + collapsed: new Set(["cloud-agent"]), + }).fitKey; + assert.equal(k1, k1Again); + assert.notEqual(k1, base); + assert.notEqual(k1, k2); + }); }); diff --git a/tests/unit/ui/overviewProjection.test.ts b/tests/unit/ui/overviewProjection.test.ts index 56c133cc96a..1a86a44abc7 100644 --- a/tests/unit/ui/overviewProjection.test.ts +++ b/tests/unit/ui/overviewProjection.test.ts @@ -44,6 +44,58 @@ describe("overviewProjection", () => { assert.equal(columns.done[0].id, "a2a:3"); assert.equal(columns.running.length, 1); }); + it("sorts the done column by updatedAt descending, with 3+ terminal items interleaved out of order", () => { + // NOTE: the implementation (model/overviewProjection.ts) sorts + // `columns.done` by `updatedAt` descending, not `endedAt` — verified by reading the + // source before writing this assertion. Interleaved with a non-terminal (`running`) + // node to also confirm it never lands in `done`. + const interleaved: OrchSnapshot = { + nodes: [ + { id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }, + { + id: "a2a:oldest", + kind: "work", + source: "a2a", + state: "failed", + label: "oldest", + updatedAt: "2026-08-30T09:00:00Z", + }, + { + id: "cloud-agent:running", + kind: "work", + source: "cloud-agent", + state: "running", + label: "not terminal", + updatedAt: "2026-08-30T13:00:00Z", + }, + { + id: "a2a:newest", + kind: "work", + source: "a2a", + state: "succeeded", + label: "newest", + updatedAt: "2026-08-30T12:00:00Z", + }, + { + id: "cloud-agent:middle", + kind: "work", + source: "cloud-agent", + state: "cancelled", + label: "middle", + updatedAt: "2026-08-30T10:30:00Z", + }, + ], + edges: [], + sources: [], + generatedAt: "2026-08-30T13:00:00Z", + }; + const { columns } = overviewProjection(interleaved, 0); + assert.deepEqual( + columns.done.map((n) => n.id), + ["a2a:newest", "cloud-agent:middle", "a2a:oldest"] + ); + assert.equal(columns.running.length, 1); + }); it("folds an overflow node's droppedByState into counts but not into columns", () => { const snapWithOverflow: OrchSnapshot = { ...snap, diff --git a/tests/unit/ui/useOrchestrationSnapshot.test.tsx b/tests/unit/ui/useOrchestrationSnapshot.test.tsx index d90e1d8a2a3..5160f8039dc 100644 --- a/tests/unit/ui/useOrchestrationSnapshot.test.tsx +++ b/tests/unit/ui/useOrchestrationSnapshot.test.tsx @@ -118,4 +118,61 @@ describe("useOrchestrationSnapshot", () => { // Two burst events → exactly ONE extra round of 3 fetches (debounce), not two. expect(fetchMock.mock.calls.length).toBe(callsAfterMount + 3); }); + + it("keeps snapshot referential identity across polls with an unchanged payload, and mints a new one when it changes", async () => { + let latest: ReturnType | null = null; + const task = { + id: "t1", + providerId: "devin", + status: "running", + prompt: "p", + source: { repoName: "r", repoUrl: "https://x" }, + options: {}, + activities: [], + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-30T10:00:00Z", + }; + const fetchMock = vi.fn((url: string) => { + if (url.startsWith("/api/v1/agents/tasks")) return okJson({ data: [task] }); + if (url.startsWith("/api/a2a/tasks")) + return okJson({ tasks: [], total: 0, limit: 200, offset: 0 }); + return okJson({ offline: false, runners: [], tasks: [] }); + }); + vi.stubGlobal("fetch", fetchMock); + + await act(async () => { + root.render( + { + latest = v; + }} + /> + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + const firstSnapshot = latest!.snapshot; + expect(firstSnapshot.nodes.some((n) => n.id === "cloud-agent:t1")).toBe(true); + + // Same payload next poll tick → `polledAt` advances but content doesn't, + // so the hook must keep returning the SAME snapshot object. + await act(async () => { + await vi.advanceTimersByTimeAsync(5_100); + }); + expect(latest!.snapshot).toBe(firstSnapshot); + + // Payload actually changes → a new snapshot identity is expected. + fetchMock.mockImplementation((url: string) => { + if (url.startsWith("/api/v1/agents/tasks")) + return okJson({ data: [{ ...task, status: "completed" }] }); + if (url.startsWith("/api/a2a/tasks")) + return okJson({ tasks: [], total: 0, limit: 200, offset: 0 }); + return okJson({ offline: false, runners: [], tasks: [] }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(5_100); + }); + expect(latest!.snapshot).not.toBe(firstSnapshot); + }); });