Files
multica/packages/views/issues/components/workspace-agent-working-chip.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

164 lines
6.2 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@multica/ui/components/ui/button";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@multica/ui/components/ui/hover-card";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import type { AgentTask } from "@multica/core/types";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { AgentActivityHoverContent } from "../../agents/components/agent-activity-hover-content";
import { useT } from "../../i18n";
interface WorkspaceAgentWorkingChipProps {
// Controlled toggle binding. Different surfaces (Issues page singleton
// hook, My Issues vanilla store) own the underlying state, so the chip
// stays presentational and accepts both forms via plain props.
value: boolean;
onToggle: () => void;
// When set, only running tasks whose issue id is in this set count
// toward the chip — and toward the hover card. Lets the chip stay in
// sync with the page's visible issue scope (e.g. My Issues only shows
// "my" running tasks, not the whole workspace). When omitted, the chip
// shows workspace-wide running agents.
scopedIssueIds?: ReadonlySet<string>;
}
/**
* Filter chip on the issues / my-issues header, sitting to the left of
* the Filter button. Always rendered so the filter toggle never
* disappears mid-flight (a previous design hid the chip when no agents
* were running, which trapped users in an active-but-invisible filter
* state).
*
* Two visual modes:
*
* - Has running agents → avatar stack + count + "working" label,
* wrapped in HoverCard that lists every active task on hover.
* Brand-filled when the filter is on.
*
* - No running agents → "0 working" label, muted when off,
* brand-filled when on. No HoverCard — there is nothing to show;
* the label IS the state.
*
* Click toggles the filter in both modes. The button itself is the
* affordance — no Tooltip wrapping (the popover IS the label when there
* is one, and the label is self-explanatory when there isn't).
*
* `scopedIssueIds` lets a calling header narrow the chip to a subset of
* issues — typically "what's visible on this page right now". My Issues
* uses it so the chip count matches the my-scope list; the global
* /issues page passes the All/Members/Agents-scoped set. Without it the
* chip is workspace-wide.
*/
export function WorkspaceAgentWorkingChip({
value,
onToggle,
scopedIssueIds,
}: WorkspaceAgentWorkingChipProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { runningTasks, agentIds, issueIds } = useMemo(() => {
const running: AgentTask[] = [];
for (const task of snapshot) {
if (task.status !== "running") continue;
// When scoped, drop running tasks whose issue isn't in the visible
// set — the chip's job is to summarise what the user sees, not
// what's happening elsewhere in the workspace.
if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue;
running.push(task);
}
// The count tracks active *issues*, not active agents: several agents
// can work the same issue at once, and the chip answers "how many
// issues are agents working on right now?" (its filter narrows the
// list to exactly those issues). The avatar stack still shows the
// distinct agents behind that work.
const uniqueIssues = [...new Set(running.map((tk) => tk.issue_id))];
const uniqueAgents = [...new Set(running.map((tk) => tk.agent_id))];
return {
runningTasks: running,
agentIds: uniqueAgents,
issueIds: uniqueIssues,
};
}, [snapshot, scopedIssueIds]);
const hasAgents = issueIds.length > 0;
// Active (brand-filled) class — must explicitly re-pin text and bg in
// every interactive state. Button's `outline` variant ships
// `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`,
// which would otherwise repaint the brand chip back to neutral on
// hover and while the HoverCard is open.
const activeClass = value
? "border-brand bg-brand text-brand-foreground hover:bg-brand/90 hover:text-brand-foreground aria-expanded:bg-brand aria-expanded:text-brand-foreground"
: hasAgents
? "text-foreground"
: "text-muted-foreground";
const label = t(($) => $.agent_activity.chip_label);
// Idle path: no agents in scope. Still wrap in HoverCard with a
// single-line placeholder so the chip's hover behavior is consistent
// with the active state — an idle chip that does nothing on hover
// reads as broken next to an active one that pops a panel.
if (!hasAgents) {
return (
<HoverCard>
<HoverCardTrigger
render={
<Button
variant="outline"
size="sm"
className={`h-8 px-2 md:h-7 md:px-2.5 ${activeClass}`}
onClick={onToggle}
aria-pressed={value}
>
<span className="tabular-nums">0</span>
<span className="hidden md:inline">{label}</span>
</Button>
}
/>
<HoverCardContent align="end" className="w-auto">
<p className="text-xs text-muted-foreground">
{t(($) => $.agent_activity.empty_hover)}
</p>
</HoverCardContent>
</HoverCard>
);
}
return (
<HoverCard>
<HoverCardTrigger
render={
<Button
variant="outline"
size="sm"
className={`h-8 px-2 md:h-7 md:px-2.5 ${activeClass}`}
onClick={onToggle}
aria-pressed={value}
>
<AgentAvatarStack
agentIds={agentIds}
size="sm"
max={3}
opacity="full"
/>
<span className="tabular-nums">{issueIds.length}</span>
<span className="hidden md:inline">{label}</span>
</Button>
}
/>
<HoverCardContent align="end" className="w-72">
<AgentActivityHoverContent tasks={runningTasks} />
</HoverCardContent>
</HoverCard>
);
}