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
3import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";3import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
4import { AutumnProvider } from "autumn-js/react";4import { AutumnProvider } from "autumn-js/react";
5import { ExecutorProvider } from "@executor/react/api/provider";5import { ExecutorProvider } from "@executor/react/api/provider";
6import { Skeleton } from "@executor/react/components/skeleton";
6import { Toaster } from "@executor/react/components/sonner";7import { Toaster } from "@executor/react/components/sonner";
7import { AuthProvider, useAuth } from "../web/auth";8import { AuthProvider, useAuth } from "../web/auth";
8import { LoginPage } from "../web/pages/login";9import { LoginPage } from "../web/pages/login";
@@ -68,15 +69,72 @@ function RootComponent() { · 59 unmodified lines
68 );69 );
69}70}
70 71
72function 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
71function AuthGate() {133function AuthGate() {
72 const auth = useAuth();134 const auth = useAuth();
73 135
74 if (auth.status === "loading") {136 if (auth.status === "loading") {
75 return (137 return <ShellSkeleton />;
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 );
80 }138 }
81 139
82 if (auth.status === "unauthenticated") {140 if (auth.status === "unauthenticated") {
@@ -89,7 +147,7 @@ function AuthGate() { · 6 unmodified lines
89 147
90 return (148 return (
91 <AutumnProvider pathPrefix="/api/autumn">149 <AutumnProvider pathPrefix="/api/autumn">
92 <ExecutorProvider>150 <ExecutorProvider fallback={<ShellSkeleton />}>
93 <Shell />151 <Shell />
94 <Toaster />152 <Toaster />
95 </ExecutorProvider>153 </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
4import { sourcesAtom } from "@executor/react/api/atoms";4import { sourcesAtom } from "@executor/react/api/atoms";
5import { useScope } from "@executor/react/api/scope-context";5import { useScope } from "@executor/react/api/scope-context";
6import { Button } from "@executor/react/components/button";6import { Button } from "@executor/react/components/button";
7import { Skeleton } from "@executor/react/components/skeleton";
7import {8import {
8 Dialog,9 Dialog,
9 DialogClose,10 DialogClose,
@@ -71,7 +72,14 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 61 unmodified lines
71 72
72 return Result.match(sources, {73 return Result.match(sources, {
73 onInitial: () => (74 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>
75 ),83 ),
76 onFailure: () => (84 onFailure: () => (
77 <div className="px-2.5 py-2 text-xs text-muted-foreground">No sources yet</div>85 <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
2import * as React from "react";2import * as React from "react";
3import { ScopeProvider } from "./scope-context";3import { ScopeProvider } from "./scope-context";
4 4
5export const ExecutorProvider = (props: React.PropsWithChildren) => (5export const ExecutorProvider = (
6 props: React.PropsWithChildren<{ fallback?: React.ReactNode }>,
7) => (
6 <RegistryProvider>8 <RegistryProvider>
7 <ScopeProvider>{props.children}</ScopeProvider>9 <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider>
8 </RegistryProvider>10 </RegistryProvider>
9);11);
packages/react/src/api/scope-context.tsx+5−4
@@ -14,17 +14,18 @@ const ScopeContext = React.createContext<ScopeInfo | null>(null); · 13 unmodified lines
14 14
15/**15/**
16 * Provides the server scope to all children.16 * 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.
18 */18 */
19export function ScopeProvider(props: React.PropsWithChildren) {19export function ScopeProvider(
20 props: React.PropsWithChildren<{ fallback?: React.ReactNode }>,
21) {
20 const result = useAtomValue(scopeAtom);22 const result = useAtomValue(scopeAtom);
21 23
22 if (Result.isSuccess(result)) {24 if (Result.isSuccess(result)) {
23 return <ScopeContext.Provider value={result.value}>{props.children}</ScopeContext.Provider>;25 return <ScopeContext.Provider value={result.value}>{props.children}</ScopeContext.Provider>;
24 }26 }
25 27
26 // Loading or error — don't render children28 return <>{props.fallback ?? null}</>;
27 return null;
28}29}
29 30
30/**31/**
packages/react/src/pages/source-detail.tsx+62−4
@@ -15,6 +15,7 @@ import { useScope } from "../hooks/use-scope"; · 14 unmodified lines
15import type { SourcePlugin } from "../plugins/source-plugin";15import type { SourcePlugin } from "../plugins/source-plugin";
16import { Button } from "../components/button";16import { Button } from "../components/button";
17import { Badge } from "../components/badge";17import { Badge } from "../components/badge";
18import { Skeleton } from "../components/skeleton";
18 19
19export function SourceDetailPage(props: {20export function SourceDetailPage(props: {
20 namespace: string;21 namespace: string;
@@ -190,9 +191,7 @@ export function SourceDetailPage(props: { · 169 unmodified lines
190 {editing && editPlugin ? (191 {editing && editPlugin ? (
191 <div className="min-h-0 flex-1 overflow-y-auto">192 <div className="min-h-0 flex-1 overflow-y-auto">
192 <div className="mx-auto max-w-2xl px-6 py-8">193 <div className="mx-auto max-w-2xl px-6 py-8">
193 <Suspense194 <Suspense fallback={<EditFormSkeleton />}>
194 fallback={<div className="p-6 text-sm text-muted-foreground">Loading...</div>}
195 >
196 <editPlugin.edit sourceId={namespace} onSave={handleEditSave} />195 <editPlugin.edit sourceId={namespace} onSave={handleEditSave} />
197 </Suspense>196 </Suspense>
198 </div>197 </div>
@@ -200,7 +199,7 @@ export function SourceDetailPage(props: { · 1 unmodified line
200 ) : (199 ) : (
201 /* Content -- split pane */200 /* Content -- split pane */
202 Result.match(tools, {201 Result.match(tools, {
203 onInitial: () => <div className="p-6 text-sm text-muted-foreground">Loading...</div>,202 onInitial: () => <SourceDetailSkeleton />,
204 onFailure: () => <div className="p-6 text-sm text-destructive">Failed to load tools</div>,203 onFailure: () => <div className="p-6 text-sm text-destructive">Failed to load tools</div>,
205 onSuccess: () => (204 onSuccess: () => (
206 <div className="flex min-h-0 flex-1 overflow-hidden">205 <div className="flex min-h-0 flex-1 overflow-hidden">
@@ -233,3 +232,62 @@ export function SourceDetailPage(props: { · 26 unmodified lines
233 </div>232 </div>
234 );233 );
235}234}
235
236function 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
272function 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
21 CardStackEntryActions,21 CardStackEntryActions,
22} from "../components/card-stack";22} from "../components/card-stack";
23import { SourceFavicon } from "../components/source-favicon";23import { SourceFavicon } from "../components/source-favicon";
24import { Skeleton } from "../components/skeleton";
24 25
25const KIND_TO_PLUGIN_KEY: Record<string, string> = {26const KIND_TO_PLUGIN_KEY: Record<string, string> = {
26 openapi: "openapi",27 openapi: "openapi",
@@ -144,7 +145,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 117 unmodified lines
144 </div>145 </div>
145 146
146 {Result.match(sources, {147 {Result.match(sources, {
147 onInitial: () => <p className="text-sm text-muted-foreground">Loading…</p>,148 onInitial: () => <SourcesGridSkeleton />,
148 onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>,149 onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>,
149 onSuccess: ({ value }) => {150 onSuccess: ({ value }) => {
150 const connectedSources = value.filter((source) => !source.runtime);151 const connectedSources = value.filter((source) => !source.runtime);
@@ -297,3 +298,26 @@ function SourceGrid(props: { · 146 unmodified lines
297 </CardStack>298 </CardStack>
298 );299 );
299}300}
301
302// ---------------------------------------------------------------------------
303// Loading skeleton
304// ---------------------------------------------------------------------------
305
306function 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}