fix(web): land new accounts in onboarding and show how to connect a model (#134)

Signing up refreshes the session, which briefly routes /onboarding to
/sign-in without a return path; the sign-in route then sent the new
account to /app. The shell's own redirect to onboarding was skipped
whenever a focus refresh landed before the bootstrap response, so a
fresh account could stay on an empty Today view.

/onboarding now sends signed-out visitors to /sign-in?next=/onboarding
and sign-in honours it, and the shell redirects an account without
any bots or conversations regardless of which refresh landed first.

Without a connected model nothing can reply, so the composer shows a
"Connect model" action that opens model settings instead of a send
error after the fact.


Claude-Session: https://claude.ai/code/session_019HKhzQ65ctE5qmkiF9Apxo

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 18:34:53 +02:00 committed by GitHub
parent f7b0cfb341
commit ad054a7586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 168 additions and 12 deletions

View File

@ -0,0 +1,41 @@
/**
* E2E: a new account reaches onboarding and, without a model, sees how to connect one.
*/
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, routePatchRpc, signup } from "./helpers";
test("a signed-out visit to onboarding returns there after sign-in", async ({ page }) => {
const email = `return-onboarding-${Date.now()}@rakazo.test`;
await signup(page, email, "password12", "Return");
await page.waitForURL(/\/onboarding$/, { timeout: 20_000 });
await page.context().clearCookies();
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
await page.waitForURL((url) => url.pathname === "/sign-in");
expect(new URL(page.url()).searchParams.get("next")).toBe("/onboarding");
await page.getByPlaceholder("Your email address").fill(email);
await page.getByPlaceholder("Password").fill("password12");
await page.getByRole("button", { name: "Continue with email" }).click();
await page.waitForURL(/\/onboarding$/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toBeVisible();
});
test("without a model the composer offers to connect one", async ({ page }) => {
const testInfo = test.info();
await signup(page, `model-needed-${Date.now()}@rakazo.test`, "password12", "Model needed");
await completeOnboarding(page, undefined, { mainConversation: true });
await routePatchRpc(page, "bootstrap", (json) => ({
...json,
me: { ...(json.me as Record<string, unknown>), needsModel: true },
}));
await page.reload({ waitUntil: "domcontentloaded" });
const notice = page.getByTestId("composer-model-needed");
await expect(notice).toContainText("Connect a model so your Oo can reply.");
await captureScreenshot(page, testInfo, "first-conversation-model-needed");
await notice.getByRole("button", { name: "Connect model" }).click();
await expect(page.getByTestId("model-settings")).toBeVisible();
});

View File

@ -5,6 +5,7 @@ import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom"; import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
import { LoadingState } from "./components/ai/primitives"; import { LoadingState } from "./components/ai/primitives";
import { authClient } from "./lib/auth"; import { authClient } from "./lib/auth";
import { authReturnPath } from "./lib/auth-next";
import { lazyWithRecovery } from "./lib/lazy-recovery"; import { lazyWithRecovery } from "./lib/lazy-recovery";
import { markAfterPaint, markOnce } from "./lib/performance"; import { markAfterPaint, markOnce } from "./lib/performance";
import { import {
@ -46,8 +47,7 @@ export function App() {
function SessionApp() { function SessionApp() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const signInDestination = const signInDestination = authReturnPath(searchParams.get("next"));
searchParams.get("next") === "/integrations/setup" ? "/integrations/setup" : "/app";
const session = authClient.useSession(); const session = authClient.useSession();
const gate = sessionGate(session); const gate = sessionGate(session);
const [holdingUnreachable, setHoldingUnreachable] = useState(false); const [holdingUnreachable, setHoldingUnreachable] = useState(false);
@ -101,7 +101,9 @@ function SessionApp() {
<Route path="/reset-password" element={<PasswordResetPage />} /> <Route path="/reset-password" element={<PasswordResetPage />} />
<Route <Route
path="/onboarding" path="/onboarding"
element={user ? <OnboardingPage /> : <Navigate to="/sign-in" replace />} element={
user ? <OnboardingPage /> : <Navigate to="/sign-in?next=/onboarding" replace />
}
/> />
<Route <Route
path="/mcp/oauth/callback" path="/mcp/oauth/callback"

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { authReturnPath } from "./auth-next";
describe("authReturnPath", () => {
it("returns to onboarding and integration setup", () => {
expect(authReturnPath("/onboarding")).toBe("/onboarding");
expect(authReturnPath("/integrations/setup")).toBe("/integrations/setup");
});
it("sends anything else to the app", () => {
expect(authReturnPath(null)).toBe("/app");
expect(authReturnPath("https://example.test")).toBe("/app");
expect(authReturnPath("//example.test")).toBe("/app");
});
});

View File

@ -0,0 +1,6 @@
/** Routes a signed-out visitor may return to after signing in; anything else lands in the app. */
const RETURN_PATHS = new Set(["/onboarding", "/integrations/setup"]);
export function authReturnPath(next: string | null): string {
return next && RETURN_PATHS.has(next) ? next : "/app";
}

View File

@ -1252,6 +1252,10 @@ msgstr "Passwort bestätigen"
msgid "Connect" msgid "Connect"
msgstr "Verbinden" msgstr "Verbinden"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr "Verbinde ein Modell, damit dein Oo antworten kann."
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "API-Schlüssel verbinden" msgstr "API-Schlüssel verbinden"
@ -1264,6 +1268,10 @@ msgstr "Executor verbinden"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "MCP-Server „{name}“ verbinden" msgstr "MCP-Server „{name}“ verbinden"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr "Modell verbinden"
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "OAuth verbinden" msgstr "OAuth verbinden"

View File

@ -1252,6 +1252,10 @@ msgstr "Confirm password"
msgid "Connect" msgid "Connect"
msgstr "Connect" msgstr "Connect"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr "Connect a model so your Oo can reply."
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "Connect API key" msgstr "Connect API key"
@ -1264,6 +1268,10 @@ msgstr "Connect Executor"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "Connect MCP server “{name}”" msgstr "Connect MCP server “{name}”"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr "Connect model"
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "Connect OAuth" msgstr "Connect OAuth"

View File

@ -1252,6 +1252,10 @@ msgstr "Confirmar contraseña"
msgid "Connect" msgid "Connect"
msgstr "Conectar" msgstr "Conectar"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "Conectar clave de API" msgstr "Conectar clave de API"
@ -1264,6 +1268,10 @@ msgstr "Conectar Executor"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "Conectar servidor MCP “{name}”" msgstr "Conectar servidor MCP “{name}”"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "Conectar OAuth" msgstr "Conectar OAuth"

View File

@ -1252,6 +1252,10 @@ msgstr "पासवर्ड की पुष्टि करें"
msgid "Connect" msgid "Connect"
msgstr "कनेक्ट करें" msgstr "कनेक्ट करें"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "API कुंजी कनेक्ट करें" msgstr "API कुंजी कनेक्ट करें"
@ -1264,6 +1268,10 @@ msgstr "Executor कनेक्ट करें"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "MCP सर्वर “{name}” कनेक्ट करें" msgstr "MCP सर्वर “{name}” कनेक्ट करें"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "OAuth कनेक्ट करें" msgstr "OAuth कनेक्ट करें"

View File

@ -1252,6 +1252,10 @@ msgstr "비밀번호 확인"
msgid "Connect" msgid "Connect"
msgstr "연결" msgstr "연결"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "API 키 연결" msgstr "API 키 연결"
@ -1264,6 +1268,10 @@ msgstr "Executor 연결"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "MCP 서버 ‘{name}’ 연결" msgstr "MCP 서버 ‘{name}’ 연결"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "OAuth 연결" msgstr "OAuth 연결"

View File

@ -1252,6 +1252,10 @@ msgstr "Confirmar senha"
msgid "Connect" msgid "Connect"
msgstr "Conectar" msgstr "Conectar"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "Conecte-se com a chave API" msgstr "Conecte-se com a chave API"
@ -1264,6 +1268,10 @@ msgstr "Conectar Executor"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "Conecte o servidor MCP “{name}”" msgstr "Conecte o servidor MCP “{name}”"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "Conectar OAuth" msgstr "Conectar OAuth"

View File

@ -1252,6 +1252,10 @@ msgstr "Подтвердите пароль"
msgid "Connect" msgid "Connect"
msgstr "Подключить" msgstr "Подключить"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "Подключить API-ключ" msgstr "Подключить API-ключ"
@ -1264,6 +1268,10 @@ msgstr "Подключить Executor"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "Подключите MCP-сервер «{name}»" msgstr "Подключите MCP-сервер «{name}»"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "Подключить OAuth" msgstr "Подключить OAuth"

View File

@ -1252,6 +1252,10 @@ msgstr "Parolayı doğrulayın"
msgid "Connect" msgid "Connect"
msgstr "Bağlan" msgstr "Bağlan"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "API anahtarıyla bağlan" msgstr "API anahtarıyla bağlan"
@ -1264,6 +1268,10 @@ msgstr "Executor'a bağlan"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "“{name}” MCP sunucusunu bağla" msgstr "“{name}” MCP sunucusunu bağla"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "OAuth ile bağlan" msgstr "OAuth ile bağlan"

View File

@ -1252,6 +1252,10 @@ msgstr "确认密码"
msgid "Connect" msgid "Connect"
msgstr "连接" msgstr "连接"
#: src/pages/shell/composer.tsx
msgid "Connect a model so your Oo can reply."
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Connect API key" msgid "Connect API key"
msgstr "连接 API 密钥" msgstr "连接 API 密钥"
@ -1264,6 +1268,10 @@ msgstr "连接 Executor"
msgid "Connect MCP server “{name}”" msgid "Connect MCP server “{name}”"
msgstr "连接 MCP 服务器“{name}”" msgstr "连接 MCP 服务器“{name}”"
#: src/pages/shell/composer.tsx
msgid "Connect model"
msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
msgid "Connect OAuth" msgid "Connect OAuth"
msgstr "连接 OAuth" msgstr "连接 OAuth"

View File

@ -5,6 +5,7 @@ import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { authClient } from "../lib/auth"; import { authClient } from "../lib/auth";
import { authReturnPath } from "../lib/auth-next";
import { clearSpaceSelection } from "../lib/rpc"; import { clearSpaceSelection } from "../lib/rpc";
type AuthMode = "in" | "up" | "forgot"; type AuthMode = "in" | "up" | "forgot";
@ -106,13 +107,7 @@ export function AuthPage({ mode }: { mode: AuthMode }) {
return; return;
} }
clearSpaceSelection(); clearSpaceSelection();
navigate( navigate(mode === "up" ? "/onboarding" : authReturnPath(searchParams.get("next")));
mode === "up"
? "/onboarding"
: searchParams.get("next") === "/integrations/setup"
? "/integrations/setup"
: "/app",
);
} catch { } catch {
setError(t`Could not reach the server`); setError(t`Could not reach the server`);
} finally { } finally {

View File

@ -957,7 +957,8 @@ export function ShellPage() {
markOnce("rk:renderer:bots-response"); markOnce("rk:renderer:bots-response");
markOnce("rk:renderer:thread-response"); markOnce("rk:renderer:thread-response");
} }
if (!applyBotLists) return; // A focus refresh can land first and skip the list writes; a fresh account still
// belongs in onboarding, so this check never depends on applyBotLists.
if ( if (
bootstrap.bots.length === 0 && bootstrap.bots.length === 0 &&
bootstrap.archivedBots.length === 0 && bootstrap.archivedBots.length === 0 &&
@ -968,6 +969,7 @@ export function ShellPage() {
navigate("/onboarding", { replace: true }); navigate("/onboarding", { replace: true });
return; return;
} }
if (!applyBotLists) return;
const bootstrapHomeSpaceId = const bootstrapHomeSpaceId =
bootstrap.spaces.find((space) => space.isDefault)?.id ?? bootstrap.me.spaceId; bootstrap.spaces.find((space) => space.isDefault)?.id ?? bootstrap.me.spaceId;
const bootstrapSpaceId = selectedSpaceId() ?? bootstrap.me.spaceId; const bootstrapSpaceId = selectedSpaceId() ?? bootstrap.me.spaceId;
@ -3365,6 +3367,7 @@ export function ShellPage() {
mentionTargets={composerMentionTargets} mentionTargets={composerMentionTargets}
agentSkills={agentSkills} agentSkills={agentSkills}
onSlashOpen={refreshAgentSkills} onSlashOpen={refreshAgentSkills}
onConnectModel={bootstrapMe?.needsModel ? () => openSettings("models") : undefined}
onSlashAction={(action) => { onSlashAction={(action) => {
if (action === "chat-settings") { if (action === "chat-settings") {
setPanel(inGroup ? "group-settings" : "settings"); setPanel(inGroup ? "group-settings" : "settings");
@ -4124,6 +4127,13 @@ export function ShellPage() {
onClose={() => { onClose={() => {
setSettingsOpen(false); setSettingsOpen(false);
setSettingsSection("general"); setSettingsSection("general");
// A model connected in settings unblocks the composer.
if (bootstrapMe?.needsModel) {
void rpc
.me()
.then(setBootstrapMe)
.catch(() => undefined);
}
}} }}
/> />
) : null} ) : null}

View File

@ -61,6 +61,7 @@ export const Composer = memo(function Composer({
agentSkills, agentSkills,
onSlashOpen, onSlashOpen,
onSlashAction, onSlashAction,
onConnectModel,
}: { }: {
running: boolean; running: boolean;
disabled?: boolean; disabled?: boolean;
@ -88,6 +89,8 @@ export const Composer = memo(function Composer({
agentSkills?: AgentSkillCatalogEntry[]; agentSkills?: AgentSkillCatalogEntry[];
onSlashOpen?: () => void; onSlashOpen?: () => void;
onSlashAction?: (action: SlashActionId) => void; onSlashAction?: (action: SlashActionId) => void;
/** Set while no model is connected: nothing can run until one is. */
onConnectModel?: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@ -370,7 +373,19 @@ export const Composer = memo(function Composer({
}`} }`}
> >
<div className="mx-auto w-full max-w-4xl"> <div className="mx-auto w-full max-w-4xl">
{sendError || runError ? ( {onConnectModel ? (
<div
data-testid="composer-model-needed"
className="mb-3 flex items-center gap-3 rounded-[14px] border border-border bg-muted px-4 py-2 text-ui-body-sm text-foreground"
>
<span className="min-w-0 flex-1">
<Trans>Connect a model so your Oo can reply.</Trans>
</span>
<Button size="sm" onClick={onConnectModel}>
<Trans>Connect model</Trans>
</Button>
</div>
) : sendError || runError ? (
<div <div
ref={runErrorRef} ref={runErrorRef}
role="alert" role="alert"