mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
feat(agents): add runtime machine filter to Agents tab (MUL-2846)
Add a dropdown filter to the Agents tab toolbar that lets the user narrow the list to agents bound to a specific runtime machine. The filter reuses `buildRuntimeMachines` from the runtimes package so the machine grouping (Local / Remote / Cloud) matches the Runtimes page sidebar, and the per-machine agent counts respect the current scope (Mine/All) so the numbers reflect what the user would see if they clicked the row. Only rendered in the Active view; the Archived view's toolbar is unchanged. If the selected machine is GC'd while the user is on the page (daemon stopped, runtime deleted), the filter auto-resets to 'All runtimes' instead of leaving the list empty. The no-matches state now surfaces 'No agents on <machine>' when the machine filter is the reason for zero results. Adds new `runtime_filter` and `no_matches.runtime_filtered` / `no_matches.search_runtime_filtered` i18n keys in en, zh-Hans, and ko. 7 new unit tests in `runtime-machine-filter-dropdown.test.tsx`. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -48,18 +48,28 @@ import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
import { type AgentRow, createAgentColumns } from "./agent-columns";
|
||||
import { useT } from "../../i18n";
|
||||
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
||||
import {
|
||||
buildRuntimeMachines,
|
||||
type RuntimeMachine,
|
||||
} from "../../runtimes/components/runtime-machines";
|
||||
import { RuntimeMachineFilterDropdown } from "./runtime-machine-filter-dropdown";
|
||||
|
||||
// Filter axes:
|
||||
//
|
||||
// View = active vs archived dataset. Archived is low-frequency,
|
||||
// accessed through a ghost link in the toolbar.
|
||||
// Scope = ownership lens (All vs Mine). Layer-1 segment.
|
||||
// Availability = "Can the agent take work right now?" — 3-state chip
|
||||
// group (online / unstable / offline) sourced from
|
||||
// AgentAvailability. The only chip filter we keep —
|
||||
// the previous Workload axis was dropped because its
|
||||
// "queued / failed / cancelled" buckets became
|
||||
// meaningless once Failed left the workload model.
|
||||
// View = active vs archived dataset. Archived is low-frequency,
|
||||
// accessed through a ghost link in the toolbar.
|
||||
// Scope = ownership lens (All vs Mine). Layer-1 segment.
|
||||
// Runtime machine = "Which host is the agent bound to?" — dropdown
|
||||
// filter grouped by section (Local / Remote / Cloud).
|
||||
// Mirrors the machine grouping on the Runtimes page
|
||||
// so a user can drill from a machine into the agents
|
||||
// hosted on it.
|
||||
// Availability = "Can the agent take work right now?" — 3-state chip
|
||||
// group (online / unstable / offline) sourced from
|
||||
// AgentAvailability. The only chip filter we keep —
|
||||
// the previous Workload axis was dropped because its
|
||||
// "queued / failed / cancelled" buckets became
|
||||
// meaningless once Failed left the workload model.
|
||||
type View = "active" | "archived";
|
||||
type Scope = "all" | "mine";
|
||||
type AvailabilityFilter = "all" | AgentAvailability;
|
||||
@@ -107,6 +117,11 @@ export function AgentsPage() {
|
||||
const setScope = useAgentsViewStore((s) => s.setScope);
|
||||
const [availabilityFilter, setAvailabilityFilter] =
|
||||
useState<AvailabilityFilter>("all");
|
||||
// `null` means "all runtimes" (the default). When set, the value is a
|
||||
// RuntimeMachine id from `buildRuntimeMachines` (the same grouping the
|
||||
// Runtimes page uses), so the user can drill from a machine on that
|
||||
// page into the agents bound to it.
|
||||
const [runtimeMachineId, setRuntimeMachineId] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>("recent");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
@@ -185,7 +200,71 @@ export function AgentsPage() {
|
||||
return visibleInView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [visibleInView, scope, currentUser, view]);
|
||||
|
||||
// Final cut — availability chip + search.
|
||||
// Build the workspace's runtime machines (local / remote / cloud
|
||||
// groupings) the same way the Runtimes page does, so the filter
|
||||
// dropdown labels match the machines the user sees there. The
|
||||
// `now` clock only affects health rollups — we don't render health
|
||||
// chips in this list, so a stale snapshot is fine; a single derive
|
||||
// per render is cheap and avoids pulling in a 30s tick on a page
|
||||
// that doesn't show health.
|
||||
const machines = useMemo(
|
||||
() => buildRuntimeMachines(runtimes, { now: Date.now() }),
|
||||
[runtimes],
|
||||
);
|
||||
|
||||
// Reverse map: runtime_id → machine id. Lets the filter step look up
|
||||
// an agent's machine in O(1). Built off the machine grouping rather
|
||||
// than `runtimesById` so a runtime's machine identity matches the
|
||||
// dropdown labels (machines dedupe across providers by daemon).
|
||||
const runtimeIdToMachineId = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const machine of machines) {
|
||||
for (const r of machine.runtimes) m.set(r.id, machine.id);
|
||||
}
|
||||
return m;
|
||||
}, [machines]);
|
||||
|
||||
// Per-machine agent counts in `inScope` — used both for the chip
|
||||
// badges in the dropdown AND to make the runtime filter respect the
|
||||
// current scope (e.g. "Mine" only shows machines that have one of
|
||||
// my agents). Computed against `inScope` (not `visibleInView`) so the
|
||||
// number next to "All" is exactly `inScope.length`.
|
||||
const agentCountByMachine = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of inScope) {
|
||||
const machineId = runtimeIdToMachineId.get(a.runtime_id);
|
||||
if (!machineId) continue;
|
||||
counts.set(machineId, (counts.get(machineId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [inScope, runtimeIdToMachineId]);
|
||||
|
||||
// If the selected machine is GC'd while we're on the page (daemon
|
||||
// stopped, runtime deleted), the filter would zero out the list with
|
||||
// no UI to clear it. Bounce back to "all" so the user always sees
|
||||
// something actionable.
|
||||
useEffect(() => {
|
||||
if (
|
||||
runtimeMachineId !== null &&
|
||||
!machines.some((machine) => machine.id === runtimeMachineId)
|
||||
) {
|
||||
setRuntimeMachineId(null);
|
||||
}
|
||||
}, [runtimeMachineId, machines]);
|
||||
|
||||
// Resolved title for the current machine filter — used by the
|
||||
// no-matches state so the user sees "No agents on `dev.local`" rather
|
||||
// than a bare "No agents match this filter" when the search is empty
|
||||
// but the machine filter is doing the narrowing.
|
||||
const selectedMachine = useMemo(
|
||||
() =>
|
||||
runtimeMachineId === null
|
||||
? null
|
||||
: machines.find((machine) => machine.id === runtimeMachineId) ?? null,
|
||||
[runtimeMachineId, machines],
|
||||
);
|
||||
|
||||
// Final cut — availability chip + runtime machine + search.
|
||||
const filteredAgents = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return inScope.filter((a) => {
|
||||
@@ -195,6 +274,16 @@ export function AgentsPage() {
|
||||
const detail = presenceMap.get(a.id);
|
||||
if (detail?.availability !== availabilityFilter) return false;
|
||||
}
|
||||
// Runtime machine filter only applies to the Active view — the
|
||||
// archived toolbar has no machine dropdown, so an archived agent
|
||||
// would never get selected but might still slip through scope.
|
||||
if (
|
||||
view === "active" &&
|
||||
runtimeMachineId !== null &&
|
||||
runtimeIdToMachineId.get(a.runtime_id) !== runtimeMachineId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (q) {
|
||||
if (
|
||||
!a.name.toLowerCase().includes(q) &&
|
||||
@@ -206,7 +295,15 @@ export function AgentsPage() {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [inScope, view, availabilityFilter, presenceMap, search]);
|
||||
}, [
|
||||
inScope,
|
||||
view,
|
||||
availabilityFilter,
|
||||
presenceMap,
|
||||
runtimeMachineId,
|
||||
runtimeIdToMachineId,
|
||||
search,
|
||||
]);
|
||||
|
||||
// Per-availability counts for the chip badges. Computed against
|
||||
// `inScope` (ignoring the availability filter itself) so the numbers
|
||||
@@ -421,6 +518,10 @@ export function AgentsPage() {
|
||||
totalCount={inScope.length}
|
||||
archivedCount={archivedCount}
|
||||
onShowArchived={() => setView("archived")}
|
||||
machines={machines}
|
||||
runtimeMachineId={runtimeMachineId}
|
||||
onRuntimeMachineChange={setRuntimeMachineId}
|
||||
agentCountByMachine={agentCountByMachine}
|
||||
/>
|
||||
<AvailabilityFilterRow
|
||||
value={availabilityFilter}
|
||||
@@ -439,7 +540,12 @@ export function AgentsPage() {
|
||||
)}
|
||||
|
||||
{sortedAgents.length === 0 ? (
|
||||
<NoMatches view={view} search={search} scope={scope} />
|
||||
<NoMatches
|
||||
view={view}
|
||||
search={search}
|
||||
scope={scope}
|
||||
runtimeMachineTitle={selectedMachine?.title ?? null}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
table={table}
|
||||
@@ -565,6 +671,10 @@ function ActiveToolbarRow({
|
||||
totalCount,
|
||||
archivedCount,
|
||||
onShowArchived,
|
||||
machines,
|
||||
runtimeMachineId,
|
||||
onRuntimeMachineChange,
|
||||
agentCountByMachine,
|
||||
}: {
|
||||
scope: Scope;
|
||||
setScope: (v: Scope) => void;
|
||||
@@ -577,6 +687,10 @@ function ActiveToolbarRow({
|
||||
totalCount: number;
|
||||
archivedCount: number;
|
||||
onShowArchived: () => void;
|
||||
machines: RuntimeMachine[];
|
||||
runtimeMachineId: string | null;
|
||||
onRuntimeMachineChange: (id: string | null) => void;
|
||||
agentCountByMachine: Map<string, number>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
@@ -592,6 +706,12 @@ function ActiveToolbarRow({
|
||||
</div>
|
||||
<ScopeSegment scope={scope} setScope={setScope} counts={scopeCounts} />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<RuntimeMachineFilterDropdown
|
||||
machines={machines}
|
||||
value={runtimeMachineId}
|
||||
onChange={onRuntimeMachineChange}
|
||||
agentCountByMachine={agentCountByMachine}
|
||||
/>
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -849,24 +969,36 @@ function NoMatches({
|
||||
view,
|
||||
search,
|
||||
scope,
|
||||
runtimeMachineTitle,
|
||||
}: {
|
||||
view: View;
|
||||
search: string;
|
||||
scope: Scope;
|
||||
runtimeMachineTitle: string | null;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const hasSearch = search.length > 0;
|
||||
const hasFilter = scope === "mine";
|
||||
const hasRuntimeFilter = runtimeMachineTitle !== null;
|
||||
|
||||
let body: string;
|
||||
if (view === "archived") {
|
||||
body = hasSearch
|
||||
? t(($) => $.no_matches.search_archived, { query: search })
|
||||
: t(($) => $.no_matches.no_archived);
|
||||
} else if (hasSearch && hasRuntimeFilter) {
|
||||
body = t(($) => $.no_matches.search_runtime_filtered, {
|
||||
query: search,
|
||||
machine: runtimeMachineTitle,
|
||||
});
|
||||
} else if (hasSearch) {
|
||||
body = hasFilter
|
||||
? t(($) => $.no_matches.search_active_filtered, { query: search })
|
||||
: t(($) => $.no_matches.search_active, { query: search });
|
||||
} else if (hasRuntimeFilter) {
|
||||
body = t(($) => $.no_matches.runtime_filtered, {
|
||||
machine: runtimeMachineTitle,
|
||||
});
|
||||
} else {
|
||||
body = t(($) => $.no_matches.no_filter_match);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
import type { RuntimeMachine } from "../../runtimes/components/runtime-machines";
|
||||
import { RuntimeMachineFilterDropdown } from "./runtime-machine-filter-dropdown";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
function makeMachine(
|
||||
overrides: Partial<RuntimeMachine> = {},
|
||||
): RuntimeMachine {
|
||||
return {
|
||||
id: "machine-1",
|
||||
daemonId: "daemon-1",
|
||||
title: "dev.local",
|
||||
subtitle: "x86_64 macOS",
|
||||
deviceInfo: "dev.local · x86_64 macOS",
|
||||
cliVersion: "1.0.0",
|
||||
mode: "local",
|
||||
section: "local",
|
||||
isCurrent: true,
|
||||
health: "online",
|
||||
runtimes: [],
|
||||
onlineCount: 1,
|
||||
issueCount: 0,
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
providerNames: ["claude"],
|
||||
lastSeenAt: "2026-05-17T11:59:50Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDropdown(
|
||||
machines: RuntimeMachine[],
|
||||
value: string | null,
|
||||
onChange: (id: string | null) => void,
|
||||
agentCountByMachine: Map<string, number>,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RuntimeMachineFilterDropdown
|
||||
machines={machines}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
agentCountByMachine={agentCountByMachine}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RuntimeMachineFilterDropdown", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
// Base UI DropdownMenu renders the menu content into a portal on
|
||||
// document.body, so leftover portals from a prior test would surface
|
||||
// duplicate "All runtimes" / "LOCAL" labels. Wipe body between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("shows the All-runtimes label and total scope count when nothing is selected", () => {
|
||||
const machines = [
|
||||
makeMachine({ id: "m-local", title: "dev.local" }),
|
||||
makeMachine({
|
||||
id: "m-remote",
|
||||
title: "build-server",
|
||||
section: "remote",
|
||||
isCurrent: false,
|
||||
}),
|
||||
];
|
||||
const counts = new Map([
|
||||
["m-local", 2],
|
||||
["m-remote", 5],
|
||||
]);
|
||||
|
||||
renderDropdown(machines, null, vi.fn(), counts);
|
||||
|
||||
// Trigger button uses the "All runtimes" label.
|
||||
const trigger = screen.getByTestId("agents-runtime-filter");
|
||||
expect(trigger.textContent).toContain("All runtimes");
|
||||
// Sum across machines surfaces as the trigger count.
|
||||
expect(trigger.textContent).toContain("7");
|
||||
});
|
||||
|
||||
it("shows the selected machine's title and per-machine count in the trigger", () => {
|
||||
const machines = [makeMachine({ id: "m-local", title: "dev.local" })];
|
||||
const counts = new Map([["m-local", 4]]);
|
||||
|
||||
renderDropdown(machines, "m-local", vi.fn(), counts);
|
||||
|
||||
const trigger = screen.getByTestId("agents-runtime-filter");
|
||||
expect(trigger.textContent).toContain("dev.local");
|
||||
expect(trigger.textContent).toContain("4");
|
||||
});
|
||||
|
||||
it("groups machines under their section headers in the menu", () => {
|
||||
const machines = [
|
||||
makeMachine({ id: "m-local", title: "dev.local", section: "local" }),
|
||||
makeMachine({
|
||||
id: "m-remote",
|
||||
title: "build-server",
|
||||
section: "remote",
|
||||
isCurrent: false,
|
||||
}),
|
||||
makeMachine({
|
||||
id: "m-cloud",
|
||||
title: "Multica cloud",
|
||||
section: "cloud",
|
||||
isCurrent: false,
|
||||
mode: "cloud",
|
||||
}),
|
||||
];
|
||||
const counts = new Map([
|
||||
["m-local", 1],
|
||||
["m-remote", 2],
|
||||
["m-cloud", 3],
|
||||
]);
|
||||
|
||||
renderDropdown(machines, null, vi.fn(), counts);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agents-runtime-filter"));
|
||||
|
||||
// Section labels render as plain text (uppercase is CSS-only).
|
||||
expect(screen.getByText("Local")).toBeTruthy();
|
||||
expect(screen.getByText("Remote")).toBeTruthy();
|
||||
expect(screen.getByText("Cloud")).toBeTruthy();
|
||||
// The menu items themselves also render.
|
||||
expect(screen.getByText("dev.local")).toBeTruthy();
|
||||
expect(screen.getByText("build-server")).toBeTruthy();
|
||||
expect(screen.getByText("Multica cloud")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires onChange(null) when the All-runtimes row is clicked", () => {
|
||||
const machines = [makeMachine({ id: "m-local", title: "dev.local" })];
|
||||
const counts = new Map([["m-local", 1]]);
|
||||
const onChange = vi.fn();
|
||||
|
||||
// Pre-select a machine so the "All runtimes" row is the one that
|
||||
// gets the data-testid="agents-runtime-filter-active" marker.
|
||||
renderDropdown(machines, "m-local", onChange, counts);
|
||||
fireEvent.click(screen.getByTestId("agents-runtime-filter"));
|
||||
const allRow = screen
|
||||
.getByTestId("agents-runtime-filter-active")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(allRow).not.toBeNull();
|
||||
// The "All runtimes" row sits at the top of the menu; fire a click
|
||||
// on the explicit "All runtimes" text instead to make the assertion
|
||||
// unambiguous.
|
||||
const allRuntimesItem = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find(
|
||||
(button) =>
|
||||
button.textContent?.includes("All runtimes") &&
|
||||
!button.hasAttribute("data-testid"),
|
||||
);
|
||||
expect(allRuntimesItem).toBeDefined();
|
||||
fireEvent.click(allRuntimesItem as HTMLButtonElement);
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("fires onChange(machineId) when a specific machine row is clicked", () => {
|
||||
const machines = [
|
||||
makeMachine({ id: "m-local", title: "dev.local", section: "local" }),
|
||||
makeMachine({
|
||||
id: "m-remote",
|
||||
title: "build-server",
|
||||
section: "remote",
|
||||
isCurrent: false,
|
||||
}),
|
||||
];
|
||||
const counts = new Map([
|
||||
["m-local", 1],
|
||||
["m-remote", 2],
|
||||
]);
|
||||
const onChange = vi.fn();
|
||||
|
||||
renderDropdown(machines, null, onChange, counts);
|
||||
fireEvent.click(screen.getByTestId("agents-runtime-filter"));
|
||||
fireEvent.click(screen.getByText("build-server"));
|
||||
expect(onChange).toHaveBeenCalledWith("m-remote");
|
||||
});
|
||||
|
||||
it("shows the per-machine count next to each item", () => {
|
||||
const machines = [makeMachine({ id: "m-local", title: "dev.local" })];
|
||||
const counts = new Map([["m-local", 7]]);
|
||||
|
||||
renderDropdown(machines, null, vi.fn(), counts);
|
||||
fireEvent.click(screen.getByTestId("agents-runtime-filter"));
|
||||
|
||||
// The menu item renders the count via the i18n plural key.
|
||||
const item = screen.getByText("dev.local").closest("button") as HTMLButtonElement;
|
||||
expect(item.textContent).toMatch(/7/);
|
||||
});
|
||||
|
||||
it("renders an empty-state hint when no machines exist", () => {
|
||||
renderDropdown([], null, vi.fn(), new Map());
|
||||
|
||||
fireEvent.click(screen.getByTestId("agents-runtime-filter"));
|
||||
|
||||
expect(screen.getByText("No machines yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMemo } from "react";
|
||||
import { ChevronDown, Server } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import type { RuntimeMachine, RuntimeMachineSection } from "../../runtimes/components/runtime-machines";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime machine filter — dropdown next to the search input. The trigger
|
||||
// shows the active machine's title (or "All runtimes"); the menu groups
|
||||
// machines by section (Local / Remote / Cloud) the same way the
|
||||
// Runtimes page sidebar does, so a user moving between the two pages
|
||||
// sees consistent labels and counts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RUNTIME_MACHINE_SECTIONS: RuntimeMachineSection[] = [
|
||||
"local",
|
||||
"remote",
|
||||
"cloud",
|
||||
];
|
||||
|
||||
export function RuntimeMachineFilterDropdown({
|
||||
machines,
|
||||
value,
|
||||
onChange,
|
||||
agentCountByMachine,
|
||||
}: {
|
||||
machines: RuntimeMachine[];
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
agentCountByMachine: Map<string, number>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const selected =
|
||||
value === null
|
||||
? null
|
||||
: machines.find((machine) => machine.id === value) ?? null;
|
||||
// Total count of agents in the current scope, regardless of which
|
||||
// machine they're on. Used as the trailing count on the "All runtimes"
|
||||
// entry so the user can see what they'd see if they cleared the filter.
|
||||
const totalAgentCount = useMemo(() => {
|
||||
let total = 0;
|
||||
for (const count of agentCountByMachine.values()) total += count;
|
||||
return total;
|
||||
}, [agentCountByMachine]);
|
||||
|
||||
const triggerLabel = selected ? selected.title : t(($) => $.runtime_filter.all);
|
||||
// Always show a count, even when the trigger is "All runtimes" — keeps
|
||||
// the affordance scannable next to the other toolbar controls.
|
||||
const triggerCount = selected
|
||||
? (agentCountByMachine.get(selected.id) ?? 0)
|
||||
: totalAgentCount;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2 text-xs"
|
||||
data-testid="agents-runtime-filter"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Server className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="max-w-[12rem] truncate">{triggerLabel}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground/70">
|
||||
{triggerCount}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground/60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72 p-0">
|
||||
<RuntimeMachineFilterMenu
|
||||
machines={machines}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
totalAgentCount={totalAgentCount}
|
||||
agentCountByMachine={agentCountByMachine}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeMachineFilterMenu({
|
||||
machines,
|
||||
value,
|
||||
onChange,
|
||||
totalAgentCount,
|
||||
agentCountByMachine,
|
||||
}: {
|
||||
machines: RuntimeMachine[];
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
totalAgentCount: number;
|
||||
agentCountByMachine: Map<string, number>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
// Group machines by section while preserving the order
|
||||
// `buildRuntimeMachines` already sorts them by (section, online count,
|
||||
// title). We iterate the section list and slice to keep that order.
|
||||
const grouped = useMemo(() => {
|
||||
const result: Array<{
|
||||
section: RuntimeMachineSection;
|
||||
machines: RuntimeMachine[];
|
||||
}> = [];
|
||||
for (const section of RUNTIME_MACHINE_SECTIONS) {
|
||||
const inSection = machines.filter((machine) => machine.section === section);
|
||||
if (inSection.length > 0) result.push({ section, machines: inSection });
|
||||
}
|
||||
return result;
|
||||
}, [machines]);
|
||||
|
||||
return (
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
<RuntimeMachineFilterItem
|
||||
active={value === null}
|
||||
onClick={() => onChange(null)}
|
||||
label={t(($) => $.runtime_filter.all)}
|
||||
count={totalAgentCount}
|
||||
/>
|
||||
{grouped.map((group) => (
|
||||
<div key={group.section}>
|
||||
<div className="flex items-center gap-2 px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span>{t(($) => $.runtime_filter[`section_${group.section}`])}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{group.machines.map((machine) => (
|
||||
<RuntimeMachineFilterItem
|
||||
key={machine.id}
|
||||
active={value === machine.id}
|
||||
onClick={() => onChange(machine.id)}
|
||||
label={machine.title}
|
||||
subtitle={machine.subtitle}
|
||||
count={agentCountByMachine.get(machine.id) ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{machines.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t(($) => $.runtime_filter.empty)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeMachineFilterItem({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
subtitle,
|
||||
count,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
subtitle?: string | null;
|
||||
count: number;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-active={active || undefined}
|
||||
data-testid={active ? "agents-runtime-filter-active" : undefined}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="block truncate font-medium">{label}</span>
|
||||
{subtitle && (
|
||||
<span className="block truncate text-[11px] font-normal text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground/70">
|
||||
{t(($) => $.runtime_filter.agent_count, { count })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,21 @@
|
||||
"no_archived": "No archived agents yet.",
|
||||
"search_active": "No agents match \"{{query}}\".",
|
||||
"search_active_filtered": "No agents match \"{{query}}\" in this filter.",
|
||||
"no_filter_match": "No agents match this filter."
|
||||
"no_filter_match": "No agents match this filter.",
|
||||
"search_runtime_filtered": "No agents on \"{{machine}}\" match \"{{query}}\".",
|
||||
"runtime_filtered": "No agents on \"{{machine}}\"."
|
||||
},
|
||||
"runtime_filter": {
|
||||
"all": "All runtimes",
|
||||
"all_description": "Agents on any machine",
|
||||
"section_local": "Local",
|
||||
"section_remote": "Remote",
|
||||
"section_cloud": "Cloud",
|
||||
"agent_count_one": "{{count}} agent",
|
||||
"agent_count_other": "{{count}} agents",
|
||||
"this_machine": "This machine",
|
||||
"empty": "No machines yet",
|
||||
"reset": "All runtimes"
|
||||
},
|
||||
"columns": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -46,7 +46,21 @@
|
||||
"no_archived": "아직 보관된 에이전트가 없습니다.",
|
||||
"search_active": "\"{{query}}\"와 일치하는 에이전트가 없습니다.",
|
||||
"search_active_filtered": "이 필터에서 \"{{query}}\"와 일치하는 에이전트가 없습니다.",
|
||||
"no_filter_match": "이 필터와 일치하는 에이전트가 없습니다."
|
||||
"no_filter_match": "이 필터와 일치하는 에이전트가 없습니다.",
|
||||
"search_runtime_filtered": "\"{{machine}}\"에서 \"{{query}}\"와 일치하는 에이전트가 없습니다.",
|
||||
"runtime_filtered": "\"{{machine}}\"에 에이전트가 없습니다."
|
||||
},
|
||||
"runtime_filter": {
|
||||
"all": "모든 런타임",
|
||||
"all_description": "모든 기기의 에이전트",
|
||||
"section_local": "로컬",
|
||||
"section_remote": "원격",
|
||||
"section_cloud": "클라우드",
|
||||
"agent_count_one": "에이전트 {{count}}개",
|
||||
"agent_count_other": "에이전트 {{count}}개",
|
||||
"this_machine": "이 기기",
|
||||
"empty": "아직 기기가 없습니다",
|
||||
"reset": "모든 런타임"
|
||||
},
|
||||
"columns": {
|
||||
"agent": "에이전트",
|
||||
|
||||
@@ -46,7 +46,20 @@
|
||||
"no_archived": "还没有已归档智能体。",
|
||||
"search_active": "没有智能体匹配\"{{query}}\"。",
|
||||
"search_active_filtered": "在该筛选下没有智能体匹配\"{{query}}\"。",
|
||||
"no_filter_match": "该筛选下没有匹配的智能体。"
|
||||
"no_filter_match": "该筛选下没有匹配的智能体。",
|
||||
"search_runtime_filtered": "\"{{machine}}\"上没有匹配\"{{query}}\"的智能体。",
|
||||
"runtime_filtered": "\"{{machine}}\"上还没有智能体。"
|
||||
},
|
||||
"runtime_filter": {
|
||||
"all": "全部运行时",
|
||||
"all_description": "所有机器上的智能体",
|
||||
"section_local": "本机",
|
||||
"section_remote": "远程",
|
||||
"section_cloud": "云端",
|
||||
"agent_count_other": "{{count}} 个智能体",
|
||||
"this_machine": "本机",
|
||||
"empty": "还没有机器",
|
||||
"reset": "全部运行时"
|
||||
},
|
||||
"columns": {
|
||||
"agent": "智能体",
|
||||
|
||||
Reference in New Issue
Block a user