diff --git a/apps/next/src/app/(app)/dashboard/page.tsx b/apps/next/src/app/(app)/dashboard/page.tsx
index 1edaf22..73baa58 100644
--- a/apps/next/src/app/(app)/dashboard/page.tsx
+++ b/apps/next/src/app/(app)/dashboard/page.tsx
@@ -3,27 +3,38 @@
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
+import { ListSkeleton } from '@/components/ui/list-skeleton';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react';
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
-import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Skeleton,
+} from '@spoon/ui';
const DashboardPage = () => {
- const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
- const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
- const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
- const activeSpoons = spoons.filter(
+ const spoons = useQuery(api.spoons.listMineWithState, {});
+ const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
+ const threads = useQuery(api.threads.listMine, { limit: 25 });
+ const spoonList = spoons ?? [];
+ const threadList = threads ?? [];
+ const metricsLoading = spoons === undefined || threads === undefined;
+ const activeSpoons = spoonList.filter(
(spoon) => spoon.status === 'active',
).length;
- const behind = spoons.filter(
+ const behind = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
).length;
- const diverged = spoons.filter(
+ const diverged = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length;
- const openPullRequests = spoons.reduce(
+ const openPullRequests = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -43,49 +54,68 @@ const DashboardPage = () => {
-
-
-
-
- !['resolved', 'ignored', 'failed', 'cancelled'].includes(
- thread.status,
- ),
- ).length
- }
- note='Across all Spoons'
- icon={MessageSquare}
- />
-
-
+ {metricsLoading ? (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+ Loading
+
+ ) : (
+
+
+
+
+ !['resolved', 'ignored', 'failed', 'cancelled'].includes(
+ thread.status,
+ ),
+ ).length
+ }
+ note='Across all Spoons'
+ icon={MessageSquare}
+ />
+
+
+ )}
Maintenance queue
-
+ {threads === undefined ? (
+
+ ) : (
+
+ )}
Recent Spoons
- {spoons.length ? (
+ {spoons === undefined ? (
+
+ ) : spoons.length ? (
spoons
.slice(0, 3)
.map((spoon) => )
@@ -108,7 +138,9 @@ const DashboardPage = () => {
Upstream checks
- {syncRuns.length ? (
+ {syncRuns === undefined ? (
+
+ ) : syncRuns.length ? (
{syncRuns.map((run) => (
{
Recent threads
- {threads.length ? (
+ {threads === undefined ? (
+
+ ) : threads.length ? (
{threads.slice(0, 5).map((thread) => (
const SpoonsPage = () => {
const router = useRouter();
- const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
- const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
- const active = spoons.filter((spoon) => spoon.status === 'active').length;
- const needsReview = threads.filter(
+ const spoons = useQuery(api.spoons.listMineWithState, {});
+ const threads = useQuery(api.threads.listMine, { limit: 100 });
+ const spoonList = spoons ?? [];
+ const threadList = threads ?? [];
+ const spoonsLoading = spoons === undefined;
+ const metricsLoading = spoons === undefined || threads === undefined;
+ const active = spoonList.filter((spoon) => spoon.status === 'active').length;
+ const needsReview = threadList.filter(
(thread) =>
thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
- const upstreamWaiting = spoons.reduce(
+ const upstreamWaiting = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -65,7 +71,11 @@ const SpoonsPage = () => {
Managed
-
{spoons.length}
+ {spoonsLoading ? (
+
+ ) : (
+
{spoonList.length}
+ )}
@@ -74,7 +84,11 @@ const SpoonsPage = () => {
Active
-
{active}
+ {spoonsLoading ? (
+
+ ) : (
+
{active}
+ )}
@@ -83,14 +97,20 @@ const SpoonsPage = () => {
Open threads
-
{needsReview}
+ {metricsLoading ? (
+
+ ) : (
+
{needsReview}
+ )}
- {spoons.length ? (
+ {spoons === undefined ? (
+
+ ) : spoons.length ? (
@@ -201,7 +221,7 @@ const SpoonsPage = () => {
)}
- {spoons.length ? (
+ {spoonList.length ? (
Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting}
diff --git a/apps/next/src/app/(app)/threads/page.tsx b/apps/next/src/app/(app)/threads/page.tsx
index a11127c..cacc1b5 100644
--- a/apps/next/src/app/(app)/threads/page.tsx
+++ b/apps/next/src/app/(app)/threads/page.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
+import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner';
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
- const threads =
- useQuery(api.threads.listMine, {
+ const threads = useQuery(api.threads.listMine, {
source: source as
| 'all'
| 'user_request'
@@ -69,9 +69,10 @@ const ThreadsPage = () => {
| 'ignored'
| 'failed'
| 'cancelled',
- limit: 100,
- }) ?? [];
- const visibleThreads = threads.filter((thread) => {
+ limit: 100,
+ });
+ const threadsLoading = threads === undefined;
+ const visibleThreads = (threads ?? []).filter((thread) => {
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
return false;
@@ -312,7 +313,9 @@ const ThreadsPage = () => {
- {visibleThreads.length ? (
+ {threadsLoading ? (
+
+ ) : visibleThreads.length ? (
visibleThreads.map((thread) => (
(
+
+ {Array.from({ length: rows }).map((_, index) => (
+
+ ))}
+ Loading
+
+);
diff --git a/apps/next/tests/component/dashboard-loading.test.tsx b/apps/next/tests/component/dashboard-loading.test.tsx
new file mode 100644
index 0000000..690e0c3
--- /dev/null
+++ b/apps/next/tests/component/dashboard-loading.test.tsx
@@ -0,0 +1,72 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import DashboardPage from '../../src/app/(app)/dashboard/page';
+
+const { mockUseQuery } = vi.hoisted(() => ({
+ mockUseQuery: vi.fn(),
+}));
+
+vi.mock('convex/react', () => ({
+ useConvexAuth: () => ({ isAuthenticated: false }),
+ useMutation: vi.fn(),
+ useQuery: mockUseQuery,
+}));
+
+vi.mock('next/navigation', () => ({
+ useParams: vi.fn(),
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+describe('dashboard loading vs empty state', () => {
+ afterEach(() => {
+ cleanup();
+ mockUseQuery.mockReset();
+ });
+
+ it('shows skeletons while queries are loading (undefined)', () => {
+ mockUseQuery.mockReturnValue(undefined);
+
+ render();
+
+ expect(screen.getAllByTestId('list-skeleton').length).toBeGreaterThan(0);
+ expect(screen.queryByText('No Spoons yet')).not.toBeInTheDocument();
+ });
+
+ it('shows empty states once loaded with no data ([])', () => {
+ mockUseQuery.mockReturnValue([]);
+
+ render();
+
+ expect(screen.queryByTestId('list-skeleton')).not.toBeInTheDocument();
+ expect(screen.getByText('No Spoons yet')).toBeInTheDocument();
+ });
+
+ it('renders data once loaded with items', () => {
+ mockUseQuery.mockImplementation((_query, args) => {
+ // spoons list has an empty args object ({}); recent syncRuns/threads pass limits.
+ if (args && 'limit' in args) return [];
+ return [
+ {
+ _id: 'spoon-1',
+ name: 'useSend',
+ upstreamOwner: 'acme',
+ upstreamRepo: 'app',
+ status: 'active',
+ syncCadence: 'daily',
+ effectiveUpstreamAheadBy: 0,
+ rawUpstreamAheadBy: 0,
+ forkAheadBy: 0,
+ ignoredUpstreamCount: 0,
+ },
+ ];
+ });
+
+ render();
+
+ expect(screen.getByText('useSend')).toBeInTheDocument();
+ expect(screen.queryByTestId('list-skeleton')).not.toBeInTheDocument();
+ expect(screen.queryByText('No Spoons yet')).not.toBeInTheDocument();
+ });
+});