diff --git a/apps/desktop/src/renderer/src/components/desktop-agents-page.tsx b/apps/desktop/src/renderer/src/components/desktop-agents-page.tsx new file mode 100644 index 000000000..72513ff76 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/desktop-agents-page.tsx @@ -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({ state: "stopped" }); + const [lastIdentity, setLastIdentity] = useState<{ + daemonId: string | null; + deviceName: string | null; + }>({ daemonId: null, deviceName: null }); + const [hostName, setHostName] = useState(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 ( + + ); +} diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index 46cef3af9..e8130b6b5 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -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: , handle: { title: "Skill" }, }, - { path: "agents", element: , handle: { title: "Agents" } }, + { path: "agents", element: , handle: { title: "Agents" } }, { path: "agents/:id", element: , diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/agents/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/agents/page.tsx index 336d8549d..243efc5e8 100644 --- a/apps/web/app/[workspaceSlug]/(dashboard)/agents/page.tsx +++ b/apps/web/app/[workspaceSlug]/(dashboard)/agents/page.tsx @@ -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 ; +} diff --git a/packages/views/agents/components/agents-page.tsx b/packages/views/agents/components/agents-page.tsx index 5af23b6f0..2cc2d0148 100644 --- a/packages/views/agents/components/agents-page.tsx +++ b/packages/views/agents/components/agents-page.tsx @@ -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 s.setScope); const [availabilityFilter, setAvailabilityFilter] = useState("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(null); const [sort, setSort] = useState("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(); + 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(); + 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 = { 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} /> ) : ( @@ -439,7 +593,12 @@ export function AgentsPage() { )} {sortedAgents.length === 0 ? ( - + ) : ( 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; }) { const { t } = useT("agents"); return ( @@ -592,6 +759,13 @@ function ActiveToolbarRow({
+ {archivedCount > 0 && (