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}