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
8 changes: 8 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -162,7 +164,13 @@ export function AppShell({
{routeHasNoStageTopBar(path) ? (
<StageTopBar title={routeLabel(path)} />
) : null}
<Suspense fallback={<div className="page-fill" aria-busy="true" />}>
<Suspense
fallback={
<div className="page-fill shell-route-loading">
<WorkbenchLoadingState />
</div>
}
>
{children}
</Suspense>
</div>
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/shell/routine-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -469,7 +468,7 @@ function RoutineListPanel({
New routine
</button>
{routinesQuery.kind === "loading" ? (
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState title="Loading routines…" />
) : routinesQuery.kind === "ready" ? (
routines.length === 0 ? (
<EmptyState
Expand Down Expand Up @@ -606,7 +605,7 @@ function RunsCanvasPanel({ onBack }: { readonly onBack: () => void }) {
/>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{traceQuery.kind === "loading" ? (
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState title="Loading trace…" />
) : null}
{traceQuery.kind === "ready" && spans.length > 0 ? (
<TraceWaterfall
Expand Down Expand Up @@ -637,7 +636,7 @@ function RunsCanvasPanel({ onBack }: { readonly onBack: () => void }) {
<CanvasPaneHeader className="px-3 pt-2" title="Runs" onBack={onBack} />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{runsQuery.kind === "loading" ? (
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState title="Loading runs…" />
) : runsQuery.kind === "ready" && runs.length === 0 ? (
<EmptyState
icon={<Clock />}
Expand Down Expand Up @@ -714,7 +713,7 @@ function TasksSection({
</h3>
</div>
{tasksQuery.kind === "loading" ? (
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState title="Loading tasks…" />
) : tasks.length === 0 ? (
<div className="px-3 pb-3">
<EmptyState
Expand Down
14 changes: 9 additions & 5 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// narrow-port shape `@corbits/chat`'s `routes.ts` uses for `ChatPlatform`.

import { isAgentAddress } from "@corbits/chat/mentions";
import { Button, EmptyState, Skeleton, toast } from "@corbits/react-ui";
import { Button, EmptyState, toast } from "@corbits/react-ui";
import {
ChartColumn,
ChevronDown,
Expand Down Expand Up @@ -46,6 +46,7 @@ import type { WorkbenchSettingsSectionId } from "./workbench-settings";
import { Composer } from "./composer";
import type { ComposerHandle } from "./composer";
import { InviteAgentDialog } from "./invite-agent-dialog";
import { WorkbenchLoadingState } from "./loading-state";
import { mentionCandidatesFromParticipants } from "./mentions";
import type { BringInMember } from "./mentions";
import { PinnedStrip } from "./pinned-strip";
Expand Down Expand Up @@ -949,7 +950,7 @@ function ChatWorkspaceInner({
<div className="chat-main">
{bareLeadingHeader}
{workbenchesState.kind === "loading" ? (
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState />
) : workbenchesState.kind === "error" ? (
<EmptyState
icon={<CircleAlert />}
Expand Down Expand Up @@ -1157,7 +1158,7 @@ function ChatWorkspaceInner({
</div>
</div>
{messagesState.kind === "loading" ? (
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState />
) : messagesState.kind === "error" &&
messagesState.workbenchNotFound ? (
<EmptyState
Expand Down Expand Up @@ -1219,7 +1220,10 @@ function ChatWorkspaceInner({
<WorkbenchTimeline
settingUpAgent={
activeWorkbench?.kind === "chat" &&
typeof activeWorkbench.definitionId === "string"
typeof activeWorkbench.definitionId === "string" &&
!(activeWorkbench.participants ?? []).some(
(participant) => isAgentAddress(participant.address),
)
}
items={appendReplyTimedOutNotice(
mergeStreamingReply(
Expand Down Expand Up @@ -1545,7 +1549,7 @@ export function ChatWorkspace({
case "loading":
return (
<ChatWorkspaceFrame>
<Skeleton className="query-skeleton" />
<WorkbenchLoadingState />
</ChatWorkspaceFrame>
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type {
TimelineMessageItem,
} from "./timeline";

export { WorkbenchLoadingState } from "./loading-state";

export { PinnedStrip } from "./pinned-strip";
export {
Composer,
Expand Down
85 changes: 85 additions & 0 deletions packages/chat-ui/src/loading-state.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span key={index} className="chat-workbench-loading-tip" aria-live="polite">
{tips[index]}
</span>
);
}

/**
* 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 (
<div className={classNames.join(" ")} role="status">
<span className="chat-workbench-loading-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
<span className="chat-workbench-loading-title">{title}</span>
<WorkbenchLoadingTip />
</div>
);
}
2 changes: 2 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 20 additions & 36 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<span key={index} className="chat-workbench-loading-tip" aria-live="polite">
{tips[index]}
</span>
);
}

/** A workbench's scroll position, captured/restored across a
* `WorkbenchTimeline` unmount-remount (e.g. opening/closing Settings) — see
* `WorkbenchTimeline`'s `scrollRestore`/`onScrollSnapshot`. */
Expand Down Expand Up @@ -1660,17 +1636,25 @@ export function WorkbenchTimeline({
if (settingUpAgent === true) {
return (
<div className="chat-timeline-empty">
<div className="chat-workbench-loading" role="status">
<span className="chat-workbench-loading-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
<span className="chat-workbench-loading-title">
{CHAT_STRINGS.workbenchLoadingTitle}
</span>
<WorkbenchLoadingTip />
</div>
<WorkbenchLoadingState delayMs={0} />
</div>
);
}
// 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 (
<div className="chat-timeline-empty">
<EmptyState
icon={<MessageSquare />}
title={`Say hello to ${displayNameFromHandle(readyAgent.handle)}`}
description={CHAT_STRINGS.emptyAgentTimelineDescription}
/>
</div>
);
}
Expand Down
70 changes: 70 additions & 0 deletions packages/chat-ui/test/loading-state.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WorkbenchLoadingState delayMs={0} />);

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(<WorkbenchLoadingState delayMs={0} />);

expect(el.textContent).toContain(CHAT_STRINGS.workbenchLoadingTitle);
});

test("accepts a title override for a non-workbench surface", () => {
const el = mount(
<WorkbenchLoadingState delayMs={0} title="Loading routines…" />,
);

expect(el.textContent).toContain("Loading routines…");
});

test("renders nothing until the delay elapses (flash prevention)", async () => {
const el = mount(<WorkbenchLoadingState delayMs={200} />);

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