fix(onboarding): close the gap above the step CTA and put Log out back on one row

Two defects that read as one thing on screen: a large empty block sitting
directly above the primary button.

The card list was capped at STEP_MEASURE along with the form. That cap exists
so a workspace name does not get an 800px input — a good reason for a text
field and a bad one for selection cards, which are a list like the runtime
grid. Capped, they stopped 299px short of the CTA, leaving a void above the
button. The cards take the frame now; only the form keeps the reading measure.

Log out came in from main as `fixed right-8 top-8`, pinned to the window
corner. Its own comment says the fixed position exists to survive the flow's
full-bleed layouts — which is what the measured frame replaced, so it landed
outside the measure and above Back / Step N of N as a second header row. It
now rides the header row on the frame.

StepShellHeader takes it as a `trailing` slot rather than rendering it:
calling useLogout inside the shared header forced a QueryClient into five
step test files just to render a header bar. The flow injects it, matching
how runtimeInstructions is already threaded, and the header stays
presentational.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Lambda
2026-08-03 01:20:43 +08:00
parent 8cc0d2e981
commit 7650a66f97
7 changed files with 68 additions and 22 deletions

View File

@@ -2,16 +2,24 @@
import { LogOut } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useLogout } from "../../auth";
import { useT } from "../../i18n";
/**
* Account-switch escape hatch shared by every onboarding step.
*
* The fixed position keeps it available across the flow's full-bleed layouts,
* including the narrow mobile layout where the issue was originally reported.
* `fixed` only for the welcome screen, which has no step header to sit in.
* Every other step passes `inline` so it rides the header row on STEP_FRAME:
* pinning it to the window corner put it outside the measure and above
* Back / Step N of N, which read as a second header row.
*/
export function OnboardingLogoutButton() {
export function OnboardingLogoutButton({
inline = false,
}: {
/** Render in normal flow (inside a step header) instead of pinned. */
inline?: boolean;
} = {}) {
const { t } = useT("onboarding");
const logout = useLogout();
@@ -19,7 +27,10 @@ export function OnboardingLogoutButton() {
<Button
variant="ghost"
size="sm"
className="fixed right-8 top-8 z-50 text-muted-foreground hover:text-destructive"
className={cn(
"text-muted-foreground hover:text-destructive",
inline ? "-mr-2 shrink-0" : "fixed right-8 top-8 z-50",
)}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onClick={logout}
>

View File

@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { ArrowLeft } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
@@ -52,11 +53,16 @@ export function StepShellHeader({
currentStep,
onBack,
backDisabled,
trailing,
}: {
currentStep: OnboardingStep;
onBack?: () => void;
/** Workspace step disables Back while its create request is in flight. */
backDisabled?: boolean;
/** Injected by the flow — the Log out escape hatch. A slot rather than a
* direct render so this header stays presentational: rendering the logout
* mutation inline forced a QueryClient into five step test files. */
trailing?: ReactNode;
}) {
const { t } = useT("onboarding");
return (
@@ -78,6 +84,7 @@ export function StepShellHeader({
<div className="flex-1">
<StepHeader currentStep={currentStep} />
</div>
{trailing}
</div>
</header>
);

View File

@@ -125,12 +125,7 @@ interface OnboardingFlowProps {
}
export function OnboardingFlow(props: OnboardingFlowProps) {
return (
<>
<OnboardingLogoutButton />
<OnboardingStepFlow {...props} />
</>
);
return <OnboardingStepFlow {...props} />;
}
function OnboardingStepFlow({
@@ -333,6 +328,11 @@ function OnboardingStepFlow({
// guidance. The remaining exits both end somewhere coherent: Skip runs the
// runtime-skipped path (guide issue + land in the new workspace), and
// continuing provisions Mika.
// Log out lives in the step header row, on the frame, rather than pinned to
// the window corner: pinned put it outside the measure and above Back /
// Step N of N, which read as a second header row.
const headerTrailing = <OnboardingLogoutButton inline />;
const runtimeStepBack = isNewWorkspace
? undefined
: () => handleBack("runtime");
@@ -340,18 +340,23 @@ function OnboardingStepFlow({
// Every step owns its full-bleed shell; this component only switches
// between the active screen.
if (step === "welcome") {
// Welcome has no step header, so the escape hatch stays pinned here.
return (
<StepWelcome
onNext={handleWelcomeNext}
onSkip={canSkipWelcome ? handleWelcomeSkip : undefined}
isWeb={isWeb}
/>
<>
<OnboardingLogoutButton />
<StepWelcome
onNext={handleWelcomeNext}
onSkip={canSkipWelcome ? handleWelcomeSkip : undefined}
isWeb={isWeb}
/>
</>
);
}
if (step === "about_you") {
return (
<StepAboutYou
headerTrailing={headerTrailing}
answers={answers}
onChange={applyAnswers}
onAdvance={() => advanceFrom("about_you")}
@@ -364,6 +369,7 @@ function OnboardingStepFlow({
if (step === "workspace") {
return (
<StepWorkspace
headerTrailing={headerTrailing}
existing={existingWorkspace}
onCreated={handleWorkspaceCreated}
onBack={() => handleBack("workspace")}
@@ -381,6 +387,7 @@ function OnboardingStepFlow({
if (!runtimeInstructions) {
return (
<StepRuntimeConnect
headerTrailing={headerTrailing}
wsId={workspace.id}
wsSlug={workspace.slug}
onNext={handleRuntimeNext}
@@ -392,6 +399,7 @@ function OnboardingStepFlow({
}
return (
<StepPlatformFork
headerTrailing={headerTrailing}
wsId={workspace.id}
wsSlug={workspace.slug}
onNext={handleRuntimeNext}

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef } from "react";
import { useRef , type ReactNode } from "react";
import {
ArrowRight,
Brain,
@@ -64,12 +64,16 @@ export function StepAboutYou({
onAdvance,
onSkip,
onBack,
headerTrailing,
}: {
answers: QuestionnaireAnswers;
onChange: (patch: Partial<QuestionnaireAnswers>) => void;
onAdvance: () => void;
onSkip: () => void;
onBack?: () => void;
/** Log out escape hatch, injected by the flow so this step does not depend
* on the auth layer. */
headerTrailing?: ReactNode;
}) {
const { t } = useT("onboarding");
const mainRef = useRef<HTMLElement>(null);
@@ -185,7 +189,7 @@ export function StepAboutYou({
return (
<div className="animate-onboarding-enter flex h-full min-h-0 flex-col bg-background">
<DragStrip />
<StepShellHeader currentStep="about_you" onBack={onBack} />
<StepShellHeader currentStep="about_you" onBack={onBack} trailing={headerTrailing} />
<main
ref={mainRef}

View File

@@ -60,6 +60,7 @@ export function StepPlatformFork({
wsSlug,
onNext,
onBack,
headerTrailing,
cliInstructions,
}: {
wsId: string;
@@ -69,6 +70,9 @@ export function StepPlatformFork({
wsSlug?: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Log out escape hatch, injected by the flow so this step does not depend
* on the auth layer. */
headerTrailing?: ReactNode;
/** Platform-specific CLI install card, rendered inside the CLI dialog. */
cliInstructions?: ReactNode;
}) {
@@ -113,7 +117,7 @@ export function StepPlatformFork({
<div className="animate-onboarding-enter flex h-full min-h-0 flex-col bg-background">
<DragStrip />
<StepShellHeader currentStep="runtime" onBack={onBack} />
<StepShellHeader currentStep="runtime" onBack={onBack} trailing={headerTrailing} />
<main
ref={mainRef}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState , type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -48,6 +48,7 @@ export function StepRuntimeConnect({
wsSlug,
onNext,
onBack,
headerTrailing,
onRefresh,
runtimesPending,
}: {
@@ -58,6 +59,9 @@ export function StepRuntimeConnect({
wsSlug?: string;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
/** Log out escape hatch, injected by the flow so this step does not depend
* on the auth layer. */
headerTrailing?: ReactNode;
/** Platform-level rescan hook. Desktop wires this to restart the
* bundled daemon so a freshly-installed CLI shows up — otherwise the
* daemon's PATH probe runs once at boot and never re-probes. */
@@ -82,6 +86,7 @@ export function StepRuntimeConnect({
setSelectedId={setSelectedId}
onNext={onNext}
onBack={onBack}
headerTrailing={headerTrailing}
onRefresh={onRefresh}
runtimesPending={runtimesPending}
/>
@@ -111,6 +116,7 @@ function FancyView({
setSelectedId,
onNext,
onBack,
headerTrailing,
onRefresh,
runtimesPending,
}: {
@@ -121,6 +127,7 @@ function FancyView({
setSelectedId: (id: string) => void;
onNext: (runtime: AgentRuntime | null) => void | Promise<void>;
onBack?: () => void;
headerTrailing?: ReactNode;
onRefresh?: () => void | Promise<void>;
runtimesPending?: boolean;
}) {
@@ -231,7 +238,7 @@ function FancyView({
<DragStrip />
{/* Header — Back + horizontal step indicator */}
<StepShellHeader currentStep="runtime" onBack={onBack} />
<StepShellHeader currentStep="runtime" onBack={onBack} trailing={headerTrailing} />
{/* Scrollable middle — content changes by phase but always wraps
at STEP_FRAME — the same measure as the header — so nine

View File

@@ -71,10 +71,14 @@ export function StepWorkspace({
existing,
onCreated,
onBack,
headerTrailing,
}: {
existing?: Workspace | null;
onCreated: (workspace: Workspace) => void | Promise<void>;
onBack?: () => void;
/** Log out escape hatch, injected by the flow so this step does not depend
* on the auth layer. */
headerTrailing?: ReactNode;
}) {
const { t, i18n } = useT("onboarding");
const locale = matchLocale([i18n.resolvedLanguage ?? i18n.language]);
@@ -221,7 +225,7 @@ export function StepWorkspace({
}
const createFields = (
<div className="flex flex-col gap-5">
<div className={cn("flex flex-col gap-5", STEP_MEASURE)}>
<div className="flex flex-col gap-1.5">
<Label
htmlFor="ws-name"
@@ -303,6 +307,7 @@ export function StepWorkspace({
currentStep="workspace"
onBack={onBack}
backDisabled={isCreating}
trailing={headerTrailing}
/>
<main
@@ -339,7 +344,7 @@ export function StepWorkspace({
: t(($) => $.step_workspace.creation_disabled_lede)}
</p>
<div className={cn("mt-10", STEP_MEASURE)}>
<div className="mt-10">
{reusing ? (
<div className="flex flex-col gap-3">
<ExistingWorkspaceCard