From 0f25562299af24bfdebe6d0830bfe4a40b3e3590 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 06:25:26 -0700 Subject: [PATCH] CL-6370: kill the grey slots, warm loader + designed empty states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page/room-level wait now renders the same shared treatment CL-6307 shipped for workbench setup — one honest headline plus a rotating tip, never a bare Skeleton slab. Extracted that markup out of timeline.tsx into chat-ui's new WorkbenchLoadingState (with a title override for non-workbench surfaces and a delay threshold so a sub-200ms resolve never flashes an intermediate frame) and reused it for: - the route-level Suspense fallback in app-shell.tsx (was a bare page-fill div) - chat-workspace's workbench-list, message-timeline, and tenant-loading states (were bare Skeleton slabs — the source of the "grey rectangle" on opening a workbench) - routine-panel's routines/trace/runs/tasks panels (were bare Skeleton slabs) Also fixes the empty-agent-DM "blank void": settingUpAgent was permanently true for every agent chat (it never checked whether the agent had actually joined), so an agent DM with zero messages could never show anything but the setup loader. It now clears once the agent participant appears, and a genuinely empty-but-ready agent DM leads with the agent's own name ("Say hello to Myra") instead of generic timeline copy. Files/Skills/Agents pages already had designed RichEmptyState treatments with a primary action, so those were left alone. --- apps/web/src/app.css | 8 ++ apps/web/src/shell/app-shell.tsx | 10 ++- apps/web/src/shell/routine-panel.tsx | 11 ++- packages/chat-ui/src/chat-workspace.tsx | 14 +-- packages/chat-ui/src/index.ts | 2 + packages/chat-ui/src/loading-state.tsx | 85 +++++++++++++++++++ packages/chat-ui/src/strings.ts | 2 + packages/chat-ui/src/timeline.tsx | 56 +++++------- packages/chat-ui/test/loading-state.test.tsx | 70 +++++++++++++++ .../test/workbench-loading-tip.test.tsx | 20 +++++ 10 files changed, 230 insertions(+), 48 deletions(-) create mode 100644 packages/chat-ui/src/loading-state.tsx create mode 100644 packages/chat-ui/test/loading-state.test.tsx diff --git a/apps/web/src/app.css b/apps/web/src/app.css index d542217ba..9691d1e98 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -2156,6 +2156,14 @@ select:disabled, min-height: 0; } +/* Route-level Suspense fallback (CL-6370): centers the shared warm loader + instead of leaving a bare, un-laid-out box while a route chunk loads. */ +.shell-route-loading { + display: flex; + align-items: center; + justify-content: center; +} + /* Tables fit their page slot instead of scrolling sideways: fixed layout, one line per cell, ellipsis on overflow (cells that can truncate carry a title tooltip with the full value). */ diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 793d155f8..741250dcc 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -22,6 +22,8 @@ import { import * as Y from "yjs"; import type { ArtifactSaveState } from "@corbits/artifact-ui"; +import { WorkbenchLoadingState } from "@corbits/chat-ui"; + import { useBench } from "../bench-context"; import { useNavigate } from "../navigation"; import { usePresenceRoom } from "../presence/use-presence-room"; @@ -162,7 +164,13 @@ export function AppShell({ {routeHasNoStageTopBar(path) ? ( ) : null} - }> + + + + } + > {children} diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index 8ec00b267..bc34558ca 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -49,14 +49,13 @@ import { MenuTrigger, RichEmptyState, RunNowButton, - Skeleton, StatusDot, Switch, toast, TraceWaterfall, } from "@corbits/react-ui"; import type { BadgeTone, StatusDotTone } from "@corbits/react-ui"; -import { listWorkbenchAgents } from "@corbits/chat-ui"; +import { listWorkbenchAgents, WorkbenchLoadingState } from "@corbits/chat-ui"; import { listTasks } from "@corbits/tasks-ui"; import type { Task, TaskStatus } from "@corbits/tasks-ui"; import { Clock, Plus, X } from "lucide-react"; @@ -469,7 +468,7 @@ function RoutineListPanel({ New routine {routinesQuery.kind === "loading" ? ( - + ) : routinesQuery.kind === "ready" ? ( routines.length === 0 ? ( void }) { />
{traceQuery.kind === "loading" ? ( - + ) : null} {traceQuery.kind === "ready" && spans.length > 0 ? ( void }) {
{runsQuery.kind === "loading" ? ( - + ) : runsQuery.kind === "ready" && runs.length === 0 ? ( } @@ -714,7 +713,7 @@ function TasksSection({
{tasksQuery.kind === "loading" ? ( - + ) : tasks.length === 0 ? (
{bareLeadingHeader} {workbenchesState.kind === "loading" ? ( - + ) : workbenchesState.kind === "error" ? ( } @@ -1157,7 +1158,7 @@ function ChatWorkspaceInner({
{messagesState.kind === "loading" ? ( - + ) : messagesState.kind === "error" && messagesState.workbenchNotFound ? ( isAgentAddress(participant.address), + ) } items={appendReplyTimedOutNotice( mergeStreamingReply( @@ -1545,7 +1549,7 @@ export function ChatWorkspace({ case "loading": return ( - + ); } diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index 27bd9bbfa..cfdb029a4 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -11,6 +11,8 @@ export type { TimelineMessageItem, } from "./timeline"; +export { WorkbenchLoadingState } from "./loading-state"; + export { PinnedStrip } from "./pinned-strip"; export { Composer, diff --git a/packages/chat-ui/src/loading-state.tsx b/packages/chat-ui/src/loading-state.tsx new file mode 100644 index 000000000..4f1e8791d --- /dev/null +++ b/packages/chat-ui/src/loading-state.tsx @@ -0,0 +1,85 @@ +// The one warm loader every page/room-level wait in this app renders +// (CL-6370, following CL-6307's setup loader) — a bare skeleton/spinner/grey +// slab is never the right answer for "we don't know how long this takes": +// one honest headline plus a small rotating tip reads as useful rather than +// stalled, and it's the same shape everywhere so a reader learns it once. +// +// `delayMs` (default 200) holds the loader itself back: a wait that +// resolves before the delay elapses never gets an intermediate frame at +// all, which is what keeps a fast round-trip from flashing chrome the +// reader has no time to read. + +import { useEffect, useState } from "react"; + +import { CHAT_STRINGS } from "./strings"; + +const WORKBENCH_LOADING_TIP_INTERVAL_MS = 4000; +const DEFAULT_LOADING_DELAY_MS = 200; + +/** A small, honest product tip under the loading headline — rotates on a + * timer regardless of motion preference; the fade between tips is the + * only thing `prefers-reduced-motion` turns off (the CSS keyframe is + * scoped to `no-preference`, so a reduced-motion reader still sees each + * tip in turn, just without the crossfade). */ +function WorkbenchLoadingTip() { + const tips = CHAT_STRINGS.workbenchLoadingTips; + const [index, setIndex] = useState(0); + + useEffect(() => { + const id = setInterval(() => { + setIndex((current) => (current + 1) % tips.length); + }, WORKBENCH_LOADING_TIP_INTERVAL_MS); + return () => clearInterval(id); + }, [tips.length]); + + return ( + + {tips[index]} + + ); +} + +/** + * The shared page/room-level loading treatment: one honest headline (never + * an internal stage name — "Starting the runtime…" tells the reader + * nothing they can act on) plus a rotating tip. Delays its own mount by + * `delayMs` so a wait that resolves quickly never flashes an intermediate + * frame — see this file's doc. + */ +export function WorkbenchLoadingState({ + delayMs = DEFAULT_LOADING_DELAY_MS, + title = CHAT_STRINGS.workbenchLoadingTitle, + className, +}: { + readonly delayMs?: number; + /** Overrides the headline for a surface that isn't the workbench + * timeline itself (a side panel loading routines or runs, say) — still + * one honest sentence naming what's loading, never an internal stage. */ + readonly title?: string; + readonly className?: string; +}) { + const [visible, setVisible] = useState(delayMs <= 0); + + useEffect(() => { + if (delayMs <= 0) return; + const id = setTimeout(() => setVisible(true), delayMs); + return () => clearTimeout(id); + }, [delayMs]); + + if (!visible) return null; + + const classNames = ["chat-workbench-loading"]; + if (className !== undefined) classNames.push(className); + + return ( +
+ + {title} + +
+ ); +} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 96deba1fb..337c8f725 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -58,6 +58,8 @@ export const CHAT_STRINGS = { "Tip: press / for commands", ], emptyTimelineDescription: "Say something to get the conversation going.", + emptyAgentTimelineDescription: + "They're ready — send the first message to get started.", mentionEmpty: "No matches", mentionAgentsGroupLabel: "Agents", mentionPeopleGroupLabel: "People", diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index eeef90df1..6390a2443 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -54,6 +54,7 @@ import type { BlockResponseActions } from "./blocks/block-responses"; import type { ConnectGithubActions } from "./blocks/connect-github-actions"; import { BlockPartView } from "./blocks/registry"; import { isClassifiedInferenceFailureText } from "./inference-failure"; +import { WorkbenchLoadingState } from "./loading-state"; import { Markdown } from "./markdown"; import { PrFailedTurnStrip } from "./pr-thread-view"; import type { ProfileSubject } from "./profile-subject"; @@ -1451,31 +1452,6 @@ function ThreadAffordance({ ); } -const WORKBENCH_LOADING_TIP_INTERVAL_MS = 4000; - -/** A small, honest product tip under the loading headline — rotates on a - * timer regardless of motion preference; the fade between tips is the - * only thing `prefers-reduced-motion` turns off (the CSS keyframe is - * scoped to `no-preference`, so a reduced-motion reader still sees each - * tip in turn, just without the crossfade). */ -function WorkbenchLoadingTip() { - const tips = CHAT_STRINGS.workbenchLoadingTips; - const [index, setIndex] = useState(0); - - useEffect(() => { - const id = setInterval(() => { - setIndex((current) => (current + 1) % tips.length); - }, WORKBENCH_LOADING_TIP_INTERVAL_MS); - return () => clearInterval(id); - }, [tips.length]); - - return ( - - {tips[index]} - - ); -} - /** A workbench's scroll position, captured/restored across a * `WorkbenchTimeline` unmount-remount (e.g. opening/closing Settings) — see * `WorkbenchTimeline`'s `scrollRestore`/`onScrollSnapshot`. */ @@ -1660,17 +1636,25 @@ export function WorkbenchTimeline({ if (settingUpAgent === true) { return (
-
- - - {CHAT_STRINGS.workbenchLoadingTitle} - - -
+ +
+ ); + } + // Once an agent DM's agent has actually joined (see `settingUpAgent`'s + // caller), an empty timeline isn't a stage to wait out — it's a ready + // conversation with nobody in it yet. Leads with the agent's own name + // so the affordance is "message them", not the generic feed copy. + const readyAgent = participants.find((participant) => + isAgentAddress(participant.address), + ); + if (readyAgent !== undefined) { + return ( +
+ } + title={`Say hello to ${displayNameFromHandle(readyAgent.handle)}`} + description={CHAT_STRINGS.emptyAgentTimelineDescription} + />
); } diff --git a/packages/chat-ui/test/loading-state.test.tsx b/packages/chat-ui/test/loading-state.test.tsx new file mode 100644 index 000000000..c0a1fcc4d --- /dev/null +++ b/packages/chat-ui/test/loading-state.test.tsx @@ -0,0 +1,70 @@ +// CL-6370: every page/room-level wait renders the shared warm loader — +// headline + rotating tip — never a bare skeleton/spinner slab, and a wait +// that resolves inside the delay window never renders an intermediate +// frame at all (flash prevention). + +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import type { ReactElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { WorkbenchLoadingState } from "../src/loading-state"; +import { CHAT_STRINGS } from "../src/strings"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; +}); + +function mount(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(element); + }); + return container; +} + +describe("WorkbenchLoadingState", () => { + test("renders the tips treatment, never a bare skeleton", () => { + const el = mount(); + + expect(el.querySelector(".chat-workbench-loading")).not.toBeNull(); + expect(el.querySelector(".chat-workbench-loading-tip")).not.toBeNull(); + expect(el.querySelector('[data-slot="skeleton"]')).toBeNull(); + expect(el.querySelector(".animate-pulse")).toBeNull(); + }); + + test("shows the default honest headline", () => { + const el = mount(); + + expect(el.textContent).toContain(CHAT_STRINGS.workbenchLoadingTitle); + }); + + test("accepts a title override for a non-workbench surface", () => { + const el = mount( + , + ); + + expect(el.textContent).toContain("Loading routines…"); + }); + + test("renders nothing until the delay elapses (flash prevention)", async () => { + const el = mount(); + + expect(el.querySelector(".chat-workbench-loading")).toBeNull(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 220)); + }); + + expect(el.querySelector(".chat-workbench-loading")).not.toBeNull(); + }); +}); diff --git a/packages/chat-ui/test/workbench-loading-tip.test.tsx b/packages/chat-ui/test/workbench-loading-tip.test.tsx index 6ad3ccc06..177ab1254 100644 --- a/packages/chat-ui/test/workbench-loading-tip.test.tsx +++ b/packages/chat-ui/test/workbench-loading-tip.test.tsx @@ -66,3 +66,23 @@ describe("WorkbenchTimeline — setup loader", () => { expect(second).not.toBe(first); }); }); + +describe("WorkbenchTimeline — empty agent DM", () => { + test("once the agent has joined, leads with its own name instead of the loader", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("Say hello to Myra"); + expect(container.querySelector(".chat-workbench-loading")).toBeNull(); + }); +});