From 56f453fed66e21d7e6c09f4e26d892b37ae5c4b7 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:15:31 +0800 Subject: [PATCH] feat(agents): add loading skeleton for Recent work list (#5438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: multica-agent --- .../tabs/activity-tab.render.test.tsx | 104 ++++++++++++++++++ .../agents/components/tabs/activity-tab.tsx | 54 ++++++++- 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 packages/views/agents/components/tabs/activity-tab.render.test.tsx diff --git a/packages/views/agents/components/tabs/activity-tab.render.test.tsx b/packages/views/agents/components/tabs/activity-tab.render.test.tsx new file mode 100644 index 0000000000..f32ff3edf3 --- /dev/null +++ b/packages/views/agents/components/tabs/activity-tab.render.test.tsx @@ -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(() => {}), +})); +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( + + + + + + + , + ); +} + +beforeEach(() => { + agentTasksRef.current = () => new Promise(() => {}); +}); + +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); + }); +}); diff --git a/packages/views/agents/components/tabs/activity-tab.tsx b/packages/views/agents/components/tabs/activity-tab.tsx index 61c6838a40..a670782739 100644 --- a/packages/views/agents/components/tabs/activity-tab.tsx +++ b/packages/views/agents/components/tabs/activity-tab.tsx @@ -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; 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 (
$.tab_body.activity.section_recent)} subtitle={subtitle}> - {tasks.length === 0 ? ( + {loading ? ( + + ) : tasks.length === 0 ? ( {t(($) => $.tab_body.activity.empty_recent)} ) : ( <> @@ -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 ( + + ); +} + function TaskList({ tasks, issueMap,