Files
multica/packages/views/issues/components/comment-trigger-chips.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

353 lines
12 KiB
TypeScript

"use client";
import { useState } from "react";
import type { CommentTriggerPreviewAgent } from "@multica/core/types";
import { useAgentPresenceDetail } from "@multica/core/agents";
import { useCurrentWorkspace } from "@multica/core/paths";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { AVATAR_SIZE_PX } from "@multica/ui/lib/avatar-size";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
import { cn } from "@multica/ui/lib/utils";
import { AgentStatusDot } from "../../common/actor-avatar";
import { useT } from "../../i18n";
// One agent renders in full ("Walt will start working", avatar + presence
// dot, click toggles). Several agents collapse to an overlapping avatar
// stack + count sentence, mirroring WorkspaceAgentWorkingChip on the issues
// header. Hover layers stay read-only (Tooltip); per-agent toggling lives in
// a click-opened Popover so the layer survives consecutive clicks.
// Suppression is communicated by brightness alone: lit = will trigger,
// dimmed = skipped.
// The single-agent avatar renders at the `xs` tier; the `+N` overflow chip
// and stack overlap below reuse that tier's pixel diameter so the collapsed
// stack lines up exactly with the avatars.
const AVATAR_SIZE = AVATAR_SIZE_PX.xs;
const MAX_STACK_HEADS = 4;
interface CommentTriggerChipsProps {
agents: CommentTriggerPreviewAgent[];
suppressedAgentIds: Set<string>;
onToggle: (agentId: string) => void;
}
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
function sourceLabel(source: string, t: IssuesT): string {
switch (source) {
case "issue_assignee":
return t(($) => $.comment.trigger_source_issue_assignee);
case "mention_agent":
return t(($) => $.comment.trigger_source_mention_agent);
case "mention_squad_leader":
return t(($) => $.comment.trigger_source_mention_squad_leader);
default:
return t(($) => $.comment.trigger_source_unknown);
}
}
// Assignee / @mention reasons are intentionally omitted: the header
// (name · source) already says why they fire, so a reason line there would
// just restate it. Only the squad-leader link (non-obvious) and the unknown
// fallback carry information the header doesn't.
function sourceReason(agent: CommentTriggerPreviewAgent, t: IssuesT): string | null {
switch (agent.source) {
case "issue_assignee":
case "mention_agent":
return null;
case "mention_squad_leader":
return t(($) => $.comment.trigger_reason_mention_squad_leader);
default:
return agent.reason || t(($) => $.comment.trigger_reason_unknown);
}
}
// Presence is display metadata only — the trigger list itself is always the
// backend preview. Online-ish agents start right away; offline ones queue.
function useTriggerPresenceLine(agentId: string, t: IssuesT): string | null {
const ws = useCurrentWorkspace();
const detail = useAgentPresenceDetail(ws?.id, agentId);
if (detail === "loading") return null;
return detail.availability === "online" || detail.availability === "unstable"
? t(($) => $.comment.trigger_starts_now)
: t(($) => $.comment.trigger_starts_when_online);
}
// One tooltip body for every trigger surface (single chip, popover rows):
// who · why it fires (+ presence) · what a click does.
function TriggerAgentTooltipBody({
agent,
suppressed,
t,
}: {
agent: CommentTriggerPreviewAgent;
suppressed: boolean;
t: IssuesT;
}) {
const presenceLine = useTriggerPresenceLine(agent.id, t);
return (
<div className="space-y-0.5">
<div className="flex items-baseline gap-1.5">
<span className="font-medium">{agent.name}</span>
<span className="text-[10px] text-muted-foreground">{sourceLabel(agent.source, t)}</span>
</div>
{suppressed ? (
<div>{t(($) => $.comment.trigger_click_to_restore)}</div>
) : (
<>
{(() => {
// Reason (when present) and presence share one line; either may be
// absent, so join only the parts that exist to avoid a stray space.
const line = [sourceReason(agent, t), presenceLine].filter(Boolean).join(" ");
return line ? <div>{line}</div> : null;
})()}
<div className="text-muted-foreground">{t(($) => $.comment.trigger_click_to_skip)}</div>
</>
)}
</div>
);
}
export function CommentTriggerChips({
agents,
suppressedAgentIds,
onToggle,
}: CommentTriggerChipsProps) {
const { t } = useT("issues");
// Loading and errors render nothing: the preview is an enhancement, and
// any interim chrome here reads as composer noise.
if (agents.length === 0) return null;
if (agents.length === 1) {
const agent = agents[0]!;
return (
<SingleTriggerChip
agent={agent}
suppressed={suppressedAgentIds.has(agent.id)}
onToggle={onToggle}
t={t}
/>
);
}
return (
<MultiTriggerChip
agents={agents}
suppressedAgentIds={suppressedAgentIds}
onToggle={onToggle}
t={t}
/>
);
}
function SingleTriggerChip({
agent,
suppressed,
onToggle,
t,
}: {
agent: CommentTriggerPreviewAgent;
suppressed: boolean;
onToggle: (agentId: string) => void;
t: IssuesT;
}) {
const state = suppressed
? t(($) => $.comment.trigger_skipped_label)
: sourceLabel(agent.source, t);
// The avatar carries "who"; the sentence carries only condition + outcome,
// so it stays fixed-width and never truncates on long agent names.
const sentence = suppressed
? t(($) => $.comment.trigger_wont_trigger)
: t(($) => $.comment.trigger_will_start);
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-pressed={suppressed}
aria-label={t(($) => $.comment.trigger_chip_aria, { name: agent.name, state })}
onClick={() => onToggle(agent.id)}
className={cn(
// Sidebar-style resting state: muted until hover so the strip
// reads as metadata, not content (see app-sidebar nav items).
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground",
suppressed && "opacity-60",
)}
>
<TriggerAgentAvatar agent={agent} suppressed={suppressed} />
<span className="truncate">{sentence}</span>
</button>
}
/>
<TooltipContent side="top" className="max-w-72 text-xs">
<TriggerAgentTooltipBody agent={agent} suppressed={suppressed} t={t} />
</TooltipContent>
</Tooltip>
);
}
function MultiTriggerChip({
agents,
suppressedAgentIds,
onToggle,
t,
}: {
agents: CommentTriggerPreviewAgent[];
suppressedAgentIds: Set<string>;
onToggle: (agentId: string) => void;
t: IssuesT;
}) {
const [open, setOpen] = useState(false);
const [tooltipHover, setTooltipHover] = useState(false);
const activeCount = agents.filter((a) => !suppressedAgentIds.has(a.id)).length;
const heads = agents.slice(0, MAX_STACK_HEADS);
const overflow = agents.length - heads.length;
// Mirror AgentAvatarStack: ~30% overlap reads as "stacked" without
// obscuring the next avatar.
const overlap = Math.round(AVATAR_SIZE * 0.3);
// The avatar stack shows who; the sentence promises only what WILL happen,
// so the count covers non-suppressed agents — skipped ones read as the
// dimmed heads right next to the number.
const sentence =
activeCount === 0
? t(($) => $.comment.trigger_none_will_trigger)
: t(($) => $.comment.trigger_will_start_count, { count: activeCount });
const popoverTrigger = (
<PopoverTrigger
render={
// Ghost-button affordance (hover fill + aria-expanded pin) so the
// stack reads as clickable, matching pickers across the app.
<button
type="button"
className={cn(
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
activeCount === 0 && "opacity-60",
)}
/>
}
>
<span className="inline-flex items-center">
{heads.map((agent, i) => (
<span
key={agent.id}
style={{ marginLeft: i === 0 ? 0 : -overlap }}
className="inline-flex rounded-full ring-2 ring-background"
>
<TriggerAgentAvatar
agent={agent}
suppressed={suppressedAgentIds.has(agent.id)}
showDot={false}
/>
</span>
))}
{overflow > 0 && (
<span
style={{
marginLeft: -overlap,
width: AVATAR_SIZE,
height: AVATAR_SIZE,
fontSize: Math.max(9, Math.round(AVATAR_SIZE * 0.45)),
}}
className="inline-flex items-center justify-center rounded-full bg-muted font-medium tabular-nums text-muted-foreground ring-2 ring-background"
>
+{overflow}
</span>
)}
</span>
<span className="truncate">{sentence}</span>
</PopoverTrigger>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip open={tooltipHover && !open} onOpenChange={setTooltipHover}>
<TooltipTrigger render={popoverTrigger} />
<TooltipContent side="top" className="text-xs">
{t(($) => $.comment.trigger_click_to_manage)}
</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-64 p-2">
<div className="px-1.5 pb-1 text-xs font-medium text-muted-foreground">
{t(($) => $.comment.trigger_preview_title)}
</div>
<div className="flex flex-col">
{agents.map((agent) => {
const suppressed = suppressedAgentIds.has(agent.id);
const state = suppressed
? t(($) => $.comment.trigger_skipped_label)
: sourceLabel(agent.source, t);
return (
<Tooltip key={agent.id}>
<TooltipTrigger
render={
<button
type="button"
aria-pressed={suppressed}
aria-label={t(($) => $.comment.trigger_chip_aria, { name: agent.name, state })}
onClick={() => onToggle(agent.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-muted",
suppressed && "opacity-60",
)}
>
<TriggerAgentAvatar agent={agent} suppressed={suppressed} />
<span
className={cn(
"min-w-0 flex-1 truncate text-xs",
suppressed && "text-muted-foreground",
)}
>
{agent.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{state}</span>
</button>
}
/>
<TooltipContent side="right" className="max-w-72 text-xs">
<TriggerAgentTooltipBody agent={agent} suppressed={suppressed} t={t} />
</TooltipContent>
</Tooltip>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
function TriggerAgentAvatar({
agent,
suppressed,
showDot = true,
}: {
agent: CommentTriggerPreviewAgent;
suppressed: boolean;
showDot?: boolean;
}) {
return (
<span
className={cn(
"relative inline-flex shrink-0",
suppressed && "opacity-40 grayscale",
)}
>
<ActorAvatarBase
name={agent.name}
initials=""
avatarUrl={agent.avatar_url}
isAgent
size="xs"
/>
{showDot && !suppressed && <AgentStatusDot agentId={agent.id} size="xs" />}
</span>
);
}