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>
This commit is contained in:
MiniMax M3
2026-06-01 09:52:37 +08:00
parent 7071f33efa
commit 1144b6023c
9 changed files with 161 additions and 31 deletions

View File

@@ -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
/>
);
}

View File

@@ -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 />,

View File

@@ -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 />;
}

View File

@@ -83,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();
@@ -204,12 +233,24 @@ export function AgentsPage() {
// 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.
// 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: Date.now() }),
[runtimes],
() =>
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
@@ -227,8 +268,13 @@ export function AgentsPage() {
// 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`.
// 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) {
@@ -711,6 +757,7 @@ function ActiveToolbarRow({
value={runtimeMachineId}
onChange={onRuntimeMachineChange}
agentCountByMachine={agentCountByMachine}
totalAgentCount={totalCount}
/>
{archivedCount > 0 && (
<button

View File

@@ -41,6 +41,7 @@ function renderDropdown(
value: string | null,
onChange: (id: string | null) => void,
agentCountByMachine: Map<string, number>,
totalAgentCount?: number,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -53,6 +54,18 @@ function renderDropdown(
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>,
@@ -210,4 +223,20 @@ describe("RuntimeMachineFilterDropdown", () => {
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");
});
});

View File

@@ -28,25 +28,23 @@ export function RuntimeMachineFilterDropdown({
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;
// 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

View File

@@ -52,15 +52,12 @@
},
"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"
"empty": "No machines yet"
},
"columns": {
"agent": "Agent",

View File

@@ -52,15 +52,12 @@
},
"runtime_filter": {
"all": "모든 런타임",
"all_description": "모든 기기의 에이전트",
"section_local": "로컬",
"section_remote": "원격",
"section_cloud": "클라우드",
"agent_count_one": "에이전트 {{count}}개",
"agent_count_other": "에이전트 {{count}}개",
"this_machine": " 기기",
"empty": "아직 기기가 없습니다",
"reset": "모든 런타임"
"empty": "아직 기기가 없습니다"
},
"columns": {
"agent": "에이전트",

View File

@@ -52,14 +52,11 @@
},
"runtime_filter": {
"all": "全部运行时",
"all_description": "所有机器上的智能体",
"section_local": "本机",
"section_remote": "远程",
"section_cloud": "云端",
"agent_count_other": "{{count}} 个智能体",
"this_machine": "本机",
"empty": "还没有机器",
"reset": "全部运行时"
"empty": "还没有机器"
},
"columns": {
"agent": "智能体",