Files
multica/packages/views/common/actor-avatar.tsx
Naiyuan Qing f4de0948a2 refactor(ui): unify ActorAvatar size tiers + round all avatars & cropper (MUL-4277, MUL-4184) (#5133)
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277)

Replace the free-form numeric `size` on ActorAvatar with a constrained
`AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by
role instead of ad-hoc pixels. This eliminates the magic-number drift where
the same role rendered at different sizes across pages.

- Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map +
  default tier).
- Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot`
  (packages/views) now take `AvatarSize`; internal font/icon math and the
  presence-dot threshold read px from the map.
- Migrate all web/desktop call sites (packages/ui + packages/views) from
  numeric sizes to tiers using the role table
  (12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl).
- Token-ise the derived consumers `AgentAvatarStack` and
  `IssueAgentActivityIndicator` (px looked up internally for overlap/+N math).

Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker,
mobile, and component-name disambiguation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184)

main's avatar-shape decision rendered non-human actors (agent, squad,
system) and the workspace logo as rounded squares, and the upload cropper
mirrored that with a square crop window. Per the updated decision
(avatars_and_cropper_round_required), every avatar and the crop UI are now
circular; the square path is removed rather than left as dead config.

- Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split).
- avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is
  always cropShape="round".
- avatar-upload-control: drop VARIANT_SHAPE; the control is always round and
  no longer threads a shape to the dialog (variant still drives the fallback).
- Strip rounded-md/rounded-none square overrides from agent/squad/member
  ActorAvatar call sites; round the read-only agent/squad static wrappers.
- WorkspaceAvatar: round the org logo so it matches the (now round) workspace
  upload/crop and the shared avatar shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): make round avatar shape a hard invariant (MUL-4277)

Close the two remaining square squad-avatar paths flagged in review and
prevent call sites from re-squaring the avatar:

- base ActorAvatar: keep `rounded-full` as the last class in cn() so a
  call-site `className` can no longer override the circle.
- SquadHeaderAvatar: drop `className="rounded"` (was overriding the base
  circle into a small rounded square).
