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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/features/orchestration-canvas-fase2-quickwins.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 10 additions & 2 deletions src/app/(dashboard)/dashboard/combos/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1373,6 +1373,14 @@ export default function CombosPage() {
);
}

export default function CombosPage() {
return (
<Suspense fallback={null}>
<CombosPageContent />
</Suspense>
);
}

const COMBO_WIZARD_STEPS = [
{
step: 1,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,20 +9,61 @@ 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<OrchState> = new Set(ORCH_STATES);
const VALID_SOURCES: ReadonlySet<OrchSource> = new Set(["cloud-agent", "a2a", "conductor"]);

/** CSV → Set, dropping empty/invalid entries (`valid` omitted accepts any non-empty token). */
function parseCsvSet<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
const out = new Set<T>();
if (!raw) return out;
for (const v of raw.split(",")) {
if (!v) continue;
if (!valid || valid.has(v as T)) out.add(v as T);
}
return out;
}

/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
const next = new Set(current);
if (next.has(value)) next.delete(value);
else next.add(value);
return next.size > 0 ? [...next].sort().join(",") : null;
}

const TAB_KEY: Record<Tab, string> = {
agents: "tabAgents",
routing: "tabRouting",
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();

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<string, string | null>) => {
Expand All @@ -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<string>(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<typeof useTranslations>;
onSelect: (tab: Tab) => void;
}) {
return (
<div role="tablist" className="flex gap-1 border-b border-border">
{TABS.map((tb) => (
<button
key={tb}
role="tab"
aria-selected={tab === tb}
className={`px-3 py-1.5 text-sm rounded-t ${tab === tb ? "border border-b-0 border-border bg-surface font-medium" : "text-muted"}`}
onClick={() => onSelect(tb)}
>
{t(TAB_KEY[tb])}
</button>
))}
</div>
);
}

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<Tab, string> = {
agents: "tabAgents",
routing: "tabRouting",
overview: "tabOverview",
};

return (
<div className="flex flex-col h-[calc(100dvh-6rem)] min-h-[480px] p-4 gap-3">
<div role="tablist" className="flex gap-1 border-b border-border">
{TABS.map((tb) => (
<button
key={tb}
role="tab"
aria-selected={tab === tb}
className={`px-3 py-1.5 text-sm rounded-t ${tab === tb ? "border border-b-0 border-border bg-surface font-medium" : "text-muted"}`}
onClick={() => setParams({ tab: tb })}
>
{t(TAB_KEY[tb])}
</button>
))}
</div>
<div className="flex-1 min-h-0">
{tab === "agents" && (
<AgentsTab
snapshot={snapshot}
onNodeClick={onNodeClick}
showCompleted={showCompleted}
onToggleCompleted={setShowCompleted}
/>
)}
{tab === "routing" && (
<RoutingTab
comboEvents={comboEvents}
combos={[...activeCombos]}
isConnected={isConnected}
providerHealth={providerHealth}
connectionHealth={connectionHealth}
/>
)}
{tab === "overview" && (
<OverviewTab
snapshot={snapshot}
comboEvents={comboEvents}
onCardClick={(id) => setParams({ node: id })}
onSeeInGraph={(id) => setParams({ tab: "agents", node: id })}
/>
<TabList tab={tab} t={t} onSelect={(tb) => setParams({ tab: tb })} />
<div className="flex-1 min-h-0 flex flex-col gap-2">
{(tab === "agents" || tab === "overview") && (
<OrchestrationToolbar filter={filter} providerKeys={providerKeys} setParams={setParams} />
)}
<div className="flex-1 min-h-0">
{tab === "agents" && (
<AgentsTab
snapshot={filtered}
onNodeClick={onNodeClick}
showCompleted={showCompleted}
onToggleCompleted={setShowCompleted}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
/>
)}
{tab === "routing" && (
<RoutingTab
comboEvents={comboEvents}
combos={[...activeCombos]}
isConnected={isConnected}
providerHealth={providerHealth}
connectionHealth={connectionHealth}
/>
)}
{tab === "overview" && (
<OverviewTab
snapshot={filtered}
comboEvents={comboEvents}
onCardClick={(id) => setParams({ node: id })}
onSeeInGraph={(id) => setParams({ tab: "agents", node: id })}
/>
)}
</div>
</div>
<OrchestrationDrawer
node={selectedNode}
onClose={() => setParams({ node: null })}
onActionDone={refetch}
/>
<OrchestrationDrawer node={selectedNode} onClose={closeDrawer} onActionDone={refetch} />
</div>
);
}
Loading