From 0d8a7ce5c2423dd50901c27ee2b7461ae9269be6 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:11:00 +0800 Subject: [PATCH] fix(inbox): drop the agent activity hover card from inbox rows (MUL-5189) (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(views): raise hover delay on the agent activity badge The per-issue agent activity badge sits on the right edge of dense scrolling lists (inbox rows, issue rows, board cards) and appears on every issue an agent currently touches. Base UI's 600ms default opened the 288px activity card on pointer travel rather than on intent. Raise the open delay to 900ms and drop the close delay to 150ms. The card body is read-only, so there is no hover bridge to protect. MUL-5189 Co-authored-by: multica-agent * fix(inbox): drop the agent activity hover card from inbox rows The badge already shows who is running and whether they are working or queued. On a triage surface the card's only incremental fact is elapsed time, which never changes the one decision an inbox row supports — do I open this? The row also carries the ActorAvatar hover card on the left, so a second popup was mostly noise. Add an opt-out `hoverCard` prop to IssueAgentActivityIndicator and pass false from the inbox row. Issue lists and board cards keep the card: monitoring work in flight is what those views are for, and they keep the 900ms dwell delay from the previous commit. MUL-5189 Co-authored-by: multica-agent --------- Co-authored-by: Bohan-J Co-authored-by: multica-agent --- .../inbox/components/inbox-list-item.test.tsx | 25 ++- .../inbox/components/inbox-list-item.tsx | 10 +- .../issue-agent-activity-indicator.test.tsx | 144 ++++++++++++++++++ .../issue-agent-activity-indicator.tsx | 84 +++++++--- 4 files changed, 241 insertions(+), 22 deletions(-) create mode 100644 packages/views/issues/components/issue-agent-activity-indicator.test.tsx 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}