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
2 changes: 1 addition & 1 deletion apps/web/src/chat/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export const CHAT_STRINGS = {
turnCancelledTitle: (sender: string) => `You stopped ${sender}'s turn`,
turnFailedSub: "No reply arrived — the agent may be unavailable.",
noUsableModelBannerText: "No model is connected yet, so a reply here won't come through.",
noUsableModelBannerAction: "Connect a model",
noUsableModelBannerAction: "Connect a provider",
rowMenuLabel: "Conversation actions",
rowMenuRename: "Rename",
rowMenuPin: "Pin",
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/deferred-myra-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Finishes the setup a skipped provider step left undone, once a model
// exists: the same publish-and-install the onboarding page runs.

import { reportError } from "@corbits/error-sink";

import { runPortableClientBootstrap } from "./client-bootstrap";
import { deployMyraSource } from "./myra-deploy";
import { createFetchStockHub, findOwnedTenants } from "./needs-converge";
import { resolveExistingOffering } from "./onboarding/provider-connect-step";
import { isProviderSkipped, setProviderSkipped } from "./provider-skip";
import type { SessionUser } from "./session";

let inFlight: Promise<boolean> | null = null;

/** True once Myra is deployed and the skip flag is cleared; false while
* there is nothing to do yet (no skip, or no model connected). */
export function finishDeferredMyraSetup(user: SessionUser): Promise<boolean> {
if (!isProviderSkipped(user.id)) return Promise.resolve(false);
inFlight ??= run(user).finally(() => {
inFlight = null;
});
return inFlight;
}

async function run(user: SessionUser): Promise<boolean> {
try {
const owned = await findOwnedTenants(createFetchStockHub());
const primary = owned.find((tenant) => tenant.parentId === null);
if (primary === undefined) return false;
const offering = await resolveExistingOffering(primary.id);
if (offering === null) return false;
const myraDeploy = await deployMyraSource({
tenantId: primary.id,
tenantDomain: primary.domain,
sourceOfferingIds: offering.sourceOfferingIds,
defaultSourceOfferingId: offering.defaultSourceOfferingId,
declaredSources: offering.declaredSources,
});
const result = await runPortableClientBootstrap(user, { myraDeploy });
if (result.kind !== "ready") return false;
setProviderSkipped(user.id, false);
return true;
} catch (cause) {
reportError(cause, { operation: "deferred_myra_setup" });
return false;
}
}
2 changes: 2 additions & 0 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useBench } from "../bench-context";
import { workbenchKeys } from "../chat-path";
import { createWorkbench, WorkbenchCreateError } from "../workbench-create";
import { useNavigate } from "../navigation";
import { ProviderSkipBanner } from "../provider-skip-banner";
import { StageTopBar } from "../shell/stage-top-bar";
import { workbenchPath } from "../workbench-path";
import "./new-workbench.css";
Expand Down Expand Up @@ -98,6 +99,7 @@ export function NewWorkbenchPickerRoute() {
<WorkbenchLoadingState delayMs={0} title="Setting up your workbench…" />
) : (
<>
<ProviderSkipBanner />
<h1 className="new-title">What should this workbench do?</h1>
<form
className="new-composer"
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { deployMyraSource } from "../myra-deploy";
import { useNavigate } from "../navigation";
import { NEW_WORKBENCH_PATH } from "../routes";
import { triggerFirstLoginProvisioning } from "../onboarding";
import { setProviderSkipped } from "../provider-skip";
import { OnboardingLayout } from "../onboarding/onboarding-layout";
import {
ProviderConnectStep,
Expand Down Expand Up @@ -42,6 +43,12 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
const [state, setState] = useState<GateState>({ phase: "checking" });
const installRef = useRef<ReturnType<typeof runPortableClientBootstrap> | null>(null);

// Remembered so a reload lands in the shell instead of back here.
function skipForNow() {
setProviderSkipped(user.id, true);
navigate(NEW_WORKBENCH_PATH);
}

// One status read per landing (plus each manual recheck): a hub that
// already has tenants means setup is done; an empty hub starts the
// converge loop immediately rather than showing a dead-end panel.
Expand Down Expand Up @@ -228,7 +235,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
});
}}
onError={(message) => setState({ phase: "error", message })}
onSkip={() => navigate(NEW_WORKBENCH_PATH)}
onSkip={skipForNow}
/>
</div>
</div>
Expand Down Expand Up @@ -262,7 +269,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
<Button variant="primary" onClick={checkStatus}>
Check again
</Button>
<Button variant="ghost" onClick={() => navigate(NEW_WORKBENCH_PATH)}>
<Button variant="ghost" onClick={skipForNow}>
Skip for now
</Button>
</div>
Expand Down Expand Up @@ -297,7 +304,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
<Button variant="primary" onClick={checkStatus}>
Try again
</Button>
<Button variant="ghost" onClick={() => navigate(NEW_WORKBENCH_PATH)}>
<Button variant="ghost" onClick={skipForNow}>
Skip for now
</Button>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/pages/workbench-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { createFetchStockHub } from "../needs-converge";
import { workbenchKeys } from "../chat-path";
import { tenantKeys } from "../query-client";
import { recordLastWorkbenchId } from "../last-workbench";
import { ProviderSkipBanner } from "../provider-skip-banner";
import { redeployWorkbenchAgent } from "../workbench-create";
import { workbenchIdFromPath } from "../workbench-path";

