feat(runtimes): show runtime owner avatar on machine sidebar rows

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 <github@multica.ai>
This commit is contained in:
J
2026-07-06 18:20:43 +08:00
parent b2db309618
commit e541eb4e73
4 changed files with 89 additions and 9 deletions

View File

@@ -31,6 +31,7 @@ function makeMachine(
runningCount: 0,
queuedCount: 0,
providerNames: ["claude"],
ownerIds: ["user-1"],
lastSeenAt: "2026-05-17T11:59:50Z",
...overrides,
};

View File

@@ -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(
[

View File

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

View File

@@ -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({
</span>
<span className="mt-1.5 flex min-w-0 items-center gap-1.5">
<ProviderIconStack providers={machine.providerNames} />
{busyCount > 0 ? (
<span className="ml-auto shrink-0 text-xs font-medium text-primary">
{t(($) => $.machine.busy_count, { count: busyCount })}
</span>
) : (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{runtimeCount}
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-1.5">
<MachineOwners ownerIds={machine.ownerIds} />
{busyCount > 0 ? (
<span className="text-xs font-medium text-primary">
{t(($) => $.machine.busy_count, { count: busyCount })}
</span>
) : (
<span className="text-xs text-muted-foreground">
{runtimeCount}
</span>
)}
</span>
</span>
</span>
</button>
@@ -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<MemberWithUser[]>(() => {
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 (
<span className="flex shrink-0 items-center -space-x-1">
{visible.map((owner) => (
<span
key={owner.user_id}
className="inline-flex rounded-full ring-2 ring-background"
title={owner.name}
aria-label={owner.name}
>
<ActorAvatar
actorType="member"
actorId={owner.user_id}
size={18}
enableHoverCard={false}
profileLink={false}
/>
</span>
))}
{extra > 0 && (
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-muted px-1 text-[10px] font-medium text-muted-foreground ring-2 ring-background">
+{extra}
</span>
)}
</span>
);
}
function MachineDetail({
machine,
updatableIds,