From 226e49d1fdec26c8301043a3d35248c56cf9b6cb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 07:14:36 -0700 Subject: [PATCH] CL-6381: warm loader everywhere + app error boundary (UI polish slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the one error boundary the app never had (a render error used to leave the reader on a blank white page), and swaps every bare spinner, "Loading…" text, and empty PageShell wait for WorkbenchLoadingState so the app has one wait treatment instead of four. KPI tiles get a real shimmer instead of an ellipsis; the sidebar's activity loader and the library preview header now match their real content's shape. --- apps/web/src/app-error-boundary.tsx | 52 ++++++++ apps/web/src/app.css | 11 +- apps/web/src/app.tsx | 14 +- apps/web/src/main.tsx | 5 +- apps/web/src/pages/agent-skills-picker.tsx | 3 +- apps/web/src/pages/agents-page.tsx | 5 +- apps/web/src/pages/home-page.tsx | 16 ++- apps/web/src/pages/insights-page.tsx | 18 ++- apps/web/src/pages/library-page.tsx | 2 +- apps/web/src/pages/new-workbench-picker.tsx | 126 ++++++++++-------- apps/web/src/pages/onboarding-page.tsx | 11 +- .../src/pages/plugin-skill-detail-panel.tsx | 3 +- apps/web/src/pages/plugins-page.tsx | 5 +- apps/web/src/pages/skills-page.tsx | 5 +- apps/web/src/shell/workbench-list.tsx | 9 +- apps/web/test/app-error-boundary.test.tsx | 58 ++++++++ apps/web/test/auth.test.tsx | 4 +- apps/web/test/insights-page.test.tsx | 4 +- packages/api-query/src/query-view.test.tsx | 15 +++ packages/api-query/src/query-view.tsx | 13 +- packages/chat-ui/src/strings.ts | 2 +- .../src/workbench-settings/surface.tsx | 9 +- .../test/workbench-loading-tip.test.tsx | 2 +- 23 files changed, 293 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/app-error-boundary.tsx create mode 100644 apps/web/test/app-error-boundary.test.tsx diff --git a/apps/web/src/app-error-boundary.tsx b/apps/web/src/app-error-boundary.tsx new file mode 100644 index 000000000..1462a671e --- /dev/null +++ b/apps/web/src/app-error-boundary.tsx @@ -0,0 +1,52 @@ +// The last line of defence (CL-6381): a render error anywhere in the tree +// used to leave the reader staring at a blank white page. React only offers +// this as a class component (no hook equivalent exists), so it's the one +// class in an otherwise function-component codebase. + +import { getLogger } from "@corbits/client-log"; +import { Button, EmptyState } from "@corbits/react-ui"; +import { BoldIconProvider, WarningCircle } from "@corbits/icons"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +const log = getLogger("web.app-error-boundary"); + +export class AppErrorBoundary extends Component< + { readonly children: ReactNode }, + { readonly hasError: boolean } +> { + override state = { hasError: false }; + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + log.error(error.message, { + stack: error.stack, + componentStack: info.componentStack, + }); + } + + override render(): ReactNode { + if (!this.state.hasError) return this.props.children; + return ( + +
+ } + title="This screen hit a snag" + description="Something broke while rendering. Reloading usually fixes it." + action={ + + } + /> +
+
+ ); + } +} diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f35558370..78d07a3ee 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1648,8 +1648,15 @@ select:disabled, border-radius: 0; } -.shell-activity-skeleton { - height: 6rem; +.shell-activity-skeleton-rows { + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: 0.32rem 0.42rem; +} + +.shell-activity-skeleton-row { + height: 1.6rem; width: 100%; } diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 38b3038c8..db1f1c0f2 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -3,7 +3,8 @@ // screens that talk to the hub only mount once the session is confirmed, so // a signed-out browser fires no authenticated request anywhere. -import { BootScreen, Button, CorbitsMark, EmptyState } from "@corbits/react-ui"; +import { Button, EmptyState } from "@corbits/react-ui"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { QueryClientProvider } from "@tanstack/react-query"; import { BoldIconProvider, WarningCircle } from "@corbits/icons"; import { useEffect, useMemo } from "react"; @@ -75,15 +76,6 @@ function OnboardingGate({ ); } -function Brand() { - return ( - <> - - Workbench - - ); -} - function Shell({ path, navigate, @@ -176,7 +168,7 @@ export function App({ case "loading": return (
- } /> +
); case "signed-out": diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 714adada2..7e87253fb 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import { StrictMode, useCallback, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { getLogger } from "@corbits/client-log"; +import { AppErrorBoundary } from "./app-error-boundary"; import { App } from "./app"; import { validatedNextPath } from "./login-next"; import { triggerFirstLoginProvisioning } from "./onboarding"; @@ -120,6 +121,8 @@ const container = document.getElementById("root"); if (container === null) throw new Error("index.html is missing #root"); createRoot(container).render( - + + + , ); diff --git a/apps/web/src/pages/agent-skills-picker.tsx b/apps/web/src/pages/agent-skills-picker.tsx index f823dbdbf..6a3a263bb 100644 --- a/apps/web/src/pages/agent-skills-picker.tsx +++ b/apps/web/src/pages/agent-skills-picker.tsx @@ -7,6 +7,7 @@ // yet", because attaching nothing because the read failed and attaching // nothing because there is nothing are very different outcomes. +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useEffect, useState } from "react"; import { listSkills, type SkillSummary } from "../skills-api"; @@ -59,7 +60,7 @@ export function AgentSkillsPicker({ } if (state.status === "loading") { - return

Loading skills…

; + return ; } if (state.status === "error") { diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index da65f7832..6e13a3c09 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -14,6 +14,7 @@ import { Button, PageShell, RichEmptyState, + Skeleton, Table, TableBody, TableCell, @@ -125,7 +126,9 @@ function AgentDetailPanel({
Model
- {capabilities.status === "loading" ? "Loading…" : null} + {capabilities.status === "loading" ? ( + + ) : null} {capabilities.status === "error" ? ( {capabilities.message} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 258e16aea..a73aec9d3 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -8,11 +8,11 @@ // dashboard does not earn its keep — `/` only exists as this hop onto // `/w/:workbenchId`. Deep links to other pages are unchanged. -import { BootScreen, Button, EmptyState, PageShell } from "@corbits/react-ui"; +import { Button, EmptyState, PageShell } from "@corbits/react-ui"; import { WarningCircle } from "@corbits/icons"; import { useEffect, useState } from "react"; -import { listAllWorkbenches } from "@corbits/chat-ui"; +import { listAllWorkbenches, WorkbenchLoadingState } from "@corbits/chat-ui"; import { useBench } from "../bench-context"; import { workbenchPath } from "../workbench-path"; @@ -72,7 +72,11 @@ export function HomeRoute() { }, [selectedTenantId, navigate, retryCount]); if (memberships.kind === "loading") { - return ; + return ( +
+ +
+ ); } if (memberships.kind === "error") { @@ -124,5 +128,9 @@ export function HomeRoute() { ); } - return ; + return ( +
+ +
+ ); } diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 34e640525..a7658a1bf 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -149,7 +149,7 @@ export function statusTone( } function tileValue(value: string | number | null, loading: boolean): string { - if (loading) return "…"; + if (loading) return ""; if (value === null) return "—"; return String(value); } @@ -254,13 +254,21 @@ function InsightsStat({ readonly onClick?: () => void; readonly loading?: boolean; }) { + if (loading === true) { + return ( +
+ + {label} + + +
+ ); + } return ( ); diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index 091f42cdc..4bc98df5f 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -169,7 +169,7 @@ function PreviewPane({

- {detail?.title ?? (loading ? "Loading…" : "Preview")} + {detail?.title ?? "Preview"}

{detail !== null ? (

diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 6f2d94926..9213e10ab 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -10,6 +10,7 @@ import { Button, toast } from "@corbits/react-ui"; import { ChatCircle, GitPullRequest, Plus } from "@corbits/icons"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useState } from "react"; import { useBench } from "../bench-context"; @@ -66,75 +67,84 @@ export function NewWorkbenchPickerRoute() { } />

-

What should this workbench do?

-

- Pick one. You can change your mind later — nothing is locked in. -

+ {creating ? ( + + ) : ( + <> +

What should this workbench do?

+

+ Pick one. You can change your mind later — nothing is locked in. +

-
- {WORKBENCH_TEMPLATES.map((template) => { - const Icon = ROW_ICON[template.id]; - const selected = template.id === selectedId; - return ( - + ); + })} + + - {template.title} + {COMING_SOON_ROW.title} - {template.promise} + {COMING_SOON_ROW.promise} - - {ctaLabel(selected)} - - - ); - })} - - - - - - {COMING_SOON_ROW.title} - - - {COMING_SOON_ROW.promise} + Coming - - Coming - -
+
-
- - - Takes about ten seconds. - -
+
+ + + Takes about ten seconds. + +
+ + )}
); diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 9676ab45a..252860449 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -29,6 +29,7 @@ import { Button, EmptyState, Input, ProviderMark } from "@corbits/react-ui"; import { Key, WarningCircle } from "@corbits/icons"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; import type { FormEvent } from "react"; import { OLLAMA_PLACEHOLDER_SECRET } from "@workbench/hub-client/credential-test"; @@ -367,7 +368,10 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {

Setting up your workbench

One moment.

-
@@ -383,7 +387,10 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { Key added — setting up your workbench.

-
diff --git a/apps/web/src/pages/plugin-skill-detail-panel.tsx b/apps/web/src/pages/plugin-skill-detail-panel.tsx index 07250de51..66f7cd574 100644 --- a/apps/web/src/pages/plugin-skill-detail-panel.tsx +++ b/apps/web/src/pages/plugin-skill-detail-panel.tsx @@ -23,6 +23,7 @@ import { TableRow, formatRelativeTime, } from "@corbits/react-ui"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useEffect, useState } from "react"; import { @@ -120,7 +121,7 @@ export function PluginSkillDetailPanel({ {state.status === "loading" ? ( -

Loading skill…

+
) : state.status === "error" ? ( diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index a6b16e477..57c241f1e 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -20,6 +20,7 @@ import { PageShell, RichEmptyState, } from "@corbits/react-ui"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { PluginsGallery, PluginConnectPanel, @@ -192,7 +193,9 @@ export function PluginsRoute({ return (
- + + +
); } diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx index 1346482f4..bba0c8ec1 100644 --- a/apps/web/src/pages/skills-page.tsx +++ b/apps/web/src/pages/skills-page.tsx @@ -31,6 +31,7 @@ import { formatRelativeTime, } from "@corbits/react-ui"; import { Lightning, Plus } from "@corbits/icons"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; import { rowActivationProps } from "../activatable-row"; @@ -114,7 +115,7 @@ function SkillDetailView({ }, [reload]); if (state.status === "loading") { - return

Loading skill…

; + return ; } if (state.status === "error") { return ( @@ -346,7 +347,7 @@ export function SkillsPage({ } if (state.status === "loading") { - return

Loading skills…

; + return ; } if (state.status === "error") { diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index fd90efc42..8f96400a4 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -373,7 +373,14 @@ export function WorkbenchList({ const [query, setQuery] = useState(""); if (activity.kind === "loading") { - return ; + return ( + + ); } if (activity.kind === "empty") { return ( diff --git a/apps/web/test/app-error-boundary.test.tsx b/apps/web/test/app-error-boundary.test.tsx new file mode 100644 index 000000000..b9eae8386 --- /dev/null +++ b/apps/web/test/app-error-boundary.test.tsx @@ -0,0 +1,58 @@ +// CL-6381: a render error anywhere in the tree used to leave the reader +// staring at a blank white page (no error boundary existed anywhere in +// apps/web). This pins the boundary's red/green behaviour — a throwing +// child renders the designed EmptyState, never a blank screen — and that +// a healthy tree passes straight through untouched. + +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { AppErrorBoundary } from "../src/app-error-boundary"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + if (container !== null) container.remove(); + root = null; + container = null; +}); + +function Bomb(): never { + throw new Error("kaboom"); +} + +function render(children: ReactNode) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render({children})); + return container; +} + +describe("AppErrorBoundary", () => { + test("a healthy tree passes straight through", () => { + const el = render(

Everything is fine

); + expect(el.textContent).toContain("Everything is fine"); + }); + + test("a throwing child renders the empty state, not a blank screen", () => { + // React logs the caught error to the real console during this render; + // that's expected noise for a deliberately-throwing test component. + const originalError = console.error; + console.error = () => undefined; + let el: HTMLDivElement; + try { + el = render(); + } finally { + console.error = originalError; + } + expect(el.textContent).toContain("This screen hit a snag"); + expect(el.textContent).toContain("Reload"); + expect(el.textContent).not.toContain("Everything is fine"); + }); +}); diff --git a/apps/web/test/auth.test.tsx b/apps/web/test/auth.test.tsx index c113ae5d4..0b75e5ac5 100644 --- a/apps/web/test/auth.test.tsx +++ b/apps/web/test/auth.test.tsx @@ -231,7 +231,9 @@ describe("the gate", () => { }); test("loading and error are their own screens, not a broken shell", () => { - expect(renderApp({ kind: "loading" })).toContain("Loading workbench"); + expect(renderApp({ kind: "loading" })).toContain( + "Getting your workbench ready", + ); const markup = renderApp({ kind: "error", message: "socket hang up" }); expect(markup).toContain("socket hang up"); expect(markup).toContain("Try again"); diff --git a/apps/web/test/insights-page.test.tsx b/apps/web/test/insights-page.test.tsx index a16c1d110..d2eb44cc2 100644 --- a/apps/web/test/insights-page.test.tsx +++ b/apps/web/test/insights-page.test.tsx @@ -309,7 +309,7 @@ describe("InsightsPage run-detail stat strip", () => { expect(markup).not.toContain(">Bench<"); }); - test("while the trace is loading, the KPIs render an ellipsis, not a dash", () => { + test("while the trace is loading, the KPIs render a shimmer, not a dash", () => { const markup = renderToStaticMarkup( { onBack={() => undefined} />, ); - expect(markup).toContain(">…<"); + expect(markup).toContain('data-slot="skeleton"'); // Owner is genuinely absent from WorkflowRunResponse today (not a // loading state), so it keeps its dash even while the trace loads. expect(markup).toContain(">—<"); diff --git a/packages/api-query/src/query-view.test.tsx b/packages/api-query/src/query-view.test.tsx index 56f20b508..3ac859382 100644 --- a/packages/api-query/src/query-view.test.tsx +++ b/packages/api-query/src/query-view.test.tsx @@ -97,6 +97,21 @@ describe("QueryView loading skeletons", () => { expect(markup).toContain("query-skeleton-detail"); expect(markup).toContain("query-skeleton-detail-header"); }); + + test("loadingContent overrides the skeleton entirely for a page-level wait", () => { + const markup = renderToStaticMarkup( + + query={{ kind: "loading" }} + label="item" + loadingContent={
Hang tight…
} + > + {(data) =>
{data}
} + , + ); + expect(markup).toContain("my-warm-loader"); + expect(markup).toContain("Hang tight…"); + expect(markup).not.toContain("query-skeleton"); + }); }); describe("SignedOutNotice", () => { diff --git a/packages/api-query/src/query-view.tsx b/packages/api-query/src/query-view.tsx index 7e6b6d23d..a257a0357 100644 --- a/packages/api-query/src/query-view.tsx +++ b/packages/api-query/src/query-view.tsx @@ -74,19 +74,28 @@ export function QueryView({ query, label, skeleton = "block", + loadingContent, children, }: { readonly query: APIQuery; /** What is being loaded, named in the failure message: "your benches". */ readonly label: string; /** The loading placeholder's shape — pick the one nearest this surface's - * real content so it doesn't jump when data lands. */ + * real content so it doesn't jump when data lands. Ignored when + * `loadingContent` is set. */ readonly skeleton?: QuerySkeletonVariant; + /** Overrides the loading render entirely — a page-level wait (a whole + * stage or panel's primary content, not a row hint) should pass its own + * warm loader here rather than take the `"block"` skeleton slab, which + * this package can't render itself: `@corbits/chat-ui`'s + * `WorkbenchLoadingState` depends on this package, so `QueryView` can + * never import it back without a cycle. */ + readonly loadingContent?: ReactNode; readonly children: (data: T) => ReactNode; }) { switch (query.kind) { case "loading": - return ; + return loadingContent ?? ; case "unauthenticated": return ; case "error": diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index d99120fe9..1ab670659 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -50,7 +50,7 @@ export const CHAT_STRINGS = { // One honest headline for every waiting state \u2014 the reader never needs // to know which internal stage this is; that distinction stays in logs, // never in front of them. - workbenchLoadingTitle: "Loading your workbench\u2026", + workbenchLoadingTitle: "Getting your workbench ready\u2026", workbenchLoadingTips: [ "Tip: @mention an agent to bring them into the conversation", "Tip: every message can become a thread", diff --git a/packages/chat-ui/src/workbench-settings/surface.tsx b/packages/chat-ui/src/workbench-settings/surface.tsx index 07bcb1d8e..8854b2210 100644 --- a/packages/chat-ui/src/workbench-settings/surface.tsx +++ b/packages/chat-ui/src/workbench-settings/surface.tsx @@ -24,6 +24,7 @@ import { patchWorkbenchSettings, } from "../api"; import type { WorkbenchSettings } from "../api"; +import { WorkbenchLoadingState } from "../loading-state"; import { CHAT_STRINGS } from "../strings"; import { AgentsSection } from "./agents-section"; import { CapacitySection } from "./capacity-section"; @@ -266,7 +267,13 @@ export function WorkbenchSettingsSurface({ ) : null}
- + + } + > {({ data, benchDefault }) => (