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
20 changes: 20 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,26 @@ select:disabled,
color: var(--card-foreground);
}

/* The post-landing setup line (CL-6462): quieter than the health banner
above it — no icon, muted text — because nothing is wrong and nothing
is being asked of the reader. */
.setup-progress-note {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--background);
flex-shrink: 0;
}

.setup-progress-note-text {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
color: var(--muted-foreground);
}

/* The Plugins gallery's own "couldn't find that connection" notice
(CL-6092): a deep link from the shell banner that names a provider the
gallery has no card for. */
Expand Down
54 changes: 29 additions & 25 deletions apps/web/src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,41 +589,45 @@ export async function completeSetup(): Promise<CompleteSetupOutcome> {

const ProvisioningStatus = type({
kind: "'ready' | 'provisioning'",
deployed: "string[]",
pending: "string[]",
setupAgentReady: "boolean",
});

export type ProvisioningProgress = {
readonly ready: boolean;
/** How many of this bench's agents are live, and how many there are in
* total — the numbers a waiting surface shows so the wait reads as
* progress rather than a frozen label. */
readonly live: number;
readonly total: number;
};
/**
* Whether this account can start a conversation yet, and whether
* anything is still coming online behind it (CL-6462). Deliberately not
* a count: how many workflows a bench seeds is an implementation detail,
* and a person watching "0 of 5" learns nothing they can act on.
*
* - `ready` — everything this bench seeds is live.
* - `chat-ready` — Myra is live, so the person can start now; the rest
* converge in the background.
* - `preparing` — Myra is not live yet; this is the only state worth
* holding someone on a loader for.
* - `unknown` — we could not tell (offline, a hiccup, an account with no
* personal bench). Never rendered as either progress or failure.
*/
export type AgentReadiness =
| { readonly kind: "ready" }
| { readonly kind: "chat-ready" }
| { readonly kind: "preparing" }
| { readonly kind: "unknown" };

/**
* Where this account's agents stand right now. Cheap and read-only, so a
* surface that has to wait may poll it on a short interval. An
* unreachable or unparseable answer reports "not ready yet" rather than
* throwing: a hiccup in a progress check must never turn into an error
* screen over work that is, in fact, still progressing fine.
* surface that has to wait may poll it on a short interval.
*/
export async function fetchProvisioningProgress(): Promise<ProvisioningProgress> {
export async function fetchAgentReadiness(): Promise<AgentReadiness> {
try {
const response = await fetch("/api/onboarding/provisioning-status");
if (!response.ok) return { kind: "unknown" };
const body: unknown = await response.json().catch(() => null);
if (!response.ok) return { ready: false, live: 0, total: 0 };
const parsed = ProvisioningStatus(body);
if (parsed instanceof type.errors) {
return { ready: false, live: 0, total: 0 };
}
return {
ready: parsed.kind === "ready",
live: parsed.deployed.length,
total: parsed.deployed.length + parsed.pending.length,
};
if (parsed instanceof type.errors) return { kind: "unknown" };
if (parsed.kind === "ready") return { kind: "ready" };
return parsed.setupAgentReady
? { kind: "chat-ready" }
: { kind: "preparing" };
} catch {
return { ready: false, live: 0, total: 0 };
return { kind: "unknown" };
}
}
167 changes: 84 additions & 83 deletions apps/web/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@
// creates) the Myra workbench in the main stage, unchanged. Home as a
// dashboard does not earn its keep — `/` only exists as this hop onto
// `/w/:workbenchId`. Deep links to other pages are unchanged.
//
// Right after a provider connect this hop is also the wait (CL-6457's
// deploys run in the background, so landing here can beat them). CL-6462
// settled what that wait looks like: one warm loader and nothing else.
// The land is simply attempted again every few seconds, because launching
// Myra IS the test of whether the person can start — she is deployed
// first (`SETUP_AGENT_ASSET_NAME` leads `DEFAULT_WORKFLOWS`), so the
// moment she answers we go, with every other seeded workflow still
// converging 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.

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

import { fetchProvisioningProgress } from "../onboarding";
import { fetchAgentReadiness } from "../onboarding";
import { useBench } from "../bench-context";
import { workbenchPath } from "../workbench-path";
import { createAgentAndLaunch } from "../instant-agent-create";
import { ensureMyraWorkbench } from "../myra-workbench";
import { useNavigate } from "../navigation";

type LandState =
| { readonly kind: "checking" }
/** The bench exists and its credential is connected, but its agents
* are still being deployed in the background (CL-6457). Landing here
* is expected right after someone connects a provider — connecting
* deliberately no longer waits for deploys — so this is a warm wait
* with live progress, never an error. */
| {
readonly kind: "provisioning";
readonly live: number;
readonly total: number;
}
/** 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" }
/** 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" }
| { readonly kind: "error"; readonly message: string };

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

export function HomeRoute() {
export function HomeRoute({
retryMs = LAND_RETRY_MS,
stallAfterMs = LAND_STALL_MS,
}: {
/** Timing seams. Production passes neither. */
readonly retryMs?: number;
readonly stallAfterMs?: number;
} = {}) {
const navigate = useNavigate();
const { selectedTenantId, memberships } = useBench();
const [state, setState] = useState<LandState>({ kind: "checking" });
const [retryCount, setRetryCount] = useState(0);
const [state, setState] = useState<LandState>({ kind: "opening" });
const [attempt, setAttempt] = useState(0);
const [waitId, setWaitId] = useState(0);

const startOver = () => {
setState({ kind: "opening" });
setAttempt(0);
setWaitId((id) => id + 1);
};

useEffect(() => {
if (selectedTenantId === null) return;
let cancelled = false;
setState({ kind: "checking" });
let retryTimer: ReturnType<typeof setTimeout> | undefined;

const waitAndRetry = () => {
if (cancelled) return;
if ((attempt + 1) * retryMs >= stallAfterMs) {
setState({ kind: "slow" });
return;
}
retryTimer = setTimeout(() => setAttempt((count) => count + 1), retryMs);
};

// A land that failed is either "she isn't up yet" or a real problem,
// and only the bench itself can say which.
const classify = (cause: unknown) => {
void fetchAgentReadiness().then((readiness) => {
if (cancelled) return;
if (readiness.kind === "ready" || readiness.kind === "chat-ready") {
setState({
kind: "error",
message: describeApiError(cause, "opening Myra"),
});
return;
}
waitAndRetry();
});
};

void listAllWorkbenches(selectedTenantId).then(
(workbenches) => {
if (cancelled) return;
if (workbenches.length === 0) {
createAgentAndLaunch(selectedTenantId, navigate).catch(
(cause: unknown) => {
if (cancelled) return;
// Myra cannot be launched if she has not finished
// deploying yet. Rather than reading that off an error
// message, ask the bench where its agents actually are: a
// bench still provisioning is someone waiting, not someone
// broken.
void fetchProvisioningProgress().then((progress) => {
if (cancelled) return;
setState(
progress.ready
? {
kind: "error",
message: describeApiError(cause, "opening Myra"),
}
: {
kind: "provisioning",
live: progress.live,
total: progress.total,
},
);
});
},
);
createAgentAndLaunch(selectedTenantId, navigate).catch(classify);
return;
}
void ensureMyraWorkbench(selectedTenantId).then((result) => {
Expand All @@ -85,7 +109,7 @@ export function HomeRoute() {
navigate(workbenchPath(result.workbenchId));
return;
}
setState({ kind: "error", message: result.message });
classify(new Error(result.message));
});
},
(cause: unknown) => {
Expand All @@ -96,36 +120,12 @@ export function HomeRoute() {
});
},
);
return () => {
cancelled = true;
};
}, [selectedTenantId, navigate, retryCount]);

