mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
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:
@@ -31,6 +31,7 @@ function makeMachine(
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
providerNames: ["claude"],
|
||||
ownerIds: ["user-1"],
|
||||
lastSeenAt: "2026-05-17T11:59:50Z",
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user