feat(agents): add loading skeleton for Recent work list (#5438)

The Recent work section on the agent Activity tab reads a lazily-loaded
per-agent task list. On first paint the query is still pending, so the
section rendered its 'nothing finished yet' empty state — a wrong answer
that flashes before real data arrives.

Render a skeleton (bordered, divided placeholder rows matching the real
TaskList shell) while the first fetch is in flight, keyed off the query's
isLoading. Once the cache is hydrated the tab opens straight into data
with no skeleton flash.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-07-15 13:15:31 +08:00
committed by GitHub
parent 07e2d378bd
commit 56f453fed6
2 changed files with 154 additions and 4 deletions

View File

@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
import {
NavigationProvider,
type NavigationAdapter,
} from "../../../navigation";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// api / paths are only reached from a rendered TaskRow, which never mounts in
// the loading and empty states under test — stub them so the module graph
// resolves without dragging in platform wiring.
vi.mock("@multica/core/api", () => ({ api: {} }));
// The tab reads three data sources. Snapshot ("Now") and the activity map
// ("Last 30 days") stay empty; the per-agent task list is the one under test,
// its queryFn swapped per test to stay pending or resolve.
const agentTasksRef = vi.hoisted(() => ({
current: () => new Promise<unknown>(() => {}),
}));
vi.mock("@multica/core/agents", () => ({
agentTaskSnapshotOptions: () => ({
queryKey: ["snapshot"],
queryFn: () => Promise.resolve([]),
}),
agentTasksOptions: () => ({
queryKey: ["agent-tasks"],
queryFn: () => agentTasksRef.current(),
}),
useWorkspaceActivityMap: () => ({ byAgent: new Map() }),
summarizeActivityWindow: () => ({
totalRuns: 0,
totalFailed: 0,
buckets: [],
}),
}));
import { ActivityTab } from "./activity-tab";
const baseAgent = {
id: "agent-1",
name: "Agent",
} as unknown as Agent;
const EMPTY_RECENT = "This agent hasn't completed anything yet.";
function renderTab() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const navigation: NavigationAdapter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/agents/agent-1",
searchParams: new URLSearchParams(),
getShareableUrl: (path) => path,
};
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<NavigationProvider value={navigation}>
<QueryClientProvider client={queryClient}>
<ActivityTab agent={baseAgent} showPerformance={false} />
</QueryClientProvider>
</NavigationProvider>
</I18nProvider>,
);
}
beforeEach(() => {
agentTasksRef.current = () => new Promise<unknown>(() => {});
});
describe("ActivityTab Recent work loading state", () => {
it("shows a skeleton, not the empty state, while the task list is loading", () => {
// Never-resolving queryFn keeps the per-agent task query pending, which is
// exactly the first-paint window the skeleton is meant to cover.
const { container } = renderTab();
expect(
container.querySelectorAll('[data-slot="skeleton"]').length,
).toBeGreaterThan(0);
expect(screen.queryByText(EMPTY_RECENT)).not.toBeInTheDocument();
});
it("shows the empty state once the task list resolves to no runs", async () => {
agentTasksRef.current = () => Promise.resolve([]);
renderTab();
expect(await screen.findByText(EMPTY_RECENT)).toBeInTheDocument();
expect(
document.querySelectorAll('[data-slot="skeleton"]').length,
).toBe(0);
});
});

View File

@@ -17,6 +17,7 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { NumberFlow } from "@multica/ui/components/ui/number-flow";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useQueries, useQuery } from "@tanstack/react-query";
import type {
Agent,
@@ -50,6 +51,10 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
// "more" is a pure state flip — zero extra fetches.
const RECENT_INITIAL = 10;
const RECENT_PAGE = 20;
// Placeholder rows shown while the lazily-loaded per-agent task list is
// still in flight, so first paint of the tab is a skeleton rather than the
// "nothing finished yet" empty state (which reads as a wrong answer).
const RECENT_SKELETON_ROWS = 4;
interface ActivityTabProps {
agent: Agent;
@@ -73,7 +78,12 @@ export function ActivityTab({ agent, showPerformance = true }: ActivityTabProps)
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { data: agentTasks = [] } = useQuery(agentTasksOptions(wsId, agent.id));
// `isLoading` (pending + fetching, no cached data) is true only on the
// very first fetch. Once the page has hydrated this cache elsewhere the
// tab opens straight into data with no skeleton flash.
const { data: agentTasks = [], isLoading: isLoadingRecent } = useQuery(
agentTasksOptions(wsId, agent.id),
);
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
const activity = activityMap.get(agent.id);
@@ -176,6 +186,7 @@ export function ActivityTab({ agent, showPerformance = true }: ActivityTabProps)
tasks={recentTasks}
totalCount={recentTasksAll.length}
hasMore={hasMoreRecent}
loading={isLoadingRecent}
onShowMore={() =>
setRecentDisplayLimit((n) => n + RECENT_PAGE)
}
@@ -382,6 +393,7 @@ function RecentWorkSection({
tasks,
totalCount,
hasMore,
loading,
onShowMore,
issueMap,
agent,
@@ -389,20 +401,26 @@ function RecentWorkSection({
tasks: AgentTask[];
totalCount: number;
hasMore: boolean;
loading: boolean;
onShowMore: () => void;
issueMap: Map<string, Issue>;
agent: Agent;
}) {
const { t } = useT("agents");
const subtitle =
tasks.length === 0
// While the first fetch is in flight we have no counts to summarise, so
// the subtitle stays blank rather than claiming "nothing finished yet".
const subtitle = loading
? ""
: tasks.length === 0
? t(($) => $.tab_body.activity.subtitle_no_recent)
: totalCount > tasks.length
? t(($) => $.tab_body.activity.subtitle_recent_progress, { shown: tasks.length, total: totalCount })
: t(($) => $.tab_body.activity.subtitle_recent_latest, { count: tasks.length });
return (
<Section title={t(($) => $.tab_body.activity.section_recent)} subtitle={subtitle}>
{tasks.length === 0 ? (
{loading ? (
<RecentWorkSkeleton />
) : tasks.length === 0 ? (
<EmptyText>{t(($) => $.tab_body.activity.empty_recent)}</EmptyText>
) : (
<>
@@ -427,6 +445,34 @@ function RecentWorkSection({
);
}
/**
* Loading placeholder for the Recent work list. Mirrors the completed-mode
* TaskList shell (bordered, divided rows) and the two-line TaskRow rhythm —
* a status glyph plus title and meta line — so the skeleton settles into the
* real rows without a layout jump. Widths are staggered per row so it reads
* as content rather than a solid block.
*/
function RecentWorkSkeleton() {
const titleWidths = ["w-3/5", "w-4/5", "w-1/2", "w-2/3"];
const metaWidths = ["w-2/5", "w-1/3", "w-2/5", "w-1/4"];
return (
<div
className="overflow-hidden rounded-lg border divide-y"
aria-hidden="true"
>
{Array.from({ length: RECENT_SKELETON_ROWS }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-3">
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className={`h-3.5 ${titleWidths[i % titleWidths.length]}`} />
<Skeleton className={`h-3 ${metaWidths[i % metaWidths.length]}`} />
</div>
</div>
))}
</div>
);
}
function TaskList({
tasks,
issueMap,