Expand Down Expand Up @@ -298,6 +299,7 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }
</div>
<div className="workbench-main-composer">
<PageShell width="prose" className="page-fill">
<ProviderSkipBanner />
<Composer
placeholder={
startingAgent === undefined
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/provider-skip-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NoUsableModelBanner } from "@/chat";

import { useNavigate } from "./navigation";
import { useProviderSkipped } from "./provider-skip";

export const PROVIDER_SETTINGS_PATH = "/settings/credentials";

/** Shown wherever a model is required, until a provider is connected. */
export function ProviderSkipBanner() {
const navigate = useNavigate();
const skipped = useProviderSkipped();
if (!skipped) return null;
return <NoUsableModelBanner onConnectModel={() => navigate(PROVIDER_SETTINGS_PATH)} />;
}
44 changes: 44 additions & 0 deletions apps/web/src/provider-skip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// A person who skips the provider step has a working shell and no Myra yet.
// The flag is per user and client-side only; it never reaches the hub.

import { reportError } from "@corbits/error-sink";
import { useSyncExternalStore } from "react";

import { useSessionUser } from "./navigation";

const listeners = new Set<() => void>();

function keyFor(userId: string): string {
return `corbits-provider-skipped:${userId}`;
}

export function isProviderSkipped(userId: string): boolean {
try {
return localStorage.getItem(keyFor(userId)) === "1";
} catch (cause) {
reportError(cause, { operation: "provider_skip.read" });
return false;
}
}

export function setProviderSkipped(userId: string, skipped: boolean): void {
try {
if (skipped) localStorage.setItem(keyFor(userId), "1");
else localStorage.removeItem(keyFor(userId));
} catch (cause) {
reportError(cause, { operation: "provider_skip.write" });
}
for (const listener of listeners) listener();
}

function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