// While agents are still deploying, keep asking — and the moment the
// bench is ready, re-run the land so the person drops straight into
// Myra without touching anything.
useEffect(() => {
if (state.kind !== "provisioning") return;
let cancelled = false;
const timer = setInterval(() => {
void fetchProvisioningProgress().then((progress) => {
if (cancelled) return;
if (progress.ready) {
setRetryCount((count) => count + 1);
return;
}
setState({
kind: "provisioning",
live: progress.live,
total: progress.total,
});
});
}, PROVISIONING_POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
}, [state.kind]);
}, [selectedTenantId, navigate, attempt, waitId, retryMs, stallAfterMs]);

if (memberships.kind === "loading") {
return (
Expand Down Expand Up @@ -172,10 +172,7 @@ export function HomeRoute() {
title="Couldn't open Myra"
description={state.message}
action={
<Button
variant="outline"
onClick={() => setRetryCount((count) => count + 1)}
>
<Button variant="outline" onClick={startOver}>
Retry
</Button>
}
Expand All @@ -184,22 +181,26 @@ export function HomeRoute() {
);
}

if (state.kind === "provisioning") {
if (state.kind === "slow") {
return (
<div className="page-fill shell-route-loading">
<WorkbenchLoadingState title="Getting your agents ready…" delayMs={0} />
<p className="shell-route-loading-note" role="status">
{state.total > 0
? `${state.live} of ${state.total} ready`
: "This only takes a moment."}
</p>
</div>
<PageShell width="full" className="page-fill">
<EmptyState
icon={<WarningCircle />}
title="Myra is taking longer than usual"
description="She's still getting set up. Give it another moment, or try again."
action={
<Button variant="outline" onClick={startOver}>
Try again
</Button>
}
/>
</PageShell>
);
}

return (
<div className="page-fill shell-route-loading">
<WorkbenchLoadingState />
<WorkbenchLoadingState delayMs={0} />
</div>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from "../onboarding";
import type { CredentialProvider, CredentialProviderCard } from "../onboarding";
import { OnboardingLayout } from "../onboarding/onboarding-layout";
import { markSetupInProgress } from "../shell/setup-progress-note";
import type { SessionUser } from "../session";

/** No naming step means provisioning always needs a name to send — this
Expand Down Expand Up @@ -296,6 +297,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
if (state.phase === "finishing-setup") {
void completeSetup().then((outcome) => {
if (outcome.kind === "connected") {
if (outcome.agentsPending) markSetupInProgress();
navigate("/");
} else if (outcome.kind === "unseeded") {
setResumingUnseeded(true);
Expand Down Expand Up @@ -348,6 +350,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
// moves on now, and the warm loading state on the other side
// covers whatever is still coming online.
if (outcome.kind === "connected") {
if (outcome.agentsPending) markSetupInProgress();
navigate("/");
} else {
setState(
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
useToggleCanvasFocus,
} from "./canvas-availability";
import { ProviderHealthBanner } from "./provider-health-banner";
import { SetupProgressNote } from "./setup-progress-note";
import { Sidebar } from "./sidebar";
import { ShellContextMenu } from "./context-menu/shell-context-menu";

Expand Down Expand Up @@ -172,6 +173,7 @@ export function AppShell({
<div className="shell-main" ref={mainRef}>
<div className="shell-main-content">
<ProviderHealthBanner />
<SetupProgressNote />
{routeHasNoStageTopBar(path) ? (
<StageTopBar
crumbs={[{ label: routeLabel(path) }]}
Expand Down
Loading
Loading