mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
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:
@@ -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 (
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user