Files
multica/packages/views/issues/components/issue-agent-header-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

181 lines
7.1 KiB
TypeScript

"use client";
import { memo, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { useActorName } from "@multica/core/workspace/hooks";
import { cn } from "@multica/ui/lib/utils";
import { api } from "@multica/core/api";
import { issueKeys } from "@multica/core/issues/queries";
import type { AgentTask } from "@multica/core/types";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { ActiveTaskRow } from "./execution-log-section";
import { useT } from "../../i18n";
// Per-issue "is an agent working on this right now?" chip for the issue
// detail header. Lives in the header (not the scrollable body) so the live
// signal stays in one fixed place and never competes with future sticky
// banners in the content column. Replaces the in-body sticky live card.
//
// Reads the same per-issue task list as the right-panel Execution log
// (shared `issueKeys.tasks(issueId)` cache), so the header chip and the log
// always agree on what is active. Both surfaces derive from one query, which
// removes the race where the old workspace-wide agent-task-snapshot refetched
// slower than this per-issue list and left the chip lagging behind the log's
// "agent is working".
//
// Collapsed display stays intentionally shallow:
// - one running agent → avatar + "{name} is working"
// - multiple running → avatar stack + "N agents working"
// - queued only → "{name} is queued" / "N agents queued",
// half-opacity avatars / muted text (no beam)
//
// Hovering the chip opens a compact Popover card with the same active rows as
// the right panel (click / keyboard still toggle it for touch and a11y). Those
// rows show necessary status/time and task entry actions, but do not render
// event counts or prefetch task messages for a count.
interface IssueAgentHeaderChipProps {
issueId: string;
}
export const IssueAgentHeaderChip = memo(function IssueAgentHeaderChip({
issueId,
}: IssueAgentHeaderChipProps) {
// Same query options as ExecutionLogSection so both observe one cache entry.
const { data: tasks = [] } = useQuery({
queryKey: issueKeys.tasks(issueId),
queryFn: () => api.listTasksByIssue(issueId),
staleTime: 30_000,
refetchOnWindowFocus: true,
});
const { running, queued } = useMemo(() => {
const running: AgentTask[] = [];
const queued: AgentTask[] = [];
// The list is already issue-scoped by the endpoint, so only the status
// split matters here.
for (const task of tasks) {
if (task.status === "running") running.push(task);
else if (
task.status === "queued" ||
task.status === "dispatched" ||
// Daemon-parked on a busy local_directory — still active, just
// waiting on a path lock. Belongs in the live chip, not dropped.
task.status === "waiting_local_directory"
)
queued.push(task);
// Terminal statuses are the execution log's story, not the live chip's.
}
return { running, queued };
}, [tasks]);
// No active work → render nothing.
if (running.length === 0 && queued.length === 0) return null;
return <ActiveChip issueId={issueId} running={running} queued={queued} />;
});
interface ActiveChipProps {
issueId: string;
running: AgentTask[];
queued: AgentTask[];
}
function ActiveChip({ issueId, running, queued }: ActiveChipProps) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const activeTasks = [...running, ...queued];
const agentIds = [...new Set(activeTasks.map((task) => task.agent_id))];
const anyRunning = running.length > 0;
const isSingle = agentIds.length === 1;
// Copy must follow the actual state: "is working" only when something is
// truly running. With nothing running (queued / dispatched / parked on a
// path lock) the chip reads "is queued" so a not-yet-started agent isn't
// mislabelled as working.
const label = isSingle
? t(
($) =>
anyRunning ? $.agent_live.is_working : $.agent_live.is_queued,
{ name: getActorName("agent", agentIds[0] ?? "") },
)
: t(
($) =>
anyRunning
? $.agent_activity.hover_header
: $.agent_activity.hover_header_queued,
{ count: agentIds.length },
);
return (
<div className="flex items-center gap-1">
<Popover>
{/* Hover opens the card so the live activity reads as a glanceable
status surface, not a click target. In Base UI the hover config
lives on the Trigger (a popover can have multiple triggers), not
the Root. The trigger stays a real button, so click and keyboard
(Enter/Space) still toggle it for touch and a11y. A short open
delay avoids flicker when the pointer merely passes over the chip;
the close delay keeps it open while the pointer travels across the
hover bridge into the interactive rows. */}
<PopoverTrigger
openOnHover
delay={150}
closeDelay={200}
render={
<button
type="button"
aria-label={label}
// While an agent is actively running, the chip wears the
// brand border beam — a highlight sweeping around its rounded
// edge — so a triggered run is unmistakably "alive" in the
// header. Queued-only state stays calm (no beam) to reserve the
// motion for work that is genuinely in flight.
className={cn(
"flex h-7 max-w-[11rem] items-center gap-1.5 rounded-md px-1.5 text-muted-foreground outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring",
anyRunning && "border-beam bg-brand/5",
)}
/>
}
>
<AgentAvatarStack
agentIds={agentIds}
size="sm"
max={3}
opacity={anyRunning ? "full" : "half"}
/>
<span
className={`min-w-0 truncate text-xs ${anyRunning ? "text-info" : "text-muted-foreground"}`}
>
{label}
</span>
</PopoverTrigger>
<PopoverContent align="end" keepMounted className="w-80">
<div className="text-xs font-medium text-muted-foreground">
{t(
($) =>
anyRunning
? $.agent_activity.hover_header
: $.agent_activity.hover_header_queued,
{ count: agentIds.length },
)}
</div>
<div className="flex flex-col gap-0.5">
{activeTasks.map((task) => (
<ActiveTaskRow key={task.id} task={task} issueId={issueId} />
))}
</div>
</PopoverContent>
</Popover>
{/* Separator from the action buttons — the chip is a status segment,
not another button, so a hairline keeps the two groups legible. */}
<span className="h-4 w-px bg-border" aria-hidden="true" />
</div>
);
}