From 32ab1e77dc3a3268b95261cc72af64ca06684ffb Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Thu, 30 Jul 2026 22:13:39 +0800 Subject: [PATCH] fix(agents): drop the runtime badge from agent avatars (MUL-5567) (#6208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: multica-agent --- packages/core/agents/index.ts | 1 - packages/core/agents/use-agent-runtime.ts | 42 ------- .../agents/components/agent-detail-page.tsx | 1 - .../components/agent-profile-card.test.tsx | 2 - .../agents/components/agent-profile-card.tsx | 24 ++-- .../chat/components/chat-session-header.tsx | 9 +- packages/views/common/actor-avatar.tsx | 46 ++----- .../views/common/agent-runtime-badge.test.tsx | 115 ------------------ packages/views/common/agent-runtime-badge.tsx | 72 ----------- 9 files changed, 23 insertions(+), 289 deletions(-) delete mode 100644 packages/core/agents/use-agent-runtime.ts delete mode 100644 packages/views/common/agent-runtime-badge.test.tsx delete mode 100644 packages/views/common/agent-runtime-badge.tsx diff --git a/packages/core/agents/index.ts b/packages/core/agents/index.ts index 322eedb3d5..c689d569e5 100644 --- a/packages/core/agents/index.ts +++ b/packages/core/agents/index.ts @@ -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"; diff --git a/packages/core/agents/use-agent-runtime.ts b/packages/core/agents/use-agent-runtime.ts deleted file mode 100644 index 021746a394..0000000000 --- a/packages/core/agents/use-agent-runtime.ts +++ /dev/null @@ -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]); -} diff --git a/packages/views/agents/components/agent-detail-page.tsx b/packages/views/agents/components/agent-detail-page.tsx index 63c670f4b8..3e565c225d 100644 --- a/packages/views/agents/components/agent-detail-page.tsx +++ b/packages/views/agents/components/agent-detail-page.tsx @@ -430,7 +430,6 @@ function DetailHeader({ actorId={agent.id} size="2xl" profileLink={false} - showRuntimeBadge className="ring-1 ring-border" />
diff --git a/packages/views/agents/components/agent-profile-card.test.tsx b/packages/views/agents/components/agent-profile-card.test.tsx index c77668bc67..1a9f6fda52 100644 --- a/packages/views/agents/components/agent-profile-card.test.tsx +++ b/packages/views/agents/components/agent-profile-card.test.tsx @@ -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", () => ({ diff --git a/packages/views/agents/components/agent-profile-card.tsx b/packages/views/agents/components/agent-profile-card.tsx index b265e69a69..c20ac185cf 100644 --- a/packages/views/agents/components/agent-profile-card.tsx +++ b/packages/views/agents/components/agent-profile-card.tsx @@ -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. */}
- {/* 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. */} - - - - + {/* 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. */} +

{agent.name}

diff --git a/packages/views/chat/components/chat-session-header.tsx b/packages/views/chat/components/chat-session-header.tsx index aab8366808..d4be8d87ff 100644 --- a/packages/views/chat/components/chat-session-header.tsx +++ b/packages/views/chat/components/chat-session-header.tsx @@ -105,14 +105,7 @@ export function ChatSessionHeader({ return (
{agent ? ( - + ) : ( )} diff --git a/packages/views/common/actor-avatar.tsx b/packages/views/common/actor-avatar.tsx index 3a088d97e5..59a455add5 100644 --- a/packages/views/common/actor-avatar.tsx +++ b/packages/views/common/actor-avatar.tsx @@ -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 ? ( - - {avatar} - {wrapBadge && } - {wrapDot && } - - ) : ( - avatar - ); + const wrapDot = showStatusDot && actorType === "agent"; + const dotted = wrapDot ? ( + + {avatar} + + + ) : ( + 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 — diff --git a/packages/views/common/agent-runtime-badge.test.tsx b/packages/views/common/agent-runtime-badge.test.tsx deleted file mode 100644 index 21d04f06d0..0000000000 --- a/packages/views/common/agent-runtime-badge.test.tsx +++ /dev/null @@ -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({ui}); -} - -afterEach(() => { - cleanup(); - provider.current = "claude"; -}); - -describe("AgentRuntimeBadge", () => { - it("marks the avatar with the runtime's provider", () => { - renderAvatar(); - 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(); - expect(screen.queryByLabelText("Runtime: Claude")).not.toBeInTheDocument(); - }); - - it("renders both overlays when a surface asks for the dot too", () => { - renderAvatar( - , - ); - 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(); - expect(screen.queryByLabelText(/^Runtime:/)).not.toBeInTheDocument(); - }); - - it("has no effect for non-agent actors", () => { - renderAvatar(); - expect(screen.queryByLabelText(/^Runtime:/)).not.toBeInTheDocument(); - }); -}); diff --git a/packages/views/common/agent-runtime-badge.tsx b/packages/views/common/agent-runtime-badge.tsx deleted file mode 100644 index ee80778b3e..0000000000 --- a/packages/views/common/agent-runtime-badge.tsx +++ /dev/null @@ -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 ( - - - - ); -}