fix(agents): gate agents list first paint on sort/filter deps (MUL-4511) (#5377)

The Agents list gated its first paint only on the main agent-list query
(prefetched, so usually warm) while the sort/filter columns arrive in
separate activity / run-count / presence queries. On entry the default
lastActive sort ran on placeholder values (lastActiveDays null→Infinity,
runCount 0), painting a degenerate name-ordered list that visibly
re-ordered once each auxiliary query resolved (1–2 jumps).

Add a need-based `listReady` render gate: wait for exactly the auxiliary
queries the active sort field / filter depends on — nothing for
name/created, run-counts for runs, activity + run-counts for the default
lastActive, plus presence when an availability filter is active. Queries
still run in parallel, so this only defers the first paint by at most one
round-trip (shown as skeleton); an empty workspace skips the gate so the
empty state is never blocked. Scope is the Agents page only.

Adds agents-page.test.tsx covering the five gate scenarios.

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 12:15:05 +08:00
committed by GitHub
parent ac62f72c2a
commit dc0c8afb98
2 changed files with 355 additions and 4 deletions

View File

@@ -0,0 +1,326 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import type { Agent } from "@multica/core/types";
import type { AgentActivity } from "@multica/core/agents";
import { renderWithI18n } from "../../test/i18n";
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
import { AgentsPage } from "./agents-page";
// These tests pin the `listReady` render gate (MUL-4511): the Agents list must
// not paint real rows until the auxiliary queries the active sort field /
// filter depends on have landed, or it sorts on placeholder values
// (lastActiveDays null→Infinity, runCount 0) and visibly re-orders when each
// query resolves. The gate waits per need — nothing for name/created,
// run-counts for runs, activity + run-counts for the default lastActive,
// presence when an availability filter is active — and never blocks the empty
// state on those queries.
const mocks = vi.hoisted(() => ({
agents: [] as Agent[],
agentsLoading: false,
runCounts: [] as Array<{ agent_id: string; run_count: number }>,
runCountsPending: false,
activity: {
byAgent: new Map<string, AgentActivity>(),
loading: false,
},
presence: {
byAgent: new Map<string, unknown>(),
loading: false,
},
viewState: {
scope: "all",
sortField: "lastActive" as string,
sortDirection: "desc" as string,
hiddenColumns: ["model", "created"] as string[],
filters: {
availability: [] as string[],
runtimes: [] as string[],
owners: [] as string[],
models: [] as string[],
},
setScope: vi.fn(),
toggleSort: vi.fn(),
setSortField: vi.fn(),
setSortDirection: vi.fn(),
toggleColumn: vi.fn(),
toggleFilter: vi.fn(),
clearFilters: vi.fn(),
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { queryKey?: readonly unknown[] }) => {
const key = options.queryKey?.[0];
if (key === "agents") {
return {
data: mocks.agents,
isLoading: mocks.agentsLoading,
error: null,
refetch: vi.fn(),
};
}
if (key === "agent-run-counts") {
return { data: mocks.runCounts, isPending: mocks.runCountsPending };
}
return { data: [], isLoading: false, isPending: false };
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
// The list virtualizes; render every row so DOM order reflects sort order.
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 64,
end: (index + 1) * 64,
size: 64,
})),
getTotalSize: () => count * 64,
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@multica/core/agents", () => ({
agentRunCounts30dOptions: () => ({ queryKey: ["agent-run-counts"] }),
useWorkspaceActivityMap: () => mocks.activity,
useWorkspacePresenceMap: () => mocks.presence,
VISIBILITY_TOOLTIP: { private: "Private", workspace: "Workspace" },
}));
vi.mock("@multica/core/agents/stores", () => ({
useAgentsViewStore: (selector: (state: unknown) => unknown) =>
selector(mocks.viewState),
AGENT_DEFAULT_HIDDEN_COLUMNS: ["model", "created"],
AGENT_SCOPES: ["mine", "all", "archived"],
}));
vi.mock("@multica/core/api", () => ({
api: { archiveAgent: vi.fn(), restoreAgent: vi.fn() },
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (selector: (state: unknown) => unknown) =>
selector({ user: { id: "user-1" } }),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "workspace-1",
}));
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
newAgent: () => "/test-workspace/agents/new",
agentDetail: (id: string) => `/test-workspace/agents/${id}`,
}),
}));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: () => ({ queryKey: ["agents"] }),
memberListOptions: () => ({ queryKey: ["members"] }),
workspaceKeys: { agents: (wsId: string) => ["agents", wsId] },
}));
vi.mock("@multica/core/runtimes", () => ({
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
}));
// View-layer children with heavy / portal deps — stub to keep the test focused
// on the gate, not on avatars, row menus, the toolbar, or tooltip portals.
vi.mock("../../common/actor-avatar", () => ({ ActorAvatar: () => null }));
vi.mock("./agent-row-actions", () => ({ AgentRowActions: () => null }));
vi.mock("./agent-list-toolbar", () => ({
AgentListToolbar: () => <div data-testid="agent-list-toolbar" />,
countActiveFilterDimensions: () => 0,
}));
vi.mock("../presence", () => ({ availabilityConfig: {} }));
vi.mock("@multica/ui/components/ui/skeleton", () => ({
Skeleton: (props: Record<string, unknown>) => (
<div data-testid="skeleton" {...props} />
),
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div role="tooltip">{children}</div>
),
}));
const BASE_AGENT: Agent = {
id: "agent-base",
workspace_id: "workspace-1",
runtime_id: "runtime-1",
name: "Base Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "cloud",
runtime_config: {},
custom_args: [],
visibility: "workspace",
permission_mode: "private",
invocation_targets: [],
status: "idle",
max_concurrent_tasks: 1,
model: "claude",
owner_id: "user-1",
skills: [],
created_at: "2026-06-01T00:00:00Z",
updated_at: "2026-06-01T00:00:00Z",
archived_at: null,
archived_by: null,
};
function makeAgent(over: Partial<Agent>): Agent {
return { ...BASE_AGENT, ...over };
}
// Build a 30-bucket activity series whose most-recent bucket with runs is
// `daysAgo` days back — `lastActiveDaysAgo` reads exactly this.
function activityLastActive(daysAgo: number): AgentActivity {
const buckets = Array.from({ length: 30 }, () => ({ total: 0, failed: 0 }));
buckets[29 - daysAgo] = { total: 1, failed: 0 };
return { buckets, daysSinceCreated: 30 };
}
const ALPHA = makeAgent({ id: "a-alpha", name: "Alpha Agent" });
const BETA = makeAgent({ id: "a-beta", name: "Beta Agent" });
function makeAdapter(
overrides: Partial<NavigationAdapter> = {},
): NavigationAdapter {
return {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/test-workspace/agents",
searchParams: new URLSearchParams(),
getShareableUrl: (p) => p,
...overrides,
};
}
function renderPage() {
renderWithI18n(
<NavigationProvider value={makeAdapter()}>
<AgentsPage />
</NavigationProvider>,
);
}
/** Beta before Alpha in document order? */
function betaPrecedesAlpha(): boolean {
const alpha = screen.getByText("Alpha Agent");
const beta = screen.getByText("Beta Agent");
return Boolean(
beta.compareDocumentPosition(alpha) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
beforeEach(() => {
mocks.agents = [ALPHA, BETA];
mocks.agentsLoading = false;
mocks.runCounts = [];
mocks.runCountsPending = false;
mocks.activity = { byAgent: new Map(), loading: false };
mocks.presence = { byAgent: new Map(), loading: false };
mocks.viewState.scope = "all";
mocks.viewState.sortField = "lastActive";
mocks.viewState.sortDirection = "desc";
mocks.viewState.hiddenColumns = ["model", "created"];
mocks.viewState.filters = {
availability: [],
runtimes: [],
owners: [],
models: [],
};
});
describe("AgentsPage listReady gate", () => {
it("shows only a skeleton (no real rows) while lastActive deps are pending", () => {
// Default lastActive sort depends on activity + run-counts.
mocks.activity = { byAgent: new Map(), loading: true };
mocks.runCountsPending = true;
renderPage();
expect(screen.queryByText("Alpha Agent")).not.toBeInTheDocument();
expect(screen.queryByText("Beta Agent")).not.toBeInTheDocument();
expect(screen.getAllByTestId("skeleton").length).toBeGreaterThan(0);
});
it("renders rows in the resolved lastActive order once deps land", () => {
// Alpha active 5d ago, Beta active today → lastActive desc puts Beta first,
// the opposite of the name-order fallback the ungated list would show.
mocks.activity = {
byAgent: new Map<string, AgentActivity>([
[ALPHA.id, activityLastActive(5)],
[BETA.id, activityLastActive(0)],
]),
loading: false,
};
mocks.runCounts = [
{ agent_id: ALPHA.id, run_count: 0 },
{ agent_id: BETA.id, run_count: 0 },
];
mocks.runCountsPending = false;
renderPage();
expect(screen.getByText("Alpha Agent")).toBeInTheDocument();
expect(screen.getByText("Beta Agent")).toBeInTheDocument();
expect(betaPrecedesAlpha()).toBe(true);
});
it("renders rows immediately for name sort without waiting on activity/run-counts", () => {
mocks.viewState.sortField = "name";
mocks.viewState.sortDirection = "asc";
// Auxiliary queries are still in flight — name sort must not wait on them.
mocks.activity = { byAgent: new Map(), loading: true };
mocks.runCountsPending = true;
mocks.presence = { byAgent: new Map(), loading: true };
renderPage();
expect(screen.getByText("Alpha Agent")).toBeInTheDocument();
expect(screen.getByText("Beta Agent")).toBeInTheDocument();
// name asc → Alpha before Beta.
expect(betaPrecedesAlpha()).toBe(false);
});
it("shows a skeleton (not a false empty/false result) while an availability filter waits on presence", () => {
// Availability filter needs presence; sort by name so ONLY presence gates.
// Ungated, presence-null rows would all be filtered out → a false "no
// matches" state. Gated, we hold on a skeleton instead.
mocks.viewState.sortField = "name";
mocks.viewState.filters.availability = ["online"];
mocks.presence = { byAgent: new Map(), loading: true };
renderPage();
expect(screen.queryByText("Alpha Agent")).not.toBeInTheDocument();
expect(screen.queryByText("Beta Agent")).not.toBeInTheDocument();
expect(screen.getAllByTestId("skeleton").length).toBeGreaterThan(0);
});
it("shows the empty state without blocking on auxiliary queries when there are no agents", () => {
mocks.agents = [];
// All auxiliary queries pending — the empty state must not wait on them.
mocks.activity = { byAgent: new Map(), loading: true };
mocks.runCountsPending = true;
mocks.presence = { byAgent: new Map(), loading: true };
renderPage();
expect(screen.getByText("No agents yet")).toBeInTheDocument();
expect(screen.queryByTestId("skeleton")).not.toBeInTheDocument();
});
});

View File

@@ -803,9 +803,13 @@ export function AgentsPage(_props: AgentsPageProps = {}) {
runtimeListOptions(wsId),
);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: runCountsRaw = [] } = useQuery(agentRunCounts30dOptions(wsId));
const { byAgent: presenceMap } = useWorkspacePresenceMap(wsId);
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
const { data: runCountsRaw = [], isPending: runCountsPending } = useQuery(
agentRunCounts30dOptions(wsId),
);
const { byAgent: presenceMap, loading: presenceLoading } =
useWorkspacePresenceMap(wsId);
const { byAgent: activityMap, loading: activityLoading } =
useWorkspaceActivityMap(wsId);
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
new Set(),
@@ -1049,6 +1053,27 @@ export function AgentsPage(_props: AgentsPageProps = {}) {
const totalCount = agents.filter((a) => !a.archived_at).length;
const showEmpty = !isLoading && agents.length === 0;
// The active sort field / availability filter reads columns that arrive in
// separate queries from the main agent list (activity → lastActiveDays,
// run-counts → runCount, presence → availability). Rendering real rows
// before those land would sort on placeholder values (lastActiveDays
// null→Infinity, runCount 0) and visibly re-order the list once each query
// resolves. Gate the first real paint on exactly the auxiliary queries the
// current sort / filter depends on — nothing for name/created, run-counts
// for runs, activity + run-counts (its tiebreaker) for the default
// lastActive, plus presence whenever an availability filter is active. The
// queries still run in parallel, so this defers the first paint by at most
// one extra round-trip (shown as skeleton) and never serialises them. An
// empty workspace (showEmpty) skips the gate so the empty state is never
// blocked on the auxiliary queries.
const needsRunCounts = sortField === "lastActive" || sortField === "runs";
const needsActivity = sortField === "lastActive";
const needsPresence = filters.availability.length > 0;
const listReady =
(!needsActivity || !activityLoading) &&
(!needsRunCounts || !runCountsPending) &&
(!needsPresence || !presenceLoading);
return (
// relative: positioning anchor for the batch toolbar (page-centered,
// not viewport-centered).
@@ -1058,7 +1083,7 @@ export function AgentsPage(_props: AgentsPageProps = {}) {
onCreate={() => navigation.push(paths.newAgent())}
/>
{isLoading ? (
{isLoading || (!showEmpty && !listReady) ? (
<div className="flex-1 overflow-y-auto @container">
<LoadingSkeleton />
</div>