mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "에이전트",
|
||||
|
||||
@@ -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": "智能体",
|
||||
|
||||
Reference in New Issue
Block a user