diff --git a/packages/views/inbox/components/inbox-list-item.test.tsx b/packages/views/inbox/components/inbox-list-item.test.tsx
index 466a3f0c55..b2a766039d 100644
--- a/packages/views/inbox/components/inbox-list-item.test.tsx
+++ b/packages/views/inbox/components/inbox-list-item.test.tsx
@@ -5,8 +5,18 @@ import { InboxListItem } from "./inbox-list-item";
vi.mock("../../issues/components", () => ({ StatusIcon: () => null }));
vi.mock("../../issues/components/issue-agent-activity-indicator", () => ({
- IssueAgentActivityIndicator: ({ issueId }: { issueId: string }) => (
-
+ IssueAgentActivityIndicator: ({
+ issueId,
+ hoverCard,
+ }: {
+ issueId: string;
+ hoverCard?: boolean;
+ }) => (
+
),
}));
vi.mock("../../common/actor-avatar", () => ({
@@ -107,6 +117,17 @@ describe("InboxListItem issue activity", () => {
).toBe("issue-1");
});
+ it("shows the activity badge without its hover card", () => {
+ // Triage rows only need "an agent is on this". The card behind the badge
+ // adds elapsed time, which does not change whether you open the row, and
+ // the row already carries the actor hover card on the left.
+ const { getByTestId } = renderRow({ item: item(), view: "inbox" });
+
+ expect(
+ getByTestId("issue-agent-activity").getAttribute("data-hover-card"),
+ ).toBe("false");
+ });
+
it("omits issue activity for a notification without an issue", () => {
const { queryByTestId } = renderRow({
item: item({ issue_id: null }),
diff --git a/packages/views/inbox/components/inbox-list-item.tsx b/packages/views/inbox/components/inbox-list-item.tsx
index b213938968..76889eeb24 100644
--- a/packages/views/inbox/components/inbox-list-item.tsx
+++ b/packages/views/inbox/components/inbox-list-item.tsx
@@ -114,8 +114,16 @@ export function InboxListItem({
+ {/* Badge only, no hover card (MUL-5189). "An agent is on this"
+ is worth showing while triaging; the card behind it adds only
+ elapsed time, which does not change whether you open the row.
+ The row already carries the ActorAvatar hover card on the
+ left, so a second popup here was mostly noise. */}
{item.issue_id && (
-
+
)}
{timeAgo(item.created_at)}
diff --git a/packages/views/issues/components/issue-agent-activity-indicator.test.tsx b/packages/views/issues/components/issue-agent-activity-indicator.test.tsx
new file mode 100644
index 0000000000..54eb31ff89
--- /dev/null
+++ b/packages/views/issues/components/issue-agent-activity-indicator.test.tsx
@@ -0,0 +1,144 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AgentTask } from "@multica/core/types";
+
+const mockState = vi.hoisted(() => ({
+ snapshot: [] as unknown[],
+}));
+
+vi.mock("@multica/core/hooks", () => ({
+ useWorkspaceId: () => "ws-1",
+}));
+
+vi.mock("@multica/core/agents", () => ({
+ agentTaskSnapshotOptions: (wsId: string) => ({
+ queryKey: ["agents", "task-snapshot", wsId],
+ }),
+}));
+
+vi.mock("../../agents/components/agent-avatar-stack", () => ({
+ AgentAvatarStack: ({ agentIds }: { agentIds: string[] }) => (
+ {agentIds.length}
+ ),
+}));
+
+vi.mock("../../agents/components/agent-activity-hover-content", () => ({
+ AgentActivityHoverContent: () => ,
+}));
+
+vi.mock("../../i18n", () => ({
+ useT: () => ({ t: () => "Working" }),
+}));
+
+// The hover card only portals its content once open, so absence of the body
+// cannot distinguish "closed" from "not wired up". Mock the primitive instead
+// and assert on the wrapper itself.
+vi.mock("@multica/ui/components/ui/hover-card", () => ({
+ HoverCard: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ HoverCardTrigger: ({
+ children,
+ delay,
+ closeDelay,
+ }: {
+ children: React.ReactNode;
+ delay?: number;
+ closeDelay?: number;
+ }) => (
+
+ {children}
+
+ ),
+ HoverCardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual =
+ await vi.importActual(
+ "@tanstack/react-query",
+ );
+ return {
+ ...actual,
+ useQuery: (opts: {
+ queryKey?: readonly unknown[];
+ select?: (data: unknown) => unknown;
+ }) => {
+ if (opts.queryKey?.[1] === "task-snapshot") {
+ return {
+ data: opts.select
+ ? opts.select(mockState.snapshot)
+ : mockState.snapshot,
+ };
+ }
+ return { data: undefined };
+ },
+ };
+});
+
+import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator";
+
+function makeTask(overrides: Partial = {}): AgentTask {
+ return {
+ id: "task-1",
+ agent_id: "agent-1",
+ runtime_id: "runtime-1",
+ issue_id: "issue-1",
+ status: "running",
+ priority: 0,
+ dispatched_at: null,
+ started_at: "2026-06-08T08:00:00Z",
+ completed_at: null,
+ result: null,
+ error: null,
+ created_at: "2026-06-08T08:00:00Z",
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ cleanup();
+ mockState.snapshot = [makeTask()];
+});
+
+describe("IssueAgentActivityIndicator", () => {
+ it("wraps the badge in a hover card by default", () => {
+ render();
+
+ expect(screen.getByTestId("hover-card")).not.toBeNull();
+ expect(screen.getByTestId("agent-avatar-stack")).not.toBeNull();
+ });
+
+ it("opens the card on a deliberate dwell, not on pointer travel", () => {
+ render();
+
+ const trigger = screen.getByTestId("hover-card-trigger");
+ expect(Number(trigger.getAttribute("data-delay"))).toBeGreaterThan(600);
+ expect(Number(trigger.getAttribute("data-close-delay"))).toBeLessThan(300);
+ });
+
+ it("renders the badge without a hover card when hoverCard is false", () => {
+ render();
+
+ expect(screen.queryByTestId("hover-card")).toBeNull();
+ expect(screen.queryByTestId("hover-card-trigger")).toBeNull();
+ // The cue itself survives — only the popup behind it is dropped.
+ expect(screen.getByTestId("agent-avatar-stack")).not.toBeNull();
+ expect(screen.getByText("Working")).not.toBeNull();
+ });
+
+ it("renders nothing when no agent is on the issue", () => {
+ mockState.snapshot = [];
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/packages/views/issues/components/issue-agent-activity-indicator.tsx b/packages/views/issues/components/issue-agent-activity-indicator.tsx
index 1a443e6e2b..7ad7afbb75 100644
--- a/packages/views/issues/components/issue-agent-activity-indicator.tsx
+++ b/packages/views/issues/components/issue-agent-activity-indicator.tsx
@@ -19,12 +19,34 @@ 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;
}
/**
@@ -47,6 +69,14 @@ interface IssueAgentActivityIndicatorProps {
* 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
@@ -59,6 +89,7 @@ interface IssueAgentActivityIndicatorProps {
export const IssueAgentActivityIndicator = memo(function IssueAgentActivityIndicator({
issueId,
size = "xs",
+ hoverCard = true,
}: IssueAgentActivityIndicatorProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
@@ -84,34 +115,49 @@ export const IssueAgentActivityIndicator = memo(function IssueAgentActivityIndic
}, [groups]);
if (agentIds.length === 0) return null;
- const hoverTasks = [...groups.running, ...groups.queued];
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 (
}
>
-
-
- {isRunning
- ? t(($) => $.agent_activity.status_running)
- : t(($) => $.agent_activity.status_queued)}
-
+ {badge}