mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
fix(agents): drop the runtime badge from agent avatars (MUL-5567) (#6208)
The provider mark overlaid on the avatar's top-right read as clutter: half of it overhangs the disc, so on the agent page, the profile card, and the chat header it looked like something stuck to the avatar rather than part of it — and every one of those surfaces already names the runtime in text a line away. Removes the overlay and everything that existed only to feed it: the `AgentRuntimeBadge` module, the `showRuntimeBadge` prop on `ActorAvatar`, and `useAgentRuntimeProvider`. The presence dot keeps the wrapper, now back to a single overlay. The provider mark on the agents list Runtime column stays — it sits beside its label instead of on an avatar, which is where scanning "which of these run on Codex" is a shape match rather than a read. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -4,7 +4,6 @@ export * from "./failure-reason";
|
||||
export * from "./effective-access";
|
||||
export * from "./queries";
|
||||
export * from "./use-agent-presence";
|
||||
export * from "./use-agent-runtime";
|
||||
export * from "./use-update-agent-allowlist";
|
||||
export * from "./use-agent-activity";
|
||||
export * from "./use-workspace-presence-prefetch";
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
|
||||
/**
|
||||
* The provider slug ("claude", "codex", …) backing an agent's runtime, or
|
||||
* `null` when it can't be resolved — lists still loading, an agent outside the
|
||||
* workspace list (archived assignee on an old issue), a runtime that no longer
|
||||
* exists, or a backend that omitted the field. Callers render nothing on null
|
||||
* rather than a placeholder: a wrong provider mark is worse than none.
|
||||
*
|
||||
* Derived from `runtime_id` on every read instead of stored on the agent, so
|
||||
* moving an agent from one runtime to another moves its provider mark with it.
|
||||
*
|
||||
* Both queries are ones the surfaces that show a provider mark already
|
||||
* subscribe to (`useActorName`, presence), so this costs a cache read rather
|
||||
* than a request.
|
||||
*/
|
||||
export function useAgentRuntimeProvider(
|
||||
wsId: string | undefined,
|
||||
agentId: string | undefined,
|
||||
): string | null {
|
||||
const { data: agents } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: runtimes } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!agentId || !agents || !runtimes) return null;
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
if (!agent) return null;
|
||||
const runtime = runtimes.find((r) => r.id === agent.runtime_id);
|
||||
return runtime?.provider?.trim() || null;
|
||||
}, [agentId, agents, runtimes]);
|
||||
}
|
||||
@@ -430,7 +430,6 @@ function DetailHeader({
|
||||
actorId={agent.id}
|
||||
size="2xl"
|
||||
profileLink={false}
|
||||
showRuntimeBadge
|
||||
className="ring-1 ring-border"
|
||||
/>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
|
||||
@@ -16,8 +16,6 @@ vi.mock("@multica/core/paths", () => ({
|
||||
useWorkspacePaths: () => ({
|
||||
agentDetail: (id: string) => `/test/agents/${id}`,
|
||||
}),
|
||||
// The runtime badge on the card's avatar resolves the provider per workspace.
|
||||
useCurrentWorkspace: () => ({ id: "ws-1", slug: "test" }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { AgentRuntimeBadge } from "../../common/agent-runtime-badge";
|
||||
import { HealthIcon } from "../../runtimes/components/shared";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { VisibilityBadge } from "./visibility-badge";
|
||||
@@ -76,19 +75,16 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
availability dot is surfaced here; last-task state lives in the
|
||||
agents list and the agent detail page. */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Base avatar + runtime badge rather than the ActorAvatar wrapper:
|
||||
this card IS a hover-card payload, so it must not nest another
|
||||
hover card or profile link inside itself. */}
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<ActorAvatarBase
|
||||
name={agent.name}
|
||||
initials={initials}
|
||||
avatarUrl={resolvePublicFileUrl(agent.avatar_url)}
|
||||
isAgent
|
||||
size="xl"
|
||||
/>
|
||||
<AgentRuntimeBadge agentId={agent.id} size="xl" />
|
||||
</span>
|
||||
{/* Base avatar rather than the ActorAvatar wrapper: this card IS a
|
||||
hover-card payload, so it must not nest another hover card or
|
||||
profile link inside itself. */}
|
||||
<ActorAvatarBase
|
||||
name={agent.name}
|
||||
initials={initials}
|
||||
avatarUrl={resolvePublicFileUrl(agent.avatar_url)}
|
||||
isAgent
|
||||
size="xl"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-body font-semibold">{agent.name}</p>
|
||||
|
||||
@@ -105,14 +105,7 @@ export function ChatSessionHeader({
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
{agent ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size="lg"
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
showRuntimeBadge
|
||||
/>
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size="lg" enableHoverCard showStatusDot />
|
||||
) : (
|
||||
<span className="size-[30px] shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useAgentPresenceDetail } from "@multica/core/agents";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
AgentRuntimeBadge,
|
||||
RUNTIME_BADGE_MIN_AVATAR_PX,
|
||||
} from "./agent-runtime-badge";
|
||||
import { AgentProfileCard } from "../agents/components/agent-profile-card";
|
||||
import { AgentLivePeekCard } from "../agents/components/agent-live-peek-card";
|
||||
import { MemberProfileCard } from "../members/member-profile-card";
|
||||
@@ -55,18 +51,6 @@ interface ActorAvatarProps {
|
||||
* popover inside the dropdown.
|
||||
*/
|
||||
showStatusDot?: boolean;
|
||||
/**
|
||||
* Overlay the provider mark of the agent's runtime at the avatar's top-right.
|
||||
* Use on identity/config surfaces — the agent's own page, its profile card,
|
||||
* a chat session header — where "what is running underneath this?" is a live
|
||||
* question. Has no effect for non-agent actors.
|
||||
*
|
||||
* Independent of `showStatusDot`, and the two are designed to coexist: they
|
||||
* sit at opposite ends of the circle and answer different questions (can it
|
||||
* take work right now, vs what backs it). No-ops below
|
||||
* {@link RUNTIME_BADGE_MIN_AVATAR_PX} — see that constant for why.
|
||||
*/
|
||||
showRuntimeBadge?: boolean;
|
||||
/**
|
||||
* When `enableHoverCard` is on for an agent, choose which payload to
|
||||
* render. See {@link AgentHoverCardVariant}. Defaults to `"profile"` so
|
||||
@@ -92,7 +76,6 @@ export function ActorAvatar({
|
||||
className,
|
||||
enableHoverCard,
|
||||
showStatusDot,
|
||||
showRuntimeBadge,
|
||||
hoverCardVariant = "profile",
|
||||
profileLink,
|
||||
}: ActorAvatarProps) {
|
||||
@@ -111,23 +94,19 @@ export function ActorAvatar({
|
||||
/>
|
||||
);
|
||||
|
||||
// Optional overlays. Only meaningful for agents — members have no presence
|
||||
// backbone and no runtime. Wrapping unconditionally with relative inline-flex
|
||||
// would create extra DOM for every avatar; we only wrap when an overlay is
|
||||
// Optional presence overlay. Only meaningful for agents — members have no
|
||||
// presence backbone. Wrapping unconditionally with relative inline-flex
|
||||
// would create extra DOM for every avatar; we only wrap when the dot is
|
||||
// asked for.
|
||||
const isAgent = actorType === "agent";
|
||||
const wrapDot = showStatusDot && isAgent;
|
||||
const wrapBadge = showRuntimeBadge && isAgent;
|
||||
const dotted =
|
||||
wrapDot || wrapBadge ? (
|
||||
<span className="relative inline-flex">
|
||||
{avatar}
|
||||
{wrapBadge && <AgentRuntimeBadge agentId={actorId} size={size} />}
|
||||
{wrapDot && <AgentStatusDot agentId={actorId} size={size} />}
|
||||
</span>
|
||||
) : (
|
||||
avatar
|
||||
);
|
||||
const wrapDot = showStatusDot && actorType === "agent";
|
||||
const dotted = wrapDot ? (
|
||||
<span className="relative inline-flex">
|
||||
{avatar}
|
||||
<AgentStatusDot agentId={actorId} size={size} />
|
||||
</span>
|
||||
) : (
|
||||
avatar
|
||||
);
|
||||
const shouldLinkToProfile =
|
||||
profileLink ??
|
||||
(actorType === "member" || actorType === "agent" || actorType === "squad");
|
||||
@@ -237,7 +216,6 @@ export function AgentStatusDot({ agentId, size }: { agentId: string; size?: Avat
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wraps an agent avatar in a hover-card. The trigger is keyboard-focusable
|
||||
* only when no focusable ancestor (link/button) already provides a tab stop —
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Runtime provider badge on agent avatars.
|
||||
*
|
||||
* The badge and the presence dot are two overlays on one circle, so the rules
|
||||
* that keep them from fighting each other are the ones worth pinning: the
|
||||
* badge only appears where a provider mark is actually legible, it never
|
||||
* displaces the dot, and it stays away from anything it can't resolve.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { NavigationProvider } from "../navigation/context";
|
||||
import type { NavigationAdapter } from "../navigation/types";
|
||||
|
||||
const provider = vi.hoisted(() => ({ current: "claude" as string | null }));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getActorName: () => "Lambda",
|
||||
getActorInitials: () => "L",
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useWorkspacePaths: () => ({
|
||||
memberDetail: (id: string) => `/acme/members/${id}`,
|
||||
agentDetail: (id: string) => `/acme/agents/${id}`,
|
||||
squadDetail: (id: string) => `/acme/squads/${id}`,
|
||||
}),
|
||||
useCurrentWorkspace: () => ({ id: "ws1", slug: "acme" }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
useAgentPresenceDetail: () => ({ availability: "online", workload: "idle" }),
|
||||
useAgentRuntimeProvider: () => provider.current,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/components/agent-profile-card", () => ({
|
||||
AgentProfileCard: () => null,
|
||||
}));
|
||||
vi.mock("../agents/components/agent-live-peek-card", () => ({
|
||||
AgentLivePeekCard: () => null,
|
||||
}));
|
||||
vi.mock("../members/member-profile-card", () => ({
|
||||
MemberProfileCard: () => null,
|
||||
}));
|
||||
vi.mock("../squads/components/squad-profile-card", () => ({
|
||||
SquadProfileCard: () => null,
|
||||
}));
|
||||
|
||||
import { ActorAvatar } from "./actor-avatar";
|
||||
|
||||
const AGENT_ID = "8f14e45f-ceea-4d0e-a1a2-9b1c0d3e4f5a";
|
||||
|
||||
// The avatar wraps itself in a profile link, which needs the adapter.
|
||||
const ADAPTER: NavigationAdapter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/",
|
||||
searchParams: new URLSearchParams(),
|
||||
getShareableUrl: (p) => `https://app.example${p}`,
|
||||
};
|
||||
|
||||
function renderAvatar(ui: ReactElement) {
|
||||
return render(<NavigationProvider value={ADAPTER}>{ui}</NavigationProvider>);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
provider.current = "claude";
|
||||
});
|
||||
|
||||
describe("AgentRuntimeBadge", () => {
|
||||
it("marks the avatar with the runtime's provider", () => {
|
||||
renderAvatar(<ActorAvatar actorType="agent" actorId={AGENT_ID} size="2xl" showRuntimeBadge />);
|
||||
expect(screen.getByLabelText("Runtime: Claude")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Provider marks are detailed artwork; at the ~8px a badge gets on a 20px
|
||||
// picker row they're a smudge. That size is the status dot's home turf, so
|
||||
// the badge has to stay out of it even when a caller asks for one.
|
||||
it("stays off avatars too small to render a legible mark", () => {
|
||||
renderAvatar(<ActorAvatar actorType="agent" actorId={AGENT_ID} size="sm" showRuntimeBadge />);
|
||||
expect(screen.queryByLabelText("Runtime: Claude")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both overlays when a surface asks for the dot too", () => {
|
||||
renderAvatar(
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={AGENT_ID}
|
||||
size="lg"
|
||||
showStatusDot
|
||||
showRuntimeBadge
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Runtime: Claude")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Status: Online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Archived agent, deleted runtime, or a backend that omitted the field. A
|
||||
// mark that names the wrong runtime is worse than no mark.
|
||||
it("renders nothing when the provider cannot be resolved", () => {
|
||||
provider.current = null;
|
||||
renderAvatar(<ActorAvatar actorType="agent" actorId={AGENT_ID} size="2xl" showRuntimeBadge />);
|
||||
expect(screen.queryByLabelText(/^Runtime:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no effect for non-agent actors", () => {
|
||||
renderAvatar(<ActorAvatar actorType="member" actorId={AGENT_ID} size="2xl" showRuntimeBadge />);
|
||||
expect(screen.queryByLabelText(/^Runtime:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useAgentRuntimeProvider } from "@multica/core/agents";
|
||||
import { providerDisplayName } from "@multica/core/runtimes";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { AVATAR_SIZE_PX, type AvatarSize } from "@multica/ui/lib/avatar-size";
|
||||
import { ProviderLogo } from "../runtimes/components/provider-logo";
|
||||
|
||||
/**
|
||||
* Smallest avatar that can carry a provider mark.
|
||||
*
|
||||
* A badge gets roughly 40% of the avatar's diameter, and provider marks are
|
||||
* detailed artwork (Claude's glyph, CodeBuddy's tile) rather than a flat shape
|
||||
* like the presence dot — below ~12px they read as a smudge. 32px is the first
|
||||
* tier that clears it. That rules the badge out of exactly the surfaces the
|
||||
* status dot lives on: nearly every `showStatusDot` call site is a 20px picker
|
||||
* row, where "can it take work right now" is the question and the provider
|
||||
* isn't.
|
||||
*/
|
||||
export const RUNTIME_BADGE_MIN_AVATAR_PX = 32;
|
||||
|
||||
/**
|
||||
* Provider mark overlaid on the top-right of an agent avatar — the opposite
|
||||
* end from the presence dot, so a surface can carry both without them
|
||||
* touching.
|
||||
*
|
||||
* Derived from the agent's current runtime on every render, so switching
|
||||
* runtimes moves the mark with it. Renders nothing when the provider can't be
|
||||
* resolved (loading, archived agent, deleted runtime): a wrong mark is worse
|
||||
* than none.
|
||||
*
|
||||
* Lives in its own module rather than beside `AgentStatusDot` because the
|
||||
* agent profile card needs it, and `actor-avatar` already imports that card
|
||||
* for its hover payload — importing back would close a cycle.
|
||||
*/
|
||||
export function AgentRuntimeBadge({
|
||||
agentId,
|
||||
size,
|
||||
}: {
|
||||
agentId: string;
|
||||
size?: AvatarSize;
|
||||
}) {
|
||||
const ws = useCurrentWorkspace();
|
||||
const provider = useAgentRuntimeProvider(ws?.id, agentId);
|
||||
const px = size ? AVATAR_SIZE_PX[size] : 24;
|
||||
if (!provider || px < RUNTIME_BADGE_MIN_AVATAR_PX) return null;
|
||||
|
||||
// The disc is background-coloured so a multi-coloured mark stays legible
|
||||
// over an emoji or photo avatar; the mark itself fills most of it. The
|
||||
// hairline is what makes it read as a chip sitting ON the avatar rather than
|
||||
// an icon floating beside it — several provider marks (Claude's asterisk,
|
||||
// Codex's ring) are airy line art with no silhouette of their own, and
|
||||
// without an edge the badge loses its anchor. Same treatment as ProviderChip.
|
||||
const disc = Math.round(px * 0.42);
|
||||
// Push the badge out past the bounding box so its centre lands on the
|
||||
// avatar's rim instead of inside it. Flush to the corner (`top-0 right-0`)
|
||||
// looks correct on a square but not on a circle: at 45° the edge has already
|
||||
// curved ~29% of the diameter away from that corner, so the badge ends up
|
||||
// sitting over the face. Offsetting by (badge radius − rim inset) leaves
|
||||
// roughly half of it overhanging, which is the usual platform-badge look.
|
||||
const offset = Math.round(px * 0.064);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={`Runtime: ${providerDisplayName(provider)}`}
|
||||
className="absolute inline-flex items-center justify-center rounded-full bg-background ring-1 ring-border"
|
||||
style={{ width: disc, height: disc, top: -offset, right: -offset }}
|
||||
>
|
||||
<ProviderLogo provider={provider} className="h-[72%] w-[72%]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user