Files
multica/packages/views/agents/components/agent-avatar-stack.tsx
Naiyuan Qing fedd0f1694 feat(issues): live agent activity chip + per-issue indicator + filter (#3058)
* feat(server): broadcast task:running event

The dispatched → running transition was silent: only task:queued,
task:dispatch, task:cancelled, task:completed and task:failed
broadcast over WS. Any UI that distinguishes "queued" from "running"
(e.g. the new issue-card agent activity indicator) would lag by up to
the 30s agentTaskSnapshot staleTime on the most user-visible
transition. StartTask now broadcasts task:running so the workspace
snapshot invalidates immediately, keeping the agent activity UI live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(issues): live agent activity chip + per-issue indicator + filter

Surfaces "which agents are working on what, right now" in the Issues
and My Issues views, with a one-click filter to narrow the list to
issues that have a running agent task.

Two visual surfaces:

- **Workspace chip** in the header (left of Filter). Shows the
  brand-tinted avatar stack of agents currently running on visible
  issues. Click toggles a page-scoped filter; idle state renders a
  static "0 working" button with a hover-card placeholder. When the
  filter is active the chip pins to brand fill across hover and popover
  states (the Button outline variant otherwise repaints back to
  neutral). A muted "Viewing only working agents" hint sits to the
  left of the chip whenever the filter is on, so users notice the
  active state without having to hover.

- **Per-issue indicator** on every board card and list row (top-right
  of the identifier line). Renders the avatar stack of agents in
  running or queued state on that issue, full-opacity ring at brand/70
  when ≥1 is running, half-opacity stack when only queued. Returns
  null when nothing is in flight.

Both surfaces open the same hover-card body that lists each active
task with the agent avatar, status dot (composed via the existing
availability + workload tokens), and a live-ticking duration.

Adds a new "All" scope to /my-issues that unions assignee, creator,
and involves_user_id via three parallel fetches deduped on the
client — no backend changes for this part. The chip's count and the
quick-filter both use the page's currently visible issue ids so they
stay in sync with the active scope.

State is per-user (Zustand + localStorage) and the agentRunningFilter
is intentionally omitted from partialize — running state changes
second-to-second and a stored toggle would land users in an
unexplained empty list. WS task:running, already added in the
preceding commit, drives real-time updates without polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(issues): swap indicator ring pulse for shimmer text label

Earlier iterations layered a brand ring with various opacity-pulse
cadences around the per-issue avatar stack. Every tuning attempt was
either invisible (transparent ring + faded pulse) or oppressive (a
visible ring that flashed on a dense board). Moves the "alive" signal
onto a small text label and reuses chat's existing
`animate-chat-text-shimmer` utility — a soft light sweep across the
glyphs that already powers the ChatGPT-style "thinking" cue in
task-status-pill.

Indicator now reads as a 12 px avatar stack + 10 px label:

- Running → full-opacity avatars + shimmering localized "Working"
- Queued  → half-opacity avatars + muted static "Queued"
- Idle    → render nothing (unchanged)

Avatars and the surrounding card stay completely still; only the few
glyphs animate. The label is i18n-driven via the existing
`status_running` / `status_queued` keys, so no locale changes are
required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:20:42 +08:00

93 lines
3.1 KiB
TypeScript

"use client";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { useActorName } from "@multica/core/workspace/hooks";
import { cn } from "@multica/ui/lib/utils";
interface AgentAvatarStackProps {
// Agent ids to render, in display order. The component does NOT dedupe —
// callers are expected to pass a unique list (`new Set(...)` upstream).
agentIds: readonly string[];
// Diameter in px. Avatars overlap by ~30% so the visible spacing scales
// naturally with size. Defaults match a compact toolbar / card-corner
// density (18 px).
size?: number;
// Maximum head count before collapsing the tail into a `+N` chip. Three
// is the plan default — beyond that the stack visually crowds.
max?: number;
// `half` drops opacity to 50%. Used by IssueAgentActivityIndicator to
// signal a queued-only state (no running task) — same heads, weakened
// visual.
opacity?: "full" | "half";
className?: string;
}
/**
* Overlapping avatar group for agents. Pure presentational — no data
* fetching, no hover handling. Wrap it in a HoverCardTrigger upstream
* (IssueAgentActivityIndicator / WorkspaceAgentWorkingChip) to surface
* per-agent detail.
*
* `agentIds` is the full input list. We render up to `max` heads; if the
* input is longer, we drop the tail and append a `+N` overflow chip styled
* to match the avatar dimensions.
*/
export function AgentAvatarStack({
agentIds,
size = 18,
max = 3,
opacity = "full",
className,
}: AgentAvatarStackProps) {
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
if (agentIds.length === 0) return null;
const visible = agentIds.slice(0, max);
const overflow = agentIds.length - visible.length;
// 30% overlap reads as "stacked" without obscuring the next avatar's icon.
const overlap = Math.round(size * 0.3);
return (
<span
className={cn(
"inline-flex items-center",
opacity === "half" && "opacity-50",
className,
)}
style={{ paddingLeft: 0 }}
>
{visible.map((id, i) => (
<span
key={id}
// Each subsequent head sits negative-margin over the previous so
// the stack collapses horizontally instead of growing linearly.
style={{ marginLeft: i === 0 ? 0 : -overlap }}
className="ring-2 ring-background rounded-full inline-flex"
>
<ActorAvatarBase
name={getActorName("agent", id)}
initials={getActorInitials("agent", id)}
avatarUrl={getActorAvatarUrl("agent", id)}
isAgent
size={size}
/>
</span>
))}
{overflow > 0 && (
<span
style={{
marginLeft: -overlap,
width: size,
height: size,
fontSize: Math.max(9, Math.round(size * 0.45)),
}}
className="ring-2 ring-background rounded-full bg-muted text-muted-foreground inline-flex items-center justify-center font-medium tabular-nums"
aria-label={`${overflow} more`}
>
+{overflow}
</span>
)}
</span>
);
}