diff --git a/packages/views/issues/components/issue-agent-activity-indicator.tsx b/packages/views/issues/components/issue-agent-activity-indicator.tsx index d7332c1abb..1a443e6e2b 100644 --- a/packages/views/issues/components/issue-agent-activity-indicator.tsx +++ b/packages/views/issues/components/issue-agent-activity-indicator.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { HoverCard, @@ -14,8 +14,11 @@ 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: [] }; + interface IssueAgentActivityIndicatorProps { issueId: string; // Avatar tier. Kept very small — this is a corner-of-card cue, not a @@ -44,8 +47,14 @@ interface IssueAgentActivityIndicatorProps { * with status dot + duration. No link rows — the card itself is the * navigation target for issue detail. * - * Re-renders on every snapshot invalidation (WS task:* events drive it - * via use-realtime-sync). 30s staleTime is the offline fallback only. + * 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, @@ -53,41 +62,29 @@ export const IssueAgentActivityIndicator = memo(function IssueAgentActivityIndic }: IssueAgentActivityIndicatorProps) { const { t } = useT("issues"); const wsId = useWorkspaceId(); - const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId)); + const select = useCallback( + (snapshot: AgentTask[]) => selectIssueTasks(snapshot, issueId), + [issueId], + ); + const { data: groups = EMPTY_GROUPS } = useQuery({ + ...agentTaskSnapshotOptions(wsId), + select, + }); - const { runningTasks, queuedTasks, agentIds, opacity } = useMemo(() => { - const running: AgentTask[] = []; - const queued: AgentTask[] = []; - for (const task of snapshot) { - if (task.issue_id !== issueId) continue; - if (task.status === "running") running.push(task); - else if ( - task.status === "queued" || - task.status === "dispatched" || - // waiting_local_directory is the daemon-parked variant of "queued" - // — the agent is still actively waiting on a path lock, so it - // belongs in the active hover stack rather than dropping out. - task.status === "waiting_local_directory" - ) - queued.push(task); - // Terminal statuses are intentionally ignored — they belong on the - // issue history, not the live indicator. - } + 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 = running.length > 0 ? running : queued; + const primary = groups.running.length > 0 ? groups.running : groups.queued; const uniqueAgents = [...new Set(primary.map((t) => t.agent_id))]; return { - runningTasks: running, - queuedTasks: queued, agentIds: uniqueAgents, - opacity: (running.length > 0 ? "full" : "half") as "full" | "half", + opacity: (groups.running.length > 0 ? "full" : "half") as "full" | "half", }; - }, [snapshot, issueId]); + }, [groups]); if (agentIds.length === 0) return null; - const hoverTasks = [...runningTasks, ...queuedTasks]; + const hoverTasks = [...groups.running, ...groups.queued]; const isRunning = opacity === "full"; return ( diff --git a/packages/views/issues/surface/activity.test.ts b/packages/views/issues/surface/activity.test.ts index 289e6c01be..dff53b3cb5 100644 --- a/packages/views/issues/surface/activity.test.ts +++ b/packages/views/issues/surface/activity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentTask } from "@multica/core/types"; -import { deriveIssueSurfaceActivity } from "./activity"; +import { deriveIssueSurfaceActivity, selectIssueTasks } from "./activity"; function task(overrides: Partial): AgentTask { return { @@ -49,3 +49,36 @@ describe("deriveIssueSurfaceActivity", () => { expect(activity.activityByIssueId.has("i-4")).toBe(false); }); }); + +describe("selectIssueTasks", () => { + const snapshot: AgentTask[] = [ + task({ id: "run-1", issue_id: "i-1", status: "running" }), + task({ id: "run-2", issue_id: "i-1", status: "running" }), + task({ id: "queue-1", issue_id: "i-1", status: "queued" }), + task({ id: "other-run", issue_id: "i-2", status: "running" }), + task({ id: "wait-1", issue_id: "i-3", status: "waiting_local_directory" }), + task({ id: "dispatch-1", issue_id: "i-3", status: "dispatched" }), + task({ id: "done-1", issue_id: "i-1", status: "completed" }), + task({ id: "no-issue", issue_id: undefined, status: "running" }), + ]; + + it("returns only the running/queued tasks for the requested issue", () => { + const groups = selectIssueTasks(snapshot, "i-1"); + expect(groups.running.map((t) => t.id)).toEqual(["run-1", "run-2"]); + expect(groups.queued.map((t) => t.id)).toEqual(["queue-1"]); + }); + + it("treats dispatched and waiting_local_directory as queued", () => { + const groups = selectIssueTasks(snapshot, "i-3"); + expect(groups.running).toEqual([]); + expect(groups.queued.map((t) => t.id)).toEqual(["wait-1", "dispatch-1"]); + }); + + it("drops terminal statuses and tasks without an issue", () => { + const groups = selectIssueTasks(snapshot, "i-1"); + expect(groups.running.map((t) => t.id)).not.toContain("done-1"); + expect(groups.queued.map((t) => t.id)).not.toContain("done-1"); + const noMatch = selectIssueTasks(snapshot, "does-not-exist"); + expect(noMatch).toEqual({ running: [], queued: [] }); + }); +}); diff --git a/packages/views/issues/surface/activity.ts b/packages/views/issues/surface/activity.ts index 649af1679c..60130924c0 100644 --- a/packages/views/issues/surface/activity.ts +++ b/packages/views/issues/surface/activity.ts @@ -26,6 +26,34 @@ function isQueuedTaskStatus(status: AgentTask["status"]) { ); } +export interface IssueTaskGroups { + running: AgentTask[]; + queued: AgentTask[]; +} + +/** + * Per-issue slice of the workspace-wide agent task snapshot. Used as the + * `select` for a row-level `useQuery(agentTaskSnapshotOptions)`: every row + * still observes the one shared snapshot query, but React Query's structural + * sharing keeps this returned object referentially stable when *this* issue's + * tasks are unchanged, so a snapshot invalidation only re-renders the rows + * whose own tasks actually moved — not the whole list. Terminal statuses are + * dropped (they belong on issue history, not the live indicator). + */ +export function selectIssueTasks( + snapshot: readonly AgentTask[], + issueId: string, +): IssueTaskGroups { + const running: AgentTask[] = []; + const queued: AgentTask[] = []; + for (const task of snapshot) { + if (task.issue_id !== issueId) continue; + if (task.status === "running") running.push(task); + else if (isQueuedTaskStatus(task.status)) queued.push(task); + } + return { running, queued }; +} + export function deriveIssueSurfaceActivity( tasks: readonly AgentTask[], ): IssueSurfaceActivity {