perf(issues): de-amplify per-row agent activity indicator (MUL-4474) (#5338)

Each issue row's IssueAgentActivityIndicator subscribed to the whole
workspace agent-task snapshot via useQuery. Any task change swaps the
snapshot array reference, so every observing row re-rendered — on a busy
workspace the snapshot changes constantly, turning one task update into a
full-list re-render and inflating the tab-switch commit.

Narrow each row's subscription to this issue's tasks with a `select`
(selectIssueTasks). React Query's structural sharing keeps the selected
value referentially stable when the issue's own tasks are unchanged, so a
snapshot invalidation now only re-renders the rows whose tasks actually
moved.

This is slice 1 of MUL-4474 (render de-amplification). Virtualization of
list/board/swimlane/inbox and the non-position useSortable mount change
are tracked separately — they need interactive drag + DevTools Performance
verification that the headless runtime can't provide.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-14 14:58:58 +08:00
committed by GitHub
parent a91a390d48
commit 3a493ab417
3 changed files with 87 additions and 29 deletions

View File

@@ -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 (

View File

@@ -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>): 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: [] });
});
});

View File

@@ -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 {