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
55 changes: 50 additions & 5 deletions apps/web/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
// behind us. Readiness is read only to tell a wait from a genuine failure,
// never to draw a progress number: a seed count is an implementation
// detail, and "0 of 5" told a waiting person nothing.
//
// CL-6780: that wait is for the agent, never a workbench that does not
// exist yet — so the loader says "Preparing your agent", and a skip with
// no credential stops pretending anything is "getting ready" and offers
// the honest next step (connect a provider) instead of spinning forever.

import { Button, EmptyState, PageShell } from "@corbits/react-ui";
import { Clock, WarningCircle } from "@corbits/icons";
Expand All @@ -29,26 +34,37 @@ import { useEffect, useState } from "react";
import { listAllWorkbenches, WorkbenchLoadingState } from "@corbits/chat-ui";
import { describeApiError } from "@corbits/api-query";

import { fetchAgentReadiness } from "../onboarding";
import { fetchAgentReadiness, hasActiveCredential } from "../onboarding";
import { useBench } from "../bench-context";
import { workbenchPath } from "../workbench-path";
import { ensureMyraWorkbench } from "../myra-workbench";
import { useNavigate } from "../navigation";
import { NEW_WORKBENCH_PATH } from "../routes";
import { NEW_WORKBENCH_PATH, ONBOARDING_PATH } from "../routes";

type LandState =
/** Working on it: the warm loader, whether we are reading the bench's
* workbenches or waiting for Myra to finish coming online. Both are
* the same thing to the person waiting. */
| { readonly kind: "opening" }
/** Zero workbenches, credential present, Myra not ready yet — the
* post-connect wait. Headline names the agent, never a workbench that
* does not exist (CL-6780). */
| { readonly kind: "waiting-for-agent" }
/** Myra has taken long enough that silence would read as a hang. Says
* so plainly and offers another go — never a frozen number. */
| { readonly kind: "slow" }
/** Zero workbenches and no active credential: the drain never starts,
* so waiting on "ready" would spin forever. Offer the connect step. */
| { readonly kind: "needs-provider" }
| { readonly kind: "error"; readonly message: string };

const LAND_RETRY_MS = 3_000;
const LAND_STALL_MS = 45_000;

/** Warm-loader headline for the post-onboarding wait on `/` when no
* workbench exists yet — Myra is coming online, not a workbench. */
const PREPARING_AGENT_TITLE = "Preparing your agent";

