Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/web/src/app-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BoldIconProvider>
<div className="app-boot-frame">
<EmptyState
icon={<WarningCircle />}
title="This screen hit a snag"
description="Something broke while rendering. Reloading usually fixes it."
action={
<Button
variant="outline"
onClick={() => window.location.reload()}
>
Reload
</Button>
}
/>
</div>
</BoldIconProvider>
);
}
}
11 changes: 9 additions & 2 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}

Expand Down
14 changes: 3 additions & 11 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -75,15 +76,6 @@ function OnboardingGate({
);
}

function Brand() {
return (
<>
<CorbitsMark decorative className="app-mark" />
<span className="app-wordmark">Workbench</span>
</>
);
}

function Shell({
path,
navigate,
Expand Down Expand Up @@ -176,7 +168,7 @@ export function App({
case "loading":
return (
<div className="app-boot-frame">
<BootScreen message="Loading workbench" brand={<Brand />} />
<WorkbenchLoadingState delayMs={0} />
</div>
);
case "signed-out":
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,6 +121,8 @@ const container = document.getElementById("root");
if (container === null) throw new Error("index.html is missing #root");
createRoot(container).render(
<StrictMode>
<Root />
<AppErrorBoundary>
<Root />
</AppErrorBoundary>
</StrictMode>,
);
3 changes: 2 additions & 1 deletion apps/web/src/pages/agent-skills-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,7 +60,7 @@ export function AgentSkillsPicker({
}

if (state.status === "loading") {
return <p className="text-sm text-muted-foreground">Loading skills…</p>;
return <WorkbenchLoadingState title="Loading skills…" />;
}

if (state.status === "error") {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Button,
PageShell,
RichEmptyState,
Skeleton,
Table,
TableBody,
TableCell,
Expand Down Expand Up @@ -125,7 +126,9 @@ function AgentDetailPanel({
<div className="flex items-center justify-between gap-2">
<dt className="text-muted-foreground">Model</dt>
<dd>
{capabilities.status === "loading" ? "Loading…" : null}
{capabilities.status === "loading" ? (
<Skeleton className="h-4 w-16" />
) : null}
{capabilities.status === "error" ? (
<span className="text-danger-foreground">
{capabilities.message}
Expand Down
16 changes: 12 additions & 4 deletions apps/web/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,7 +72,11 @@ export function HomeRoute() {
}, [selectedTenantId, navigate, retryCount]);

if (memberships.kind === "loading") {
return <BootScreen message="Opening Myra" />;
return (
<div className="page-fill shell-route-loading">
<WorkbenchLoadingState />
</div>
);
}

if (memberships.kind === "error") {
Expand Down Expand Up @@ -124,5 +128,9 @@ export function HomeRoute() {
);
}

return <BootScreen message="Opening Myra" />;
return (
<div className="page-fill shell-route-loading">
<WorkbenchLoadingState />
</div>
);
}
18 changes: 13 additions & 5 deletions apps/web/src/pages/insights-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -254,13 +254,21 @@ function InsightsStat({
readonly onClick?: () => void;
readonly loading?: boolean;
}) {
if (loading === true) {
return (
<div className="flex flex-col gap-1.5 rounded-lg border border-border bg-card p-4">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-muted-foreground">
{label}
</span>
<Skeleton className="h-[26px] w-16" />
</div>
);
}
return (
<StatGridItem
label={label}
value={loading === true ? "…" : value}
{...(detail === undefined
? {}
: { sub: loading === true ? " " : detail })}
value={value}
{...(detail === undefined ? {} : { sub: detail })}
{...(onClick === undefined ? {} : { onClick })}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ function PreviewPane({
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{detail?.title ?? (loading ? "Loading…" : "Preview")}
{detail?.title ?? "Preview"}
</p>
{detail !== null ? (
<p className="truncate text-xs text-muted-foreground">
Expand Down
126 changes: 68 additions & 58 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,75 +67,84 @@ export function NewWorkbenchPickerRoute() {
}
/>
<div className="new-workbench-picker">
<h3>What should this workbench do?</h3>
<p className="new-workbench-picker-sub">
Pick one. You can change your mind later — nothing is locked in.
</p>
{creating ? (
<WorkbenchLoadingState title="Setting up your workbench…" />
) : (
<>
<h3>What should this workbench do?</h3>
<p className="new-workbench-picker-sub">
Pick one. You can change your mind later — nothing is locked in.
</p>

<div
className="new-workbench-pick-list"
role="radiogroup"
aria-label="Workbench kind"
>
{WORKBENCH_TEMPLATES.map((template) => {
const Icon = ROW_ICON[template.id];
const selected = template.id === selectedId;
return (
<button
key={template.id}
type="button"
role="radio"
aria-checked={selected}
data-selected={selected ? "true" : undefined}
className="new-workbench-pick-row"
onClick={() => setSelectedId(template.id)}
>
<div
className="new-workbench-pick-list"
role="radiogroup"
aria-label="Workbench kind"
>
{WORKBENCH_TEMPLATES.map((template) => {
const Icon = ROW_ICON[template.id];
const selected = template.id === selectedId;
return (
<button
key={template.id}
type="button"
role="radio"
aria-checked={selected}
data-selected={selected ? "true" : undefined}
className="new-workbench-pick-row"
onClick={() => setSelectedId(template.id)}
>
<span
className="new-workbench-pick-glyph"
aria-hidden="true"
>
<Icon size={16} strokeWidth={1.8} />
</span>
<span className="new-workbench-pick-text">
<span className="new-workbench-pick-title">
{template.title}
</span>
<span className="new-workbench-pick-promise">
{template.promise}
</span>
</span>
<span className="new-workbench-pick-cta">
{ctaLabel(selected)}
</span>
</button>
);
})}

<span className="new-workbench-pick-row" aria-disabled="true">
<span className="new-workbench-pick-glyph" aria-hidden="true">
<Icon size={16} strokeWidth={1.8} />
<Plus size={16} strokeWidth={1.8} />
</span>
<span className="new-workbench-pick-text">
<span className="new-workbench-pick-title">
{template.title}
{COMING_SOON_ROW.title}
</span>
<span className="new-workbench-pick-promise">
{template.promise}
{COMING_SOON_ROW.promise}
</span>
</span>
<span className="new-workbench-pick-cta">
{ctaLabel(selected)}
</span>
</button>
);
})}

<span className="new-workbench-pick-row" aria-disabled="true">
<span className="new-workbench-pick-glyph" aria-hidden="true">
<Plus size={16} strokeWidth={1.8} />
</span>
<span className="new-workbench-pick-text">
<span className="new-workbench-pick-title">
{COMING_SOON_ROW.title}
</span>
<span className="new-workbench-pick-promise">
{COMING_SOON_ROW.promise}
<span className="new-workbench-pick-cta">Coming</span>
</span>
</span>
<span className="new-workbench-pick-cta">Coming</span>
</span>
</div>
</div>

<div className="new-workbench-picker-foot">
<Button
type="button"
onClick={() => void handleCreate()}
disabled={creating || selectedTenantId === null}
>
{creating ? "Creating…" : "Create workbench"}
</Button>
<span className="new-workbench-picker-foot-note">
Takes about ten seconds.
</span>
</div>
<div className="new-workbench-picker-foot">
<Button
type="button"
onClick={() => void handleCreate()}
disabled={creating || selectedTenantId === null}
>
Create workbench
</Button>
<span className="new-workbench-picker-foot-note">
Takes about ten seconds.
</span>
</div>
</>
)}
</div>
</div>
);
Expand Down
Loading
Loading