mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
feat(agents): add runtime machine filter to Agents tab (MUL-2846) (#3580)
* 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>
* fix(agents): address code review on runtime machine filter
- Plumb localDaemonId / localMachineName / hasLocalMachine / currentUserId
through AgentsPage → buildRuntimeMachines so the Local section and
device-name consolidation match the Runtimes page on both web and
Desktop. Adds a DesktopAgentsPage wrapper that bridges daemonAPI the
same way DesktopRuntimesPage does.
- Make the 'All runtimes' badge use the in-scope total instead of
summing per-machine counts, so an agent bound to a GC'd runtime
doesn't silently vanish from the count.
- Move Date.now() out of the machines useMemo into a useState lazy
init so the snapshot stays stable per mount.
- Drop unused i18n keys (all_description / this_machine / reset) from
runtime_filter in en / zh-Hans / ko.
- Add a regression test for the All-runtimes badge divergence.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): machine-scoped availability counts + Base UI menu items
Follow-up to the previous code-review round (Emacs review at 1144b6023).
#1 (medium) — Availability counts now respect the selected machine.
Introduce an inScopeOnMachine memo (inScope narrowed by the selected
runtime machine, but NOT by availability chip or search) and use it as
the base for both availabilityCounts and the AvailabilityFilterRow's
totalCount, so the chips reflect 'agents on this machine' once a
machine is selected. filteredAgents is now derived from inScopeOnMachine
so the availability chip and search further refine within the machine
scope. The dropdown's 'All runtimes' badge still uses inScope.length —
it's the count the user would see if they cleared the filter, so it
should stay unfiltered.
#2 (low) — Dropdown rows now use DropdownMenuItem instead of raw <button>.
Replaces the bare <button> in RuntimeMachineFilterItem with the
shared DropdownMenuItem wrapper (Base UI Menu.Item). The rows are now
registered as proper menu items: keyboard navigation (arrow keys, Enter,
Space), typeahead, ARIA role='menuitem' semantics, and auto-close on
selection (closeOnClick: true) all work. Active styling is preserved
via data-active, and a data-highlighted variant on the inactive style
matches Base UI's keyboard-focus appearance.
Tests updated to use role-based queries (getByRole('menuitem')) and
add a regression that verifies the menu is properly registered with
Base UI.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: MiniMax M3 <M3@multica.local>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop wrapper around the shared `AgentsPage`. Bridges the Electron
|
||||
* `daemonAPI` (main-process daemon state) into the page so the runtime
|
||||
* machine filter can render the Local section the same way the Runtimes
|
||||
* page does — without these props the page falls back to grouping
|
||||
* every local-mode runtime under "Remote" with a generic title, which
|
||||
* breaks the "drill from a machine into its agents" promise of the
|
||||
* filter.
|
||||
*
|
||||
* Mirrors `DesktopRuntimesPage`: we cache the last seen daemon
|
||||
* identity so the Local row doesn't get reclassified as Remote when
|
||||
* the daemon is stopped (which would null out `status.daemonId`), and
|
||||
* we fall back to the OS hostname so the section label stays useful
|
||||
* even when the app doesn't manage the running daemon (WSL2 etc.).
|
||||
*/
|
||||
export function DesktopAgentsPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [lastIdentity, setLastIdentity] = useState<{
|
||||
daemonId: string | null;
|
||||
deviceName: string | null;
|
||||
}>({ daemonId: null, deviceName: null });
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (s: DaemonStatus) => {
|
||||
setStatus(s);
|
||||
if (s.daemonId) {
|
||||
setLastIdentity({
|
||||
daemonId: s.daemonId,
|
||||
deviceName: s.deviceName ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.daemonAPI.getStatus().then(apply);
|
||||
window.daemonAPI.getHostName().then((name) => setHostName(name || null));
|
||||
return window.daemonAPI.onStatusChange(apply);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AgentsPage
|
||||
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
|
||||
localMachineName={status.deviceName ?? lastIdentity.deviceName ?? hostName}
|
||||
// Desktop owns a local machine for the lifetime of the app, even
|
||||
// while the daemon is stopped or hasn't registered yet. The shared
|
||||
// page synthesizes a placeholder local row so the filter dropdown
|
||||
// still has a Local option to pick in the empty window.
|
||||
hasLocalMachine
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { DesktopAgentsPage } from "./components/desktop-agents-page";
|
||||
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
@@ -171,7 +171,7 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <SkillDetailPage />,
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "agents", element: <DesktopAgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
export { AgentsPage as default } from "@multica/views/agents";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
|
||||
// Web has no bundled daemon, so the runtime filter always groups
|
||||
// local-mode runtimes under "Remote" (buildRuntimeMachines has no
|
||||
// localDaemonId / localMachineName / ensureLocalMachine context
|
||||
// here) — that's the expected web behavior, not a bug. The Desktop
|
||||
// app wires those props through `DesktopAgentsPage` so the local
|
||||
// section appears in the dropdown the same way it does on the
|
||||
// Runtimes page.
|
||||
export default function AgentsRoute() {
|
||||
return <AgentsPage />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -73,7 +83,36 @@ const SORT_LABEL_KEY: Record<SortKey, "label_recent" | "label_name" | "label_run
|
||||
created: "label_created",
|
||||
};
|
||||
|
||||
export function AgentsPage() {
|
||||
export interface AgentsPageProps {
|
||||
/**
|
||||
* Desktop-only daemon id for the current host. Forwarded into
|
||||
* `buildRuntimeMachines` so the local machine renders under the
|
||||
* "Local" section (rather than "Remote") on the same host that owns
|
||||
* the daemon. Web omits this — the SaaS shell doesn't bundle a
|
||||
* daemon, so the local section never has a real candidate anyway.
|
||||
*/
|
||||
localDaemonId?: string | null;
|
||||
/**
|
||||
* Desktop-only friendly device name for the local daemon. Paired
|
||||
* with `localDaemonId` for the "Local" section title; web omits.
|
||||
*/
|
||||
localMachineName?: string | null;
|
||||
/**
|
||||
* Desktop-only signal that this host always owns a local machine
|
||||
* row, even when no server-side runtime is currently registered
|
||||
* (daemon stopped, not yet started, or runtime GC'd). Mirrors
|
||||
* `RuntimesPage.hasLocalMachine`. The filter dropdown uses the
|
||||
* synthesized placeholder to keep "Local" available for selection
|
||||
* in the empty window.
|
||||
*/
|
||||
hasLocalMachine?: boolean;
|
||||
}
|
||||
|
||||
export function AgentsPage({
|
||||
localDaemonId = null,
|
||||
localMachineName = null,
|
||||
hasLocalMachine = false,
|
||||
}: AgentsPageProps = {}) {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
@@ -107,6 +146,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,10 +229,109 @@ 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 snapshot from mount time is fine. We
|
||||
// also forward `localDaemonId` / `localMachineName` /
|
||||
// `hasLocalMachine` so the Local section (and the synthesized
|
||||
// placeholder on Desktop) appears here the same way it does on the
|
||||
// Runtimes page; `currentUserId` gates device-name consolidation
|
||||
// so a remote member's identically-named host doesn't get claimed
|
||||
// as the viewer's local machine.
|
||||
const [machinesNow] = useState(() => Date.now());
|
||||
const machines = useMemo(
|
||||
() =>
|
||||
buildRuntimeMachines(runtimes, {
|
||||
now: machinesNow,
|
||||
localDaemonId,
|
||||
localMachineName,
|
||||
currentUserId: currentUser?.id ?? null,
|
||||
ensureLocalMachine: hasLocalMachine,
|
||||
}),
|
||||
[runtimes, machinesNow, localDaemonId, localMachineName, currentUser?.id, hasLocalMachine],
|
||||
);
|
||||
|
||||
// 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`).
|
||||
// Agents whose runtime doesn't map to a current machine
|
||||
// (e.g. bound to a GC'd runtime) are intentionally skipped here
|
||||
// — they still appear in the list when the filter is "All
|
||||
// runtimes", just not bucketed under any per-machine chip. The
|
||||
// "All runtimes" badge uses `inScope.length` directly so it stays
|
||||
// consistent with the unfiltered list.
|
||||
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],
|
||||
);
|
||||
|
||||
// Machine-scoped list: `inScope` narrowed by the selected runtime
|
||||
// machine, but NOT by the availability chip or search. The
|
||||
// availability row needs this intermediate step so its chips show
|
||||
// counts for "agents on this machine", not "agents on every machine"
|
||||
// — once a machine is selected, the chips further narrow the
|
||||
// already-machine-scoped list. The `inScope.length` total stays
|
||||
// available for the dropdown's "All runtimes" badge (the count the
|
||||
// user would see if they cleared the machine filter).
|
||||
const inScopeOnMachine = useMemo(() => {
|
||||
if (view !== "active") return inScope;
|
||||
if (runtimeMachineId === null) return inScope;
|
||||
return inScope.filter(
|
||||
(a) => runtimeIdToMachineId.get(a.runtime_id) === runtimeMachineId,
|
||||
);
|
||||
}, [inScope, view, runtimeMachineId, runtimeIdToMachineId]);
|
||||
|
||||
// Final cut — availability chip + search. Starts from
|
||||
// `inScopeOnMachine` so a selected machine filter is already
|
||||
// applied; the availability chip and search refine within it.
|
||||
const filteredAgents = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return inScope.filter((a) => {
|
||||
return inScopeOnMachine.filter((a) => {
|
||||
// Availability chip filter only applies to the Active view —
|
||||
// archived agents have no presence to match against.
|
||||
if (view === "active" && availabilityFilter !== "all") {
|
||||
@@ -206,25 +349,32 @@ export function AgentsPage() {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [inScope, view, availabilityFilter, presenceMap, search]);
|
||||
}, [
|
||||
inScopeOnMachine,
|
||||
view,
|
||||
availabilityFilter,
|
||||
presenceMap,
|
||||
search,
|
||||
]);
|
||||
|
||||
// Per-availability counts for the chip badges. Computed against
|
||||
// `inScope` (ignoring the availability filter itself) so the numbers
|
||||
// reflect "if I clicked this chip, this many agents would match"
|
||||
// rather than collapsing to 0 for the unselected chips.
|
||||
// `inScopeOnMachine` (ignoring the availability filter itself) so
|
||||
// the numbers reflect "if I clicked this chip, this many agents
|
||||
// would match on the currently-selected machine" rather than
|
||||
// collapsing to 0 for the unselected chips.
|
||||
const availabilityCounts = useMemo(() => {
|
||||
const counts: Record<AgentAvailability, number> = {
|
||||
online: 0,
|
||||
unstable: 0,
|
||||
offline: 0,
|
||||
};
|
||||
for (const a of inScope) {
|
||||
for (const a of inScopeOnMachine) {
|
||||
const detail = presenceMap.get(a.id);
|
||||
if (!detail) continue;
|
||||
counts[detail.availability] += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [inScope, presenceMap]);
|
||||
}, [inScopeOnMachine, presenceMap]);
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
const xs = [...filteredAgents];
|
||||
@@ -421,12 +571,16 @@ export function AgentsPage() {
|
||||
totalCount={inScope.length}
|
||||
archivedCount={archivedCount}
|
||||
onShowArchived={() => setView("archived")}
|
||||
machines={machines}
|
||||
runtimeMachineId={runtimeMachineId}
|
||||
onRuntimeMachineChange={setRuntimeMachineId}
|
||||
agentCountByMachine={agentCountByMachine}
|
||||
/>
|
||||
<AvailabilityFilterRow
|
||||
value={availabilityFilter}
|
||||
onChange={setAvailabilityFilter}
|
||||
counts={availabilityCounts}
|
||||
totalCount={inScope.length}
|
||||
totalCount={inScopeOnMachine.length}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -439,7 +593,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 +724,10 @@ function ActiveToolbarRow({
|
||||
totalCount,
|
||||
archivedCount,
|
||||
onShowArchived,
|
||||
machines,
|
||||
runtimeMachineId,
|
||||
onRuntimeMachineChange,
|
||||
agentCountByMachine,
|
||||
}: {
|
||||
scope: Scope;
|
||||
setScope: (v: Scope) => void;
|
||||
@@ -577,6 +740,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 +759,13 @@ 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}
|
||||
totalAgentCount={totalCount}
|
||||
/>
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -849,24 +1023,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,252 @@
|
||||
// @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>,
|
||||
totalAgentCount?: 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}
|
||||
// Default to the sum of per-machine counts so existing tests
|
||||
// keep their original assertion semantics; new tests can
|
||||
// override to verify the "All runtimes" badge matches an
|
||||
// external in-scope total even when agents are missing from
|
||||
// the machine map.
|
||||
totalAgentCount={
|
||||
totalAgentCount ??
|
||||
Array.from(agentCountByMachine.values()).reduce(
|
||||
(sum, n) => sum + n,
|
||||
0,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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"));
|
||||
// DropdownMenuItem renders a Base UI Menu.Item (role="menuitem") —
|
||||
// verify the active row registered as a proper menu item, not a
|
||||
// raw <button>.
|
||||
const activeRow = screen.getByTestId("agents-runtime-filter-active");
|
||||
expect(activeRow.getAttribute("role")).toBe("menuitem");
|
||||
// Click the explicit "All runtimes" menu item by its accessible name.
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /All runtimes/ }));
|
||||
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"));
|
||||
// The machine label is the menu item's accessible name.
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /build-server/ }));
|
||||
expect(onChange).toHaveBeenCalledWith("m-remote");
|
||||
});
|
||||
|
||||
it("registers machine rows as menu items so they participate in keyboard nav / ARIA", () => {
|
||||
// Regression: rows used to be raw <button> elements, which bypassed
|
||||
// the menu's role/typeahead/focus model. With DropdownMenuItem they
|
||||
// should be role="menuitem" and live inside a role="menu" popup.
|
||||
const machines = [makeMachine({ id: "m-local", title: "dev.local" })];
|
||||
const counts = new Map([["m-local", 1]]);
|
||||
|
||||
renderDropdown(machines, null, vi.fn(), counts);
|
||||
fireEvent.click(screen.getByTestId("agents-runtime-filter"));
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu).toBeTruthy();
|
||||
// Both the "All runtimes" row and the per-machine row are items.
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||
expect(items.every((item) => item.getAttribute("role") === "menuitem")).toBe(true);
|
||||
});
|
||||
|
||||
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.getByRole("menuitem", { name: /dev.local/ });
|
||||
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();
|
||||
});
|
||||
|
||||
it("uses the explicit totalAgentCount for the All-runtimes badge even when it diverges from the per-machine sum", () => {
|
||||
// Regression: the All-runtimes count used to be derived from
|
||||
// agentCountByMachine, which silently dropped agents whose runtime
|
||||
// was GC'd (not present in any current machine). The badge should
|
||||
// track the in-scope total instead so it never undercounts what
|
||||
// the user actually sees when the filter is cleared.
|
||||
const machines = [makeMachine({ id: "m-local", title: "dev.local" })];
|
||||
const counts = new Map([["m-local", 3]]);
|
||||
|
||||
renderDropdown(machines, null, vi.fn(), counts, /* totalAgentCount */ 5);
|
||||
|
||||
const trigger = screen.getByTestId("agents-runtime-filter");
|
||||
// Trigger surfaces the All-runtimes total, not the per-machine sum.
|
||||
expect(trigger.textContent).toContain("5");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useMemo } from "react";
|
||||
import { ChevronDown, Server } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
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,
|
||||
// Sourced separately from the in-scope agent list (not derived from
|
||||
// `agentCountByMachine`) so the "All runtimes" badge stays accurate
|
||||
// even when an in-scope agent is bound to a runtime that's been GC'd
|
||||
// and no longer shows up under any current machine.
|
||||
totalAgentCount,
|
||||
}: {
|
||||
machines: RuntimeMachine[];
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
agentCountByMachine: Map<string, number>;
|
||||
totalAgentCount: number;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const selected =
|
||||
value === null
|
||||
? null
|
||||
: machines.find((machine) => machine.id === value) ?? null;
|
||||
|
||||
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");
|
||||
// DropdownMenuItem (Base UI Menu.Item) wires the row into the menu's
|
||||
// keyboard navigation, typeahead, and ARIA role="menuitem" semantics,
|
||||
// and auto-closes the menu on selection (closeOnClick: true). The
|
||||
// visual treatment — selected vs. idle — is layered on top via
|
||||
// `data-active` so it survives focus/hover styling from the base.
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
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 data-highlighted: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>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,18 @@
|
||||
"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",
|
||||
"section_local": "Local",
|
||||
"section_remote": "Remote",
|
||||
"section_cloud": "Cloud",
|
||||
"agent_count_one": "{{count}} agent",
|
||||
"agent_count_other": "{{count}} agents",
|
||||
"empty": "No machines yet"
|
||||
},
|
||||
"columns": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -46,7 +46,18 @@
|
||||
"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": "모든 런타임",
|
||||
"section_local": "로컬",
|
||||
"section_remote": "원격",
|
||||
"section_cloud": "클라우드",
|
||||
"agent_count_one": "에이전트 {{count}}개",
|
||||
"agent_count_other": "에이전트 {{count}}개",
|
||||
"empty": "아직 기기가 없습니다"
|
||||
},
|
||||
"columns": {
|
||||
"agent": "에이전트",
|
||||
|
||||
@@ -46,7 +46,17 @@
|
||||
"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": "全部运行时",
|
||||
"section_local": "本机",
|
||||
"section_remote": "远程",
|
||||
"section_cloud": "云端",
|
||||
"agent_count_other": "{{count}} 个智能体",
|
||||
"empty": "还没有机器"
|
||||
},
|
||||
"columns": {
|
||||
"agent": "智能体",
|
||||
|
||||
Reference in New Issue
Block a user