From e541eb4e73a20373330796082470036cc10d7269 Mon Sep 17 00:00:00 2001 From: J Date: Mon, 6 Jul 2026 18:20:43 +0800 Subject: [PATCH] feat(runtimes): show runtime owner avatar on machine sidebar rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the owner on each machine row in the runtimes sidebar so a member can tell whose machine a runtime runs on before routing work to it — the runtime owner is authoritative for where commands and file access execute, and a public runtime is an explicit opt-in to run others' agents locally. owner_id is already on the runtime API response, so this is display-only: derive the distinct owners per machine and render their member avatars (name on hover) next to the workload count. Owners that no longer resolve to a workspace member are skipped. MUL-4133 Co-authored-by: multica-agent --- .../runtime-machine-filter-dropdown.test.tsx | 1 + .../components/runtime-machines.test.ts | 15 ++++ .../runtimes/components/runtime-machines.ts | 10 +++ .../runtimes/components/runtimes-page.tsx | 72 ++++++++++++++++--- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx b/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx index 4700b288d2..b636d91962 100644 --- a/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx +++ b/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx @@ -31,6 +31,7 @@ function makeMachine( runningCount: 0, queuedCount: 0, providerNames: ["claude"], + ownerIds: ["user-1"], lastSeenAt: "2026-05-17T11:59:50Z", ...overrides, }; diff --git a/packages/views/runtimes/components/runtime-machines.test.ts b/packages/views/runtimes/components/runtime-machines.test.ts index 027a4446af..8fa0151ef5 100644 --- a/packages/views/runtimes/components/runtime-machines.test.ts +++ b/packages/views/runtimes/components/runtime-machines.test.ts @@ -52,6 +52,21 @@ describe("runtime machine grouping", () => { }); }); + it("collects distinct runtime owners for the machine, ignoring null owners", () => { + const machines = buildRuntimeMachines( + [ + makeRuntime({ id: "rt-a", provider: "claude", owner_id: "user-1" }), + makeRuntime({ id: "rt-b", provider: "codex", owner_id: "user-2" }), + makeRuntime({ id: "rt-c", provider: "cursor", owner_id: "user-1" }), + makeRuntime({ id: "rt-d", provider: "pi", owner_id: null }), + ], + { now: NOW, localDaemonId: "daemon-1" }, + ); + + expect(machines).toHaveLength(1); + expect(machines[0]?.ownerIds).toEqual(["user-1", "user-2"]); + }); + it("counts machines with any offline runtime as issues", () => { const machines = buildRuntimeMachines( [ diff --git a/packages/views/runtimes/components/runtime-machines.ts b/packages/views/runtimes/components/runtime-machines.ts index ae00d0dcd2..a8106cb08d 100644 --- a/packages/views/runtimes/components/runtime-machines.ts +++ b/packages/views/runtimes/components/runtime-machines.ts @@ -27,6 +27,8 @@ export interface RuntimeMachine { runningCount: number; queuedCount: number; providerNames: string[]; + /** Distinct workspace user ids that own this machine's runtimes. */ + ownerIds: string[]; lastSeenAt: string | null; } @@ -129,6 +131,7 @@ function placeholderLocalMachine( runningCount: 0, queuedCount: 0, providerNames: [], + ownerIds: [], lastSeenAt: null, }; } @@ -181,6 +184,12 @@ function finalizeRuntimeMachine( ); const first = runtimes[0]; const providerNames = Array.from(new Set(runtimes.map((r) => r.provider))).sort(); + // Distinct owners of this machine's runtimes. In practice a daemon has a + // single registrant, but a machine can technically host runtimes owned by + // different members, so keep every distinct owner for the sidebar avatars. + const ownerIds = Array.from( + new Set(runtimes.map((r) => r.owner_id).filter((id): id is string => !!id)), + ); // Device-name consolidation is only safe for the current user's own // local runtimes — the list spans the whole workspace, so a host-name // match alone could claim another member's identically-named machine. @@ -248,6 +257,7 @@ function finalizeRuntimeMachine( runningCount: workload.runningCount, queuedCount: workload.queuedCount, providerNames, + ownerIds, lastSeenAt: latestLastSeenAt(runtimes), }; } diff --git a/packages/views/runtimes/components/runtimes-page.tsx b/packages/views/runtimes/components/runtimes-page.tsx index ee0cf6b9c6..13b482ec98 100644 --- a/packages/views/runtimes/components/runtimes-page.tsx +++ b/packages/views/runtimes/components/runtimes-page.tsx @@ -10,6 +10,7 @@ import { Server, } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { MemberWithUser } from "@multica/core/types"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceId } from "@multica/core/hooks"; import { agentTaskSnapshotOptions } from "@multica/core/agents"; @@ -34,6 +35,7 @@ import { CloudRuntimeDialog } from "./cloud-runtime-dialog"; import { RuntimeProfilesDialog } from "./runtime-profiles-dialog"; import { ProviderLogo } from "./provider-logo"; import { RuntimeList, buildWorkloadIndex } from "./runtime-list"; +import { ActorAvatar } from "../../common/actor-avatar"; import { pendingRuntimesForProfiles, type PendingRuntimeProfile, @@ -638,15 +640,18 @@ function MachineRow({ - {busyCount > 0 ? ( - - {t(($) => $.machine.busy_count, { count: busyCount })} - - ) : ( - - {runtimeCount} - - )} + + + {busyCount > 0 ? ( + + {t(($) => $.machine.busy_count, { count: busyCount })} + + ) : ( + + {runtimeCount} + + )} + @@ -675,6 +680,55 @@ function ProviderIconStack({ providers }: { providers: string[] }) { ); } +// Owner avatars for a machine's runtimes, shown in the sidebar row. Surfaces +// "whose machine is this" at a glance: the runtime owner is where commands and +// file access actually run, so a member can tell a shared (public) runtime from +// their own machine before routing work to it. Renders plain, non-interactive +// avatars (the row itself is the click target) with the owner name as a native +// tooltip. Owners that no longer resolve to a workspace member are skipped +// rather than rendered as an "Unknown" placeholder. +function MachineOwners({ ownerIds }: { ownerIds: string[] }) { + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const owners = useMemo(() => { + if (ownerIds.length === 0) return []; + const byUserId = new Map(members.map((m) => [m.user_id, m])); + return ownerIds + .map((id) => byUserId.get(id)) + .filter((m): m is MemberWithUser => !!m); + }, [ownerIds, members]); + + if (owners.length === 0) return null; + + const visible = owners.slice(0, 2); + const extra = owners.length - visible.length; + return ( + + {visible.map((owner) => ( + + + + ))} + {extra > 0 && ( + + +{extra} + + )} + + ); +} + function MachineDetail({ machine, updatableIds,