export function useProviderSkipped(): boolean {
const user = useSessionUser();
return useSyncExternalStore(subscribe, () =>
user === undefined ? false : isProviderSkipped(user.id),
);
}
13 changes: 10 additions & 3 deletions apps/web/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore
import { getLogger } from "@/lib/client-log";
import { App } from "./app";
import { validatedNextPath } from "./login-next";
import { finishDeferredMyraSetup } from "./deferred-myra-setup";
import { triggerFirstLoginProvisioning } from "./onboarding";
import { isProviderSkipped } from "./provider-skip";
import { getPath, navigateTo, subscribeToPath } from "./router-store";
import { ONBOARDING_PATH } from "./routes";
import { fetchSession, signOut } from "./session";
Expand Down Expand Up @@ -44,14 +46,19 @@ export function Root() {
message: string;
refId?: string | undefined;
} | null>(null);
const provisionedUserId = session.kind === "signed-in" ? session.user.id : null;
const provisionedUser = session.kind === "signed-in" ? session.user : null;
const runProvisioning = useCallback(() => {
if (provisionedUserId === null) return () => undefined;
if (provisionedUser === null) return () => undefined;
let cancelled = false;
setProvisioningError(null);
void triggerFirstLoginProvisioning().then((result) => {
if (cancelled) return;
if (result.kind === "needs-onboarding") {
// A skipped provider step keeps the shell; Myra deploys once a model exists.
if (isProviderSkipped(provisionedUser.id)) {
void finishDeferredMyraSetup(provisionedUser);
return;
}
navigate(ONBOARDING_PATH);
} else if (result.kind === "error") {
setProvisioningError({ message: result.message, refId: result.refId });
Expand All @@ -60,7 +67,7 @@ export function Root() {
return () => {
cancelled = true;
};
}, [provisionedUserId, navigate]);
}, [provisionedUser, navigate]);
useEffect(runProvisioning, [runProvisioning]);
const handleRetryProvisioning = useCallback(() => {
runProvisioning();
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/settings/credentials-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
} from "@/settings/inference";
import { redeployMyraForModelChange } from "@/settings/myra-model-redeploy";
import { tenantKeys } from "@/query-client";
import { finishDeferredMyraSetup } from "@/deferred-myra-setup";
import { useSessionUser } from "@/navigation";
import {
deleteCredential,
listCredentials,
Expand Down Expand Up @@ -90,6 +92,7 @@ type CredentialsData = {

export function CredentialsSection({ tenantId }: { readonly tenantId: string | null }) {
const queryClient = useQueryClient();
const user = useSessionUser();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null);
const [loginId, setLoginId] = useState<string | null>(null);
Expand Down Expand Up @@ -133,6 +136,8 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n

function reload() {
if (tenantId === null) return;
// A provider connected after a skipped onboarding finishes Myra's deploy.
if (user !== undefined) void finishDeferredMyraSetup(user);
void queryClient.invalidateQueries({
queryKey: tenantKeys.credentials(tenantId),
});
Expand Down
40 changes: 40 additions & 0 deletions e2e/browser/onboarding-skip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Skipping the provider step sticks: a reload lands in the shell, not back
// on /onboarding.
import { expect, test } from "bun:test";
import { bootBrowserApp, browserGate } from "../lib/browser";

const describeBrowser = browserGate(import.meta.path);

describeBrowser("onboarding skip", () => {
const app = bootBrowserApp();

test("a skipped provider step survives a reload", async () => {
const { page } = await app().newPage();
await page.goto(app().origin, { waitUntil: "networkidle0" });
await page.waitForSelector(".auth-switch", { timeout: 30_000 });
await page.click(".auth-switch");
await page.type(
"input:not([type=password]):not([type=hidden])",
`skip-${String(Date.now())}@example.com`,
);
await page.type("input[type=password]", "correct-horse-battery-staple");
await page.click("button[type=submit]");

await page.waitForFunction("location.pathname === '/onboarding'", { timeout: 30_000 });
await page.waitForSelector("button::-p-text(Skip for now)", { timeout: 30_000 });
// A synthetic click: the step's buttons animate and pointer clicks miss.
await page.evaluate(
"Array.from(document.querySelectorAll('button')).find((b) => b.innerText.includes('Skip for now')).click()",
);
await page.waitForFunction("location.pathname === '/new'", { timeout: 30_000 });

await page.goto(app().origin, { waitUntil: "networkidle0" });
// The provisioning probe runs after the session probe; give it time to
// (wrongly) redirect before asserting.
await new Promise((resolve) => setTimeout(resolve, 3_000));
expect(new URL(page.url()).pathname).not.toBe("/onboarding");
// Still signed in: the sign-in form is gone.
expect(await page.$("input[type=password]")).toBeNull();
await page.close();
}, 120_000);
});
Loading