- SquadsPage no-avatar fallback: route through the shared ActorAvatarBase
  (`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the
  fallback matches the image path — one shape source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(views): round the agent/squad avatar loading skeletons (MUL-4277)

The avatar placeholder skeletons on the agent/squad list, detail, and
profile-card loading states were still rounded squares (rounded-md/lg) from
the pre-round era, so the avatar visibly popped from square to circle on
load — inconsistent with the round avatars and with the member/inbox/issue
skeletons that already use rounded-full.

Round all five: agents-page, agent-detail-page, squads-page,
squad-detail-page, squad-profile-card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 08:31:40 +08:00

312 lines
9.6 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { AVATAR_SIZE_PX, type AvatarSize } from "@multica/ui/lib/avatar-size";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@multica/ui/components/ui/hover-card";
import { useActorName } from "@multica/core/workspace/hooks";
import { useAgentPresenceDetail } from "@multica/core/agents";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { AgentProfileCard } from "../agents/components/agent-profile-card";
import { AgentLivePeekCard } from "../agents/components/agent-live-peek-card";
import { MemberProfileCard } from "../members/member-profile-card";
import { SquadProfileCard } from "../squads/components/squad-profile-card";
import { availabilityConfig } from "../agents/presence";
import { useNavigation } from "../navigation";
/**
* Selects which agent hover-card payload to render when `enableHoverCard` is
* on. Two surfaces, two intents:
* - `"profile"` (default) — static identity (description, runtime, skills,
* owner). Used by 20+ "who is this agent?" surfaces (comment authors,
* pickers, list rows).
* - `"live"` — live activity peek (workload, current issue, last activity).
* Used where the user already knows the identity and wants the live state,
* e.g. the squad members tab.
*
* Has no effect for non-agent actors (members always render the member card).
*/
export type AgentHoverCardVariant = "profile" | "live";
interface ActorAvatarProps {
actorType: string;
actorId: string;
size?: AvatarSize;
className?: string;
/**
* Wrap the avatar in a hover-card preview on dwell. Use for "who is this?"
* surfaces — comment authors, list rows, subscriber chips. Independent of
* `showStatusDot`: a surface can have one, both, or neither.
*/
enableHoverCard?: boolean;
/**
* Overlay an agent-presence dot at the avatar's bottom-right. Use at
* decision moments (picker rows, current-assignee display, agent-centric
* surfaces). Has no effect for non-agent actors. Independent of
* `enableHoverCard` so picker rows can show the dot without nesting a
* popover inside the dropdown.
*/
showStatusDot?: boolean;
/**
* When `enableHoverCard` is on for an agent, choose which payload to
* render. See {@link AgentHoverCardVariant}. Defaults to `"profile"` so
* existing call sites keep their identity-card behaviour.
*/
hoverCardVariant?: AgentHoverCardVariant;
/**
* Make the avatar click through to the actor page. Defaults on for members
* and agents, while picker/menu controls keep their own click behavior.
*/
profileLink?: boolean;
}
const FOCUSABLE_ANCESTOR_SELECTOR =
'a[href], button:not([disabled]), [role="button"]:not([aria-disabled="true"]), [tabindex]:not([tabindex="-1"])';
const PROFILE_LINK_CONTROL_SELECTOR =
'button, [role^="menuitem"], [role="option"], [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="popover-trigger"]';
export function ActorAvatar({
actorType,
actorId,
size,
className,
enableHoverCard,
showStatusDot,
hoverCardVariant = "profile",
profileLink,
}: ActorAvatarProps) {
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
const paths = useWorkspacePaths();
const avatar = (
<ActorAvatarBase
name={getActorName(actorType, actorId)}
initials={getActorInitials(actorType, actorId)}
avatarUrl={getActorAvatarUrl(actorType, actorId)}
isAgent={actorType === "agent"}
isSystem={actorType === "system"}
isSquad={actorType === "squad"}
size={size}
className={className}
/>
);
// Optional presence dot 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 a dot is asked
// for.
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");
const profileHref = shouldLinkToProfile
? actorType === "member"
? paths.memberDetail(actorId)
: actorType === "agent"
? paths.agentDetail(actorId)
: actorType === "squad"
? paths.squadDetail(actorId)
: null
: null;
const content = profileHref ? (
<ActorAvatarProfileLink href={profileHref}>{dotted}</ActorAvatarProfileLink>
) : (
dotted
);
if (!enableHoverCard) {
return content;
}
if (actorType === "agent") {
return (
<AgentAvatarHoverCard agentId={actorId} variant={hoverCardVariant}>
{content}
</AgentAvatarHoverCard>
);
}
if (actorType === "member") {
return <MemberAvatarHoverCard userId={actorId}>{content}</MemberAvatarHoverCard>;
}
if (actorType === "squad") {
return <SquadAvatarHoverCard squadId={actorId}>{content}</SquadAvatarHoverCard>;
}
return content;
}
function ActorAvatarProfileLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const { push, openInNewTab } = useNavigation();
const navigate = (event: React.MouseEvent | React.KeyboardEvent) => {
const controlAncestor = event.currentTarget.parentElement?.closest(
PROFILE_LINK_CONTROL_SELECTOR,
);
if (controlAncestor) return;
event.preventDefault();
event.stopPropagation();
if (
"metaKey" in event &&
(event.metaKey || event.ctrlKey || event.shiftKey) &&
openInNewTab
) {
openInNewTab(href);
return;
}
push(href);
};
return (
<span
role="link"
tabIndex={-1}
className="inline-flex cursor-pointer rounded-full"
onClick={navigate}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
navigate(event);
}
}}
>
{children}
</span>
);
}
// Small presence indicator overlaid on the bottom-right of an agent avatar.
// Only renders on hover-enabled surfaces so dense decorative chips (e.g. the
// 14 px owner sub-avatar in agents-list rows) stay visually clean. The dot
// scales with the avatar size — anything ≥24 px gets the standard 8 px dot,
// smaller avatars use a 6 px dot so the indicator doesn't overwhelm them.
// Exported for surfaces that render the base avatar directly (e.g. comment
// trigger chips) but still want the standard presence dot.
export function AgentStatusDot({ agentId, size }: { agentId: string; size?: AvatarSize }) {
const ws = useCurrentWorkspace();
const detail = useAgentPresenceDetail(ws?.id, agentId);
if (detail === "loading") return null;
const { dotClass, label } = availabilityConfig[detail.availability];
const px = size ? AVATAR_SIZE_PX[size] : 24;
const dotSize = px >= 24 ? "h-1.5 w-1.5" : "h-1 w-1";
return (
<span
aria-label={`Status: ${label}`}
title={label}
className={`absolute bottom-0 right-0 rounded-full ring-1 ring-background ${dotClass} ${dotSize}`}
/>
);
}
/**
* 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 —
* this prevents nested tabbable descendants and keyboard-nav bloat at sites
* where the avatar lives inside a row link or click target.
*/
function AgentAvatarHoverCard({
agentId,
variant,
children,
}: {
agentId: string;
variant: AgentHoverCardVariant;
children: React.ReactNode;
}) {
const content =
variant === "live" ? (
<AgentLivePeekCard agentId={agentId} />
) : (
<AgentProfileCard agentId={agentId} />
);
return (
<ActorAvatarHoverCardShell content={content}>
{children}
</ActorAvatarHoverCardShell>
);
}
function MemberAvatarHoverCard({
userId,
children,
}: {
userId: string;
children: React.ReactNode;
}) {
return (
<ActorAvatarHoverCardShell content={<MemberProfileCard userId={userId} />}>
{children}
</ActorAvatarHoverCardShell>
);
}
function SquadAvatarHoverCard({
squadId,
children,
}: {
squadId: string;
children: React.ReactNode;
}) {
return (
<ActorAvatarHoverCardShell content={<SquadProfileCard squadId={squadId} />}>
{children}
</ActorAvatarHoverCardShell>
);
}
// Common chrome shared between agent and member hover cards. Keeps focus
// behaviour and width consistent so the two surfaces feel structurally
// parallel — content varies, frame doesn't.
function ActorAvatarHoverCardShell({
content,
children,
}: {
content: React.ReactNode;
children: React.ReactNode;
}) {
const triggerRef = useRef<HTMLSpanElement>(null);
const [standalone, setStandalone] = useState(false);
useEffect(() => {
const el = triggerRef.current;
if (!el) return;
const ancestor = el.parentElement?.closest(FOCUSABLE_ANCESTOR_SELECTOR);
setStandalone(!ancestor);
}, []);
return (
<HoverCard>
<HoverCardTrigger
render={<span ref={triggerRef} />}
tabIndex={standalone ? 0 : -1}
className={
standalone
? "inline-flex cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "inline-flex cursor-pointer"
}
>
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-72">
{content}
</HoverCardContent>
</HoverCard>
);
}