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
14 changes: 8 additions & 6 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ function Root() {

// The first-login hook: once per session that reaches signed-in, ask
// the hub whether this is a session with zero principals anywhere.
// Idempotent on the hub side, so re-running it on a page reload for
// an existing member costs one read and nothing else. A failure here
// blocks the shell entirely — a signed-in user with no bench and a
// failed provisioning attempt has nothing useful to do in the app.
// Without a display name the hub does not mint a bench — it returns
// needs-onboarding so we route into the naming wizard. Existing members
// cost one read. A failure blocks the shell entirely.
const [provisioningError, setProvisioningError] = useState<string | null>(
null,
);
Expand All @@ -56,8 +55,11 @@ function Root() {
setProvisioningError(null);
void triggerFirstLoginProvisioning().then((result) => {
if (cancelled) return;
if (result.kind === "provisioned") navigate(ONBOARDING_PATH);
else if (result.kind === "error") setProvisioningError(result.message);
if (result.kind === "needs-onboarding" || result.kind === "provisioned") {
navigate(ONBOARDING_PATH);
} else if (result.kind === "error") {
setProvisioningError(result.message);
}
});
return () => {
cancelled = true;
Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// The browser side of the first-login hook: one call against the
// hub's native onboarding route, made once per session. A session with
// zero principals anywhere gets a personal bench provisioned server-side
// (see @workbench/onboarding); this just learns whether that happened
// so the interface can route into the onboarding placeholder — and
// distinguishes a real failure from "nothing to do", so a broken
// provisioning call never leaves the user silently benchless.
// zero principals and no display name is reported as needs-onboarding so
// the UI can route into the naming wizard; only an explicit name creates
// the personal bench. Distinguishes real failures from "nothing to do",
// so a broken provisioning call never leaves the user silently benchless.

import { type } from "arktype";

const ProvisionResult = type({
kind: "'existing-member' | 'provisioned'",
kind: "'existing-member' | 'provisioned' | 'needs-onboarding'",
"tenantId?": "string",
"tenantSlug?": "string",
"seeded?": "boolean",
Expand All @@ -22,6 +21,7 @@ const ErrorEnvelope = type({

export type ProvisionOutcome =
| { readonly kind: "existing-member" }
| { readonly kind: "needs-onboarding" }
| {
readonly kind: "provisioned";
readonly tenantId: string;
Expand All @@ -31,10 +31,18 @@ export type ProvisionOutcome =
}
| { readonly kind: "error"; readonly message: string };

export async function triggerFirstLoginProvisioning(): Promise<ProvisionOutcome> {
export async function triggerFirstLoginProvisioning(
displayName?: string,
): Promise<ProvisionOutcome> {
try {
const response = await fetch("/api/onboarding/provision", {
method: "POST",
...(displayName !== undefined
? {
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: displayName }),
}
: {}),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
Expand All @@ -55,6 +63,7 @@ export async function triggerFirstLoginProvisioning(): Promise<ProvisionOutcome>
};
}
if (parsed.kind === "existing-member") return { kind: "existing-member" };
if (parsed.kind === "needs-onboarding") return { kind: "needs-onboarding" };
if (
parsed.tenantId === undefined ||
parsed.tenantSlug === undefined ||
Expand Down
155 changes: 121 additions & 34 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// First-run, end to end: land here fresh with no usable inference
// credential, add one for real, and watch the default routines fire.
// The heavy lifting — proving the key with a real call, seeding the
// bench, deploying and confirming every default workflow — all happens
// server-side in `@workbench/onboarding`; this page is the guided
// wizard around it. A session that already has a seeded bench (an
// operator-configured seed key, or a returning member) skips straight
// to the orientation cards this screen always ended with.
// First-run wizard in three steps: name your workbench, add an
// inference credential, then get oriented. The heavy lifting — proving
// the key with a real call, seeding the bench, deploying and confirming
// every default workflow — happens server-side in `@workbench/onboarding`;
// this page is the guided shell around it. The credential step is always
// part of the flow: when the server already has a usable seed (an
// operator-configured key, or a returning member) it renders
// pre-satisfied with a skip option rather than branching into a
// different tree that hides the step entirely.

import {
Button,
Expand All @@ -26,11 +27,11 @@ import {
AtSign,
Bot,
CircleAlert,
CircleCheck,
KeyRound,
Library,
MessageSquare,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import type { FormEvent } from "react";

import { Link, useNavigate } from "../navigation";
Expand All @@ -46,19 +47,13 @@ const GUIDANCE_CARDS = [
icon: <MessageSquare />,
title: "Channels",
description:
"Conversations with your team and your agents live in channels, the same way threads doa starter channel is ready for you below.",
"Conversations with your team and your agents live in channels. Your starter channel is readyhead there to send your first message.",
},
{
icon: <Bot />,
title: "Routines",
description:
"A routine is a workflow an agent runs on your behalf — scheduled, triggered, or kicked off right from chat. Runs show up under Runs as they execute.",
},
{
icon: <Library />,
title: "Library",
description:
"Every workflow definition running anywhere in your benches is browsable in the Library, so you can see what a routine actually does before trusting it.",
"A routine is a workflow an agent runs on your behalf — scheduled, triggered, or kicked off right from a channel. Your bench ships with a couple of starter routines already running.",
},
{
icon: <AtSign />,
Expand All @@ -78,7 +73,8 @@ function routineLabel(assetName: string): string {
}

type WizardState =
| { readonly phase: "loading" }
| { readonly phase: "naming" }
| { readonly phase: "provisioning" }
| { readonly phase: "provisioning-error"; readonly message: string }
| { readonly phase: "credential"; readonly error: string | null }
| { readonly phase: "submitting" }
Expand Down Expand Up @@ -106,11 +102,20 @@ function GuidanceCards() {
}

function wizardSteps(phase: WizardState["phase"]): WorkflowStep[] {
const nameDone = phase !== "naming";
const credentialDone = phase === "seeded" || phase === "guidance";
const credentialCurrent = phase === "credential" || phase === "submitting";
const credentialCurrent =
phase === "credential" ||
phase === "submitting" ||
phase === "provisioning";
return [
{
number: 1,
label: "Name your workbench",
status: nameDone ? "completed" : "current",
},
{
number: 2,
label: "Add a credential",
status: credentialDone
? "completed"
Expand All @@ -119,13 +124,13 @@ function wizardSteps(phase: WizardState["phase"]): WorkflowStep[] {
: "pending",
},
{
number: 2,
number: 3,
label: "Run your first routine",
status:
phase === "seeded"
? "completed"
: credentialCurrent
? "pending"
: phase === "guidance"
? "completed"
: "pending",
},
];
Expand Down Expand Up @@ -166,25 +171,52 @@ function ProviderPicker({

export function OnboardingPage() {
const navigate = useNavigate();
const [state, setState] = useState<WizardState>({ phase: "loading" });
const [state, setState] = useState<WizardState>({ phase: "naming" });
const [workbenchName, setWorkbenchName] = useState("");
const [provider, setProvider] = useState<CredentialProvider>("anthropic");
const [apiKey, setApiKey] = useState("");
// Whether the server already had a usable seed when we provisioned.
// Kept out of WizardState so a failed own-key submit doesn't wipe the
// skip option — the credential step stays in place either way.
const [preSatisfied, setPreSatisfied] = useState(false);
const [skipReason, setSkipReason] = useState<string | null>(null);

const runProvisioning = useCallback(() => {
setState({ phase: "loading" });
void triggerFirstLoginProvisioning().then((result) => {
const runProvisioning = useCallback((name: string) => {
setState({ phase: "provisioning" });
void triggerFirstLoginProvisioning(name).then((result) => {
if (result.kind === "error") {
setState({ phase: "provisioning-error", message: result.message });
} else if (result.kind === "existing-member") {
setState({ phase: "guidance" });
} else if (result.seeded) {
setState({ phase: "guidance" });
} else {
setPreSatisfied(true);
setSkipReason(null);
setState({ phase: "credential", error: null });
} else if (result.kind === "provisioned" && result.seeded) {
setPreSatisfied(true);
setSkipReason(result.seedSkipReason ?? null);
setState({ phase: "credential", error: null });
} else if (result.kind === "provisioned") {
setPreSatisfied(false);
setSkipReason(null);
setState({ phase: "credential", error: null });
} else {
// needs-onboarding after an explicit name should not happen; treat
// as a soft error so the user can retry naming.
setState({
phase: "provisioning-error",
message:
"The hub did not create your workbench. Try a different name.",
});
}
});
}, []);
useEffect(runProvisioning, [runProvisioning]);

const handleNameSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
runProvisioning(workbenchName);
},
[runProvisioning, workbenchName],
);

const handleSubmitCredential = useCallback(
(event: FormEvent<HTMLFormElement>) => {
Expand All @@ -198,14 +230,48 @@ export function OnboardingPage() {
workflows: outcome.workflows,
});
} else {
// preSatisfied is intentionally preserved: a bad own-key
// submit must not remove the skip path the server seed gave.
setState({ phase: "credential", error: outcome.message });
}
});
},
[provider, apiKey],
);

if (state.phase === "loading") {
if (state.phase === "naming") {
return (
<PageShell width="full" className="page-fill">
<Section
title="Create your workbench"
description="Give your workbench a name. This labels your personal bench across the app — you can change it later."
>
<HorizontalStepper steps={wizardSteps(state.phase)} />
<form onSubmit={handleNameSubmit} className="onboarding-name-form">
<label htmlFor="onboarding-workbench-name">Workbench name</label>
<Input
id="onboarding-workbench-name"
type="text"
placeholder="e.g. Ada's bench"
value={workbenchName}
onChange={(event) => setWorkbenchName(event.target.value)}
required
aria-describedby="onboarding-workbench-name-help"
autoFocus
/>
<p id="onboarding-workbench-name-help">
Used as the display name for your bench.
</p>
<Button type="submit" disabled={workbenchName.trim().length === 0}>
Continue
</Button>
</form>
</Section>
</PageShell>
);
}

if (state.phase === "provisioning") {
return (
<PageShell width="full" className="page-fill">
<Section title="Setting up your workbench" description="One moment.">
Expand All @@ -223,7 +289,10 @@ export function OnboardingPage() {
title="Couldn't set up your workbench"
description={state.message}
action={
<Button variant="outline" onClick={runProvisioning}>
<Button
variant="outline"
onClick={() => runProvisioning(workbenchName)}
>
Try again
</Button>
}
Expand Down Expand Up @@ -282,6 +351,24 @@ export function OnboardingPage() {
description="Your workbench needs an inference credential before any agent or routine can run. Pick a provider and paste your own key — it's used only for this bench."
>
<HorizontalStepper steps={wizardSteps(state.phase)} />
{preSatisfied && (
<EmptyState
icon={<CircleCheck />}
title="A working key is already in place"
description={
skipReason ??
"An operator-configured credential is set, so agents and routines can run right away. Add your own key below to use it instead, or skip ahead to your channel."
}
action={
<Button
variant="outline"
onClick={() => setState({ phase: "guidance" })}
>
Skip — use the default key
</Button>
}
/>
)}
<form
onSubmit={handleSubmitCredential}
className="onboarding-credential-form"
Expand Down
Loading
Loading