"use client";
import { memo, useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
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 { cn } from "@multica/ui/lib/utils";
import type { AvatarSize } from "@multica/ui/lib/avatar-size";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { AgentActivityHoverContent } from "../../agents/components/agent-activity-hover-content";
import { selectIssueTasks, type IssueTaskGroups } from "../surface/activity";
import { useT } from "../../i18n";
const EMPTY_GROUPS: IssueTaskGroups = { running: [], queued: [] };
// Dwell threshold before the activity card opens (MUL-5189).
//
// This badge is a passive cue riding on the right edge of dense scrolling
// lists (inbox rows, issue rows, board cards), and it appears on every issue
// an agent currently touches. Base UI's 600ms default is tuned for a hover
// target the user aims at; here the pointer crosses the badge constantly on
// its way to the row, the archive button, or the next row, so 600ms fires on
// travel rather than on intent and a 288px card lands over the rows below.
//
// 900ms sits past casual travel but still inside a deliberate "what is it
// doing?" pause. The header chip (issue-agent-header-chip) keeps its 150ms
// on purpose: it is one large chip the user aims at, not a per-row cue.
//
// The card body is read-only — no links, no buttons — so there is no hover
// bridge to protect and the close delay only needs to absorb pointer wobble
// across the 4px gap.
const OPEN_DELAY_MS = 900;
const CLOSE_DELAY_MS = 150;
interface IssueAgentActivityIndicatorProps {
issueId: string;
// Avatar tier. Kept very small — this is a corner-of-card cue, not a
// primary control. Default xs (16 px) reads as a dot at typical board
// densities while still showing the agent's face on hover-zoom.
size?: AvatarSize;
// Whether hovering opens the activity card. Opt OUT where the card's only
// incremental information is not worth a popup (Inbox — see below).
hoverCard?: boolean;
}
/**
* Small "is there an agent working on this issue right now" badge shown
* in the top-right of board cards and right after the identifier in list
* rows. Derives state from the workspace-wide agent task snapshot:
*
* - has ≥1 running task → tiny avatar stack + shimmering "Working"
* - 0 running, ≥1 queued → half-opacity stack + muted "Queued"
* - nothing → return null (no chrome, no placeholder)
*
* The shimmer reuses chat's `animate-chat-text-shimmer` utility (defined
* in packages/ui/styles/base.css). Earlier iterations layered a brand
* ring + opacity pulse around the avatars; both read as nervous on a
* dense board. Moving the "alive" signal onto the label keeps the
* avatars themselves still and lets the cue ride a piece of text the
* user can already read.
*
* Hover opens AgentActivityHoverContent which lists every active task
* with status dot + duration. No link rows — the card itself is the
* navigation target for issue detail.
*
* Surfaces that only need the cue can pass `hoverCard={false}` and get the
* badge alone. Inbox does (MUL-5189): the badge already shows who is running
* and whether they are working or queued, so on a triage surface the card's
* only incremental fact is elapsed time — which never changes the one
* decision an inbox row exists to support ("do I open this?"). Issue lists
* and board cards keep it: monitoring work in flight is what those views are
* for, and elapsed time is load-bearing there.
*
* Subscribes to the one shared workspace snapshot query but narrows it to
* this issue's tasks with a `select`. React Query's structural sharing keeps
* that selected value referentially stable when this issue's tasks are
* unchanged, so a snapshot invalidation (WS task:* events, driven by
* use-realtime-sync) only re-renders the rows whose own tasks actually moved
* — not the whole list. This is the de-amplification that keeps large issue
* lists cheap when agents are busy (MUL-4474). 30s staleTime is the offline
* fallback only.
*/
export const IssueAgentActivityIndicator = memo(function IssueAgentActivityIndicator({
issueId,
size = "xs",
hoverCard = true,
}: IssueAgentActivityIndicatorProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const select = useCallback(
(snapshot: AgentTask[]) => selectIssueTasks(snapshot, issueId),
[issueId],
);
const { data: groups = EMPTY_GROUPS } = useQuery({
...agentTaskSnapshotOptions(wsId),
select,
});
const { agentIds, opacity } = useMemo(() => {
// Stack heads: prefer running. If 0 running, fall back to queued.
// Each case is visually distinct (running gets shimmer, queued gets
// muted text) so the indicator always offers a face to hover.
const primary = groups.running.length > 0 ? groups.running : groups.queued;
const uniqueAgents = [...new Set(primary.map((t) => t.agent_id))];
return {
agentIds: uniqueAgents,
opacity: (groups.running.length > 0 ? "full" : "half") as "full" | "half",
};
}, [groups]);
if (agentIds.length === 0) return null;
const isRunning = opacity === "full";
const badge = (
<>
{isRunning
? t(($) => $.agent_activity.status_running)
: t(($) => $.agent_activity.status_queued)}
>
);
if (!hoverCard) {
return (
{badge}
);
}
const hoverTasks = [...groups.running, ...groups.queued];
return (
}
>
{badge}
);
});