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,