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(); + }); +});