fix(ui): loading skeletons for dashboard/spoons/threads

This commit is contained in:
Gabriel Brown
2026-07-11 16:50:24 -04:00
parent 8d7486bc8c
commit 8c579d060a
5 changed files with 213 additions and 61 deletions
+79 -45
View File
@@ -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 = () => {
</Button>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard
label='Spoons'
value={spoons.length}
note={`${activeSpoons} active`}
icon={GitBranch}
/>
<MetricCard
label='Behind upstream'
value={behind}
note={`${diverged} diverged`}
icon={RefreshCw}
/>
<MetricCard
label='Open threads'
value={
threads.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
).length
}
note='Across all Spoons'
icon={MessageSquare}
/>
<MetricCard
label='Upstream commits'
value={openPullRequests}
note='Actionable after ignores'
icon={ShieldCheck}
/>
</div>
{metricsLoading ? (
<div
role='status'
aria-label='Loading'
className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'
>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className='h-32 w-full' />
))}
<span className='sr-only'>Loading</span>
</div>
) : (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard
label='Spoons'
value={spoonList.length}
note={`${activeSpoons} active`}
icon={GitBranch}
/>
<MetricCard
label='Behind upstream'
value={behind}
note={`${diverged} diverged`}
icon={RefreshCw}
/>
<MetricCard
label='Open threads'
value={
threadList.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
).length
}
note='Across all Spoons'
icon={MessageSquare}
/>
<MetricCard
label='Upstream commits'
value={openPullRequests}
note='Actionable after ignores'
icon={ShieldCheck}
/>
</div>
)}
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
<MaintenanceQueue threads={threads} />
{threads === undefined ? (
<ListSkeleton rows={2} />
) : (
<MaintenanceQueue threads={threads} />
)}
</section>
<div className='grid gap-6 xl:grid-cols-2'>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
{spoons.length ? (
{spoons === undefined ? (
<ListSkeleton rows={3} />
) : spoons.length ? (
spoons
.slice(0, 3)
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
@@ -108,7 +138,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Upstream checks</CardTitle>
</CardHeader>
<CardContent>
{syncRuns.length ? (
{syncRuns === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : syncRuns.length ? (
<div className='space-y-3'>
{syncRuns.map((run) => (
<div
@@ -137,7 +169,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader>
<CardContent>
{threads.length ? (
{threads === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : threads.length ? (
<div className='space-y-3'>
{threads.slice(0, 5).map((thread) => (
<div
+30 -10
View File
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useQuery } from 'convex/react';
import {
ArrowUpRight,
@@ -17,6 +18,7 @@ import {
Button,
Card,
CardContent,
Skeleton,
Table,
TableBody,
TableCell,
@@ -32,15 +34,19 @@ const formatDate = (value?: number) =>
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 = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Managed</p>
<p className='text-2xl font-semibold'>{spoons.length}</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{spoonList.length}</p>
)}
</div>
<GitBranch className='text-muted-foreground size-5' />
</CardContent>
@@ -74,7 +84,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Active</p>
<p className='text-2xl font-semibold'>{active}</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{active}</p>
)}
</div>
<RefreshCw className='text-muted-foreground size-5' />
</CardContent>
@@ -83,14 +97,20 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Open threads</p>
<p className='text-2xl font-semibold'>{needsReview}</p>
{metricsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{needsReview}</p>
)}
</div>
<MessageSquare className='text-muted-foreground size-5' />
</CardContent>
</Card>
</div>
{spoons.length ? (
{spoons === undefined ? (
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
) : spoons.length ? (
<Card className='shadow-none'>
<CardContent className='p-0'>
<Table>
@@ -201,7 +221,7 @@ const SpoonsPage = () => {
</Card>
)}
{spoons.length ? (
{spoonList.length ? (
<p className='text-muted-foreground text-sm'>
Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting}
+9 -6
View File
@@ -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 = () => {
</div>
<div className='space-y-3'>
{visibleThreads.length ? (
{threadsLoading ? (
<ListSkeleton rows={4} />
) : visibleThreads.length ? (
visibleThreads.map((thread) => (
<Card
key={thread._id}
@@ -0,0 +1,23 @@
import { cn, Skeleton } from '@spoon/ui';
export const ListSkeleton = ({
rows = 3,
rowClassName = 'h-20 w-full',
className,
}: {
rows?: number;
rowClassName?: string;
className?: string;
}) => (
<div
role='status'
aria-label='Loading'
data-testid='list-skeleton'
className={cn('space-y-3', className)}
>
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className={rowClassName} />
))}
<span className='sr-only'>Loading</span>
</div>
);