export function HomeRoute({
retryMs = LAND_RETRY_MS,
stallAfterMs = LAND_STALL_MS,
Expand Down Expand Up @@ -105,15 +121,27 @@ export function HomeRoute({
// the person to the picker rather than minting anything ourselves —
// "she can't start yet" and "here, go create your first workbench"
// are different messages, and only the readiness check tells them
// apart.
// apart. Without a credential the drain never starts (CL-6780), so a
// not-ready status with no credential is an honest next step, not a
// forever spin on "getting ready".
const awaitFirstWorkbench = () => {
void fetchAgentReadiness().then((readiness) => {
if (cancelled) return;
if (readiness.kind === "ready" || readiness.kind === "chat-ready") {
navigate(NEW_WORKBENCH_PATH);
return;
}
waitAndRetry();
void hasActiveCredential(selectedTenantId).then((hasCredential) => {
if (cancelled) return;
if (!hasCredential) {
setState({ kind: "needs-provider" });
return;
}
setState((current) =>
current.kind === "slow" ? current : { kind: "waiting-for-agent" },
);
waitAndRetry();
});
});
};

Expand Down Expand Up @@ -202,6 +230,23 @@ export function HomeRoute({
);
}

if (state.kind === "needs-provider") {
return (
<PageShell width="full" className="page-fill">
<EmptyState
icon={<WarningCircle />}
title="Connect a provider"
description="Agents need a provider before they can come online. Connect one to finish setup."
action={
<Button variant="primary" onClick={() => navigate(ONBOARDING_PATH)}>
Connect a provider
</Button>
}
/>
</PageShell>
);
}

if (state.kind === "slow") {
return (
<PageShell width="full" className="page-fill">
Expand All @@ -221,7 +266,7 @@ export function HomeRoute({

return (
<div className="page-fill shell-route-loading">
<WorkbenchLoadingState delayMs={0} />
<WorkbenchLoadingState delayMs={0} title={PREPARING_AGENT_TITLE} />
</div>
);
}
11 changes: 4 additions & 7 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,12 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
return (
<OnboardingLayout>
<div className="onboarding-phase" key="provisioning">
<h1 className="onboarding-title">Setting up your workbench</h1>
<h1 className="onboarding-title">Preparing your account</h1>
<p className="onboarding-subtitle">One moment.</p>
<div className="onboarding-content">
<WorkbenchLoadingState
delayMs={0}
title="Setting up your workbench…"
title="Preparing your account…"
/>
</div>
</div>
Expand All @@ -398,15 +398,12 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
return (
<OnboardingLayout>
<div className="onboarding-phase" key="finishing-setup">
<h1 className="onboarding-title">Setting up your workbench…</h1>
<h1 className="onboarding-title">Preparing your agent</h1>
<p className="onboarding-subtitle">
Hooking up your agents. This takes about ten seconds.
</p>
<div className="onboarding-content">
<WorkbenchLoadingState
delayMs={0}
title="Setting up your workbench…"
/>
<WorkbenchLoadingState delayMs={0} title="Preparing your agent…" />
</div>
</div>
</OnboardingLayout>
Expand Down
45 changes: 44 additions & 1 deletion apps/web/test/home-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ describe("the wait right after connecting a provider", () => {
if (path.endsWith("/chat/workbenches") && method === "GET") {
return json({ items: [] });
}
// CL-6780: while Myra is still coming online we also confirm a
// credential exists — without one the drain never starts, so the
// wait must not pretend a workbench is "getting ready".
if (path === "/api/tenants/tnt_1/credentials") {
return json({ data: [{ status: "active" }], nextCursor: null });
}
if (path === "/api/onboarding/provisioning-status") {
statusReads += 1;
state.statusCalls += 1;
Expand Down Expand Up @@ -265,13 +271,50 @@ describe("the wait right after connecting a provider", () => {
}

const text = container?.textContent ?? "";
expect(text).toContain("Getting your workbench ready");
// CL-6780: zero workbenches yet — this wait is for the agent, not a
// workbench that does not exist.
expect(text).toContain("Preparing your agent");
expect(text).not.toContain("Getting your workbench ready");
expect(text).toContain("Tip:");
expect(text).not.toMatch(/\d+ of \d+/);
expect(text).not.toMatch(/\d/);
expect(navigated).toEqual([]);
});

test("skip with no credential lands on an honest next step instead of stuck workbench-ready copy (CL-6780)", async () => {
stubFetch((path, method) => {
if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE);
if (path.endsWith("/chat/workbenches") && method === "GET") {
return json({ items: [] });
}
if (path === "/api/tenants/tnt_1/credentials") {
return json({ data: [], nextCursor: null });
}
if (path === "/api/onboarding/provisioning-status") {
return json({
kind: "provisioning",
setupAgentReady: false,
deployed: [],
pending: ["assistant"],
});
}
throw new Error(`unexpected fetch: ${method} ${path}`);
});

const navigated: string[] = [];
await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated });
for (let i = 0; i < 40; i++) {
await settle();
const text = container?.textContent ?? "";
if (text.includes("Connect a provider") || navigated.length > 0) break;
}

const text = container?.textContent ?? "";
expect(text).not.toContain("Getting your workbench ready");
expect(text).toContain("Connect a provider");
expect(navigated).toEqual([]);
});

test("lands the moment Myra can answer, without waiting on the rest of the seeds", async () => {
// The status route still says "provisioning" — other workflows are
// mid-deploy — and the land happens anyway.
Expand Down
15 changes: 12 additions & 3 deletions apps/web/test/onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,10 @@ describe("App at the onboarding path", () => {
// default name derived from the account, before the credential step.
// Onboarding is down to a single screen at this point (CL-6104) — no
// stepper needed for one step.
expect(markup).toContain("Setting up your workbench");
// CL-6780: this prepares the account; no workbench exists yet.
expect(markup).toContain("Preparing your account");
expect(markup).not.toContain("Getting your workbench ready");
expect(markup).not.toContain("Setting up your workbench");
});
});

Expand Down Expand Up @@ -754,11 +757,14 @@ describe("the OpenRouter connect card", () => {
// OAuth callback only stored the key, so the checklist showing
// "confirmed running" before the deploy step even started would be
// a lie.
// CL-6780: finishing-setup prepares the agent; no workbench exists yet.
const markup = renderOnboardingAt(
"/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1",
);

expect(markup).toContain("Setting up your workbench");
expect(markup).toContain("Preparing your agent");
expect(markup).not.toContain("Getting your workbench ready");
expect(markup).not.toContain("Setting up your workbench");
expect(markup).not.toContain("Your workbench is ready");
});

Expand Down Expand Up @@ -1045,7 +1051,10 @@ describe("the Hugging Face connect card", () => {
"/onboarding?connect=huggingface&outcome=connected&tenantSlug=ada-user1",
);

expect(markup).toContain("Setting up your workbench");
// CL-6780: finishing-setup prepares the agent; no workbench exists yet.
expect(markup).toContain("Preparing your agent");
expect(markup).not.toContain("Getting your workbench ready");
expect(markup).not.toContain("Setting up your workbench");
expect(markup).not.toContain("Your workbench is ready");
});

Expand Down
Loading