History

Replace loading text with skeletons in cloud shell (#239)

Renders a full shell skeleton (sidebar + main) while auth and scope are loading, and section-level skeletons for the sidebar source list, sources page, and source detail views. ScopeProvider now accepts a fallback so the skeleton stays visible through scope loading instead of flashing a blank screen.

Rhys Sullivan committed 7 months ago

Commit 0e680c11 parent c9aa58b6 files · +169 −18Browse files

Unified · Split

6 files
  1. apps/cloud/src/routes/__root.tsx +64 −6
  2. apps/cloud/src/web/shell.tsx +9 −1
  3. packages/react/src/api/provider.tsx +4 −2
  4. packages/react/src/api/scope-context.tsx +5 −4
  5. packages/react/src/pages/source-detail.tsx +62 −4
  6. packages/react/src/pages/sources.tsx +25 −1
apps/cloud/src/routes/__root.tsx+64−6
@@ -3,6 +3,7 @@ import * as Sentry from "@sentry/react"; · 2 unmodified lines
33 import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
44 import { AutumnProvider } from "autumn-js/react";
55 import { ExecutorProvider } from "@executor/react/api/provider";
6+import { Skeleton } from "@executor/react/components/skeleton";
67 import { Toaster } from "@executor/react/components/sonner";
78 import { AuthProvider, useAuth } from "../web/auth";
89 import { LoginPage } from "../web/pages/login";
@@ -68,15 +69,72 @@ function RootComponent() { · 59 unmodified lines
6869 );
6970 }
7071
72+function ShellSkeleton() {
73+ return (
74+ <div className="flex h-screen overflow-hidden">
75+ {/* Desktop sidebar skeleton */}
76+ <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
77+ <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
78+ <Skeleton className="h-4 w-20" />
79+ </div>
80+ <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
81+ <Skeleton className="h-7 w-full rounded-md" />
82+ <Skeleton className="h-7 w-full rounded-md" />
83+ <Skeleton className="h-7 w-full rounded-md" />
84+ <Skeleton className="h-7 w-full rounded-md" />
85+ <div className="mt-5 mb-2 px-2.5">
86+ <Skeleton className="h-3 w-14" />
87+ </div>
88+ <div className="flex flex-col gap-1">
89+ <Skeleton className="h-7 w-11/12 rounded-md" />
90+ <Skeleton className="h-7 w-10/12 rounded-md" />
91+ <Skeleton className="h-7 w-9/12 rounded-md" />
92+ </div>
93+ </nav>
94+ <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
95+ <div className="flex items-center gap-2.5">
96+ <Skeleton className="size-7 rounded-full" />
97+ <div className="flex min-w-0 flex-1 flex-col gap-1">
98+ <Skeleton className="h-3 w-24" />
99+ <Skeleton className="h-3 w-16" />
100+ </div>
101+ </div>
102+ </div>
103+ </aside>
104+
105+ {/* Main content skeleton */}
106+ <main className="flex min-h-0 flex-1 flex-col overflow-hidden">
107+ {/* Mobile top bar */}
108+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
109+ <Skeleton className="size-7 rounded-md" />
110+ <Skeleton className="h-4 w-20" />
111+ <div className="w-7 shrink-0" />
112+ </div>
113+
114+ <div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
115+ <div className="flex items-center justify-between">
116+ <div className="flex flex-col gap-2">
117+ <Skeleton className="h-6 w-40" />
118+ <Skeleton className="h-4 w-64" />
119+ </div>
120+ <Skeleton className="h-8 w-28 rounded-md" />
121+ </div>
122+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
123+ {Array.from({ length: 6 }).map((_, i) => (
124+ <Skeleton key={i} className="h-24 w-full rounded-lg" />
125+ ))}
126+ </div>
127+ </div>
128+ </main>
129+ </div>
130+ );
131+}
132+
71133 function AuthGate() {
72134 const auth = useAuth();
73135
74136 if (auth.status === "loading") {
75− return (
76− <div className="flex min-h-screen items-center justify-center bg-background">
77− <p className="text-sm text-muted-foreground">Loading...</p>
78− </div>
79− );
137+ return <ShellSkeleton />;
80138 }
81139
82140 if (auth.status === "unauthenticated") {
@@ -89,7 +147,7 @@ function AuthGate() { · 6 unmodified lines
89147
90148 return (
91149 <AutumnProvider pathPrefix="/api/autumn">
92− <ExecutorProvider>
150+ <ExecutorProvider fallback={<ShellSkeleton />}>
93151 <Shell />
94152 <Toaster />
95153 </ExecutorProvider>
apps/cloud/src/web/shell.tsx+9−1
@@ -4,6 +4,7 @@ import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; · 3 unmodified lines
44 import { sourcesAtom } from "@executor/react/api/atoms";
55 import { useScope } from "@executor/react/api/scope-context";
66 import { Button } from "@executor/react/components/button";
7+import { Skeleton } from "@executor/react/components/skeleton";
78 import {
89 Dialog,
910 DialogClose,
@@ -71,7 +72,14 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 61 unmodified lines
7172
7273 return Result.match(sources, {
7374 onInitial: () => (
74− <div className="px-2.5 py-2 text-xs text-muted-foreground">Loading…</div>
75+ <div className="flex flex-col gap-1 px-2.5 py-1">
76+ {[80, 65, 72, 58, 68].map((w, i) => (
77+ <div key={i} className="flex items-center gap-2 rounded-md py-1.5">
78+ <Skeleton className="size-3.5 shrink-0 rounded" />
79+ <Skeleton className="h-3" style={{ width: `${w}%` }} />
80+ </div>
81+ ))}
82+ </div>
7583 ),
7684 onFailure: () => (
7785 <div className="px-2.5 py-2 text-xs text-muted-foreground">No sources yet</div>
packages/react/src/api/provider.tsx+4−2
@@ -2,8 +2,10 @@ import { RegistryProvider } from "@effect-atom/atom-react"; · 1 unmodified line
22 import * as React from "react";
33 import { ScopeProvider } from "./scope-context";
44
5−export const ExecutorProvider = (props: React.PropsWithChildren) => (
5+export const ExecutorProvider = (
6+ props: React.PropsWithChildren<{ fallback?: React.ReactNode }>,
7+) => (
68 <RegistryProvider>
7− <ScopeProvider>{props.children}</ScopeProvider>
9+ <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider>
810 </RegistryProvider>
911 );
packages/react/src/api/scope-context.tsx+5−4
@@ -14,17 +14,18 @@ const ScopeContext = React.createContext<ScopeInfo | null>(null); · 13 unmodified lines
1414
1515 /**
1616 * Provides the server scope to all children.
17− * Renders nothing until the scope is fetched.
17+ * Renders the optional `fallback` until the scope is fetched.
1818 */
19−export function ScopeProvider(props: React.PropsWithChildren) {
19+export function ScopeProvider(
20+ props: React.PropsWithChildren<{ fallback?: React.ReactNode }>,
21+) {
2022 const result = useAtomValue(scopeAtom);
2123
2224 if (Result.isSuccess(result)) {
2325 return <ScopeContext.Provider value={result.value}>{props.children}</ScopeContext.Provider>;
2426 }
2527
26− // Loading or error — don't render children
27− return null;
28+ return <>{props.fallback ?? null}</>;
2829 }
2930
3031 /**
packages/react/src/pages/source-detail.tsx+62−4
@@ -15,6 +15,7 @@ import { useScope } from "../hooks/use-scope"; · 14 unmodified lines
1515 import type { SourcePlugin } from "../plugins/source-plugin";
1616 import { Button } from "../components/button";
1717 import { Badge } from "../components/badge";
18+import { Skeleton } from "../components/skeleton";
1819
1920 export function SourceDetailPage(props: {
2021 namespace: string;
@@ -190,9 +191,7 @@ export function SourceDetailPage(props: { · 169 unmodified lines
190191 {editing && editPlugin ? (
191192 <div className="min-h-0 flex-1 overflow-y-auto">
192193 <div className="mx-auto max-w-2xl px-6 py-8">
193− <Suspense
194− fallback={<div className="p-6 text-sm text-muted-foreground">Loading...</div>}
195− >
194+ <Suspense fallback={<EditFormSkeleton />}>
196195 <editPlugin.edit sourceId={namespace} onSave={handleEditSave} />
197196 </Suspense>
198197 </div>
@@ -200,7 +199,7 @@ export function SourceDetailPage(props: { · 1 unmodified line
200199 ) : (
201200 /* Content -- split pane */
202201 Result.match(tools, {
203− onInitial: () => <div className="p-6 text-sm text-muted-foreground">Loading...</div>,
202+ onInitial: () => <SourceDetailSkeleton />,
204203 onFailure: () => <div className="p-6 text-sm text-destructive">Failed to load tools</div>,
205204 onSuccess: () => (
206205 <div className="flex min-h-0 flex-1 overflow-hidden">
@@ -233,3 +232,62 @@ export function SourceDetailPage(props: { · 26 unmodified lines
233232 </div>
234233 );
235234 }
235+
236+function SourceDetailSkeleton() {
237+ return (
238+ <div className="flex min-h-0 flex-1 overflow-hidden">
239+ {/* Left: tool tree skeleton */}
240+ <div className="flex w-72 shrink-0 flex-col gap-1 border-r border-border/60 p-3 lg:w-80 xl:w-[22rem]">
241+ <Skeleton className="mb-2 h-8 w-full rounded-md" />
242+ {Array.from({ length: 8 }).map((_, i) => (
243+ <div key={i} className="flex items-center gap-2 rounded-md px-2 py-1.5">
244+ <Skeleton className="size-4 shrink-0 rounded" />
245+ <Skeleton
246+ className="h-3.5"
247+ style={{ width: `${55 + ((i * 13) % 35)}%` }}
248+ />
249+ </div>
250+ ))}
251+ </div>
252+
253+ {/* Right: tool detail skeleton */}
254+ <div className="flex min-w-0 flex-1 flex-col gap-6 overflow-hidden p-6">
255+ <div className="flex flex-col gap-2">
256+ <Skeleton className="h-5 w-48" />
257+ <Skeleton className="h-4 w-80" />
258+ <Skeleton className="h-4 w-64" />
259+ </div>
260+ <div className="flex flex-col gap-3">
261+ <Skeleton className="h-4 w-24" />
262+ <Skeleton className="h-20 w-full rounded-md" />
263+ <Skeleton className="h-4 w-24" />
264+ <Skeleton className="h-20 w-full rounded-md" />
265+ </div>
266+ <Skeleton className="h-9 w-32 rounded-md" />
267+ </div>
268+ </div>
269+ );
270+}
271+
272+function EditFormSkeleton() {
273+ return (
274+ <div className="flex flex-col gap-5 p-6">
275+ <div className="flex flex-col gap-2">
276+ <Skeleton className="h-4 w-20" />
277+ <Skeleton className="h-9 w-full rounded-md" />
278+ </div>
279+ <div className="flex flex-col gap-2">
280+ <Skeleton className="h-4 w-24" />
281+ <Skeleton className="h-9 w-full rounded-md" />
282+ </div>
283+ <div className="flex flex-col gap-2">
284+ <Skeleton className="h-4 w-28" />
285+ <Skeleton className="h-24 w-full rounded-md" />
286+ </div>
287+ <div className="flex justify-end gap-2 pt-2">
288+ <Skeleton className="h-9 w-20 rounded-md" />
289+ <Skeleton className="h-9 w-24 rounded-md" />
290+ </div>
291+ </div>
292+ );
293+}
packages/react/src/pages/sources.tsx+25−1
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 CardStackEntryActions,
2222 } from "../components/card-stack";
2323 import { SourceFavicon } from "../components/source-favicon";
24+import { Skeleton } from "../components/skeleton";
2425
2526 const KIND_TO_PLUGIN_KEY: Record<string, string> = {
2627 openapi: "openapi",
@@ -144,7 +145,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 117 unmodified lines
144145 </div>
145146
146147 {Result.match(sources, {
147− onInitial: () => <p className="text-sm text-muted-foreground">Loading…</p>,
148+ onInitial: () => <SourcesGridSkeleton />,
148149 onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>,
149150 onSuccess: ({ value }) => {
150151 const connectedSources = value.filter((source) => !source.runtime);
@@ -297,3 +298,26 @@ function SourceGrid(props: { · 146 unmodified lines
297298 </CardStack>
298299 );
299300 }
301+
302+// ---------------------------------------------------------------------------
303+// Loading skeleton
304+// ---------------------------------------------------------------------------
305+
306+function SourcesGridSkeleton() {
307+ return (
308+ <CardStack>
309+ <CardStackContent>
310+ {Array.from({ length: 4 }).map((_, i) => (
311+ <div key={i} className="flex items-center gap-3 px-4 py-3">
312+ <Skeleton className="size-8 shrink-0 rounded-md" />
313+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
314+ <Skeleton className="h-4" style={{ width: `${40 + ((i * 11) % 30)}%` }} />
315+ <Skeleton className="h-3" style={{ width: `${25 + ((i * 7) % 20)}%` }} />
316+ </div>
317+ <Skeleton className="h-5 w-16 rounded-full" />
318+ </div>
319+ ))}
320+ </CardStackContent>
321+ </CardStack>
322+ );
323+}