Files
multica/packages/views/onboarding/steps/step-agent.tsx
Jiayuan Zhang c577a29c10 feat(onboarding): v2 per-question questionnaire (source/role/use_case) (#2814)
* feat(onboarding): per-question v2 questionnaire (source/role/use_case)

Replaces the 3-questions-on-one-screen gate with three lightweight,
individually-skippable steps. New step order:

  welcome → source → role → use_case → workspace → runtime → agent → first_issue

- New v2 questionnaire schema: source/role/use_case + per-slot
  `*_skipped` markers. `team_size` removed.
- Click-to-advance card grid with lucide + emoji icons (RFC Option B).
- Skip is a footer text button; Other expands a free-text input.
- Recommendation table updated for new role × use_case vocabulary,
  with use_case-only fallback when role is skipped.
- DB migration v1 → v2 maps existing role/use_case answers and drops
  team_size; historical nulls stay null (not retroactively skipped).
- Re-entry treats skipped slots as fresh; analytics record kept in DB.
- onboarding_questionnaire_submitted event payload updated:
  source replaces team_size, per-slot skip booleans added.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): tighten question UX (Continue, layout, brand icons)

Address review feedback on Source/Role/Use-case:

- Replace auto-advance with an explicit Continue button so selections
  are reviewable. Continue is disabled until something is picked (and,
  for Other, until the free-text input is non-empty).
- Move Back/Skip/Continue inline under the option grid; drop the
  duplicate Back from the top header — the page now has a single,
  anchored action row.
- Swap the placeholder lucide marks for real brand SVGs on Source:
  Google, X, LinkedIn, YouTube, and an OpenAI mark for the AI-assistant
  option. Generic options stay on lucide.
- Replace the awkward expanded underline input on the Other card with
  an inline borderless input that swaps in for the label slot, so the
  Other state has the same height and weight as the other cards.

E2E smoke test updated to click Continue between question steps.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): unify step nav, rename Runtime step around "where agents run"

- Refactor the Source/Role/Use case questionnaire steps to use the same
  3-region chrome (header with Back + step indicator, scrolling main,
  sticky footer with Skip + Continue) that Workspace/Runtime/Agent
  already use, so the Back/Skip/Continue affordances stay in the same
  on-screen position across the whole flow.
- Reframe the Runtime step around the user-visible question — "Where
  will your agents run?" — instead of the internal "runtime" concept.
  The aside panel keeps the educational "What's a runtime?" copy for
  users who want to learn.
- Drop the hard-coded "Step 3 · Runtime" eyebrow on the web fork step:
  Runtime is now step 5 of 7 after the per-question split, and the
  step indicator already shows the correct count.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): tighten Skip/Continue spacing in step footer

Group Skip and Continue inside a sub-flex with gap-2 so they read as a
single action cluster on the right, while the status hint still anchors
left via mr-auto. Applied to both the questionnaire steps and the
runtime step so the footer layout stays consistent across onboarding.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): move Skip/Continue inline below form, drop sticky footer

The sticky bottom footer left a large dead zone between the form
content and the action buttons — most onboarding steps only fill the
top third of the viewport. Move the hint + Skip + Continue inline,
directly below the form/options grid, so the buttons sit where the eye
already is after picking an option.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): match Skip button size to Continue (size="lg")

Skip used the default button size (h-8) while Continue used size="lg"
(h-9), so the two adjacent action buttons rendered visibly different
heights. Promote Skip to size="lg" in step-question and
step-runtime-connect so they line up.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): reframe step 3 as 'connect a computer' / 'pick an agent runtime'

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): replace cloud waitlist with "Coming soon", reword CLI intro

- Web Step 3 cloud card: remove "Join waitlist" CTA + dialog and render a
  static "Coming soon" badge instead. Drops CloudWaitlistDialog, the
  cloud DialogState, waitlistSubmitted local state, and the
  onWaitlistSubmitted prop on StepPlatformFork (desktop's
  StepRuntimeConnect still owns its own waitlist path).
- Tighten cloud_subtitle to drop the "join the waitlist" half now that
  the action is gone.
- cli_install.intro: "AI coding tool" → "agent runtime", EN + zh-Hans.

Tests updated to match: asserts the Coming soon badge is non-actionable
and drops the four cloud-dialog scenarios (now unreachable).

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): refresh button, "agent runtime" wording, coming-soon card

Three fixes on the desktop Step 3 empty state per review:

1. Empty headline + hints now say "agent runtime", matching the
   picker-context terminology established earlier in this PR.
2. Add a Refresh button (header pill in Found, inline with the
   headline in Empty). Desktop wires it to restart the bundled
   daemon so a freshly-installed Claude/Codex/Cursor CLI is picked
   up — the daemon's PATH probe runs once at boot, so without a
   restart the install would only take effect on next launch.
3. "Use a cloud computer" loses the waitlist dialog and renders as
   a disabled "Coming soon" badge, aligning with the web fork.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): address review follow-ups (i18n, step-order, version, tests)

- runtime-aside-panel: point "Learn more" to /docs/install-agent-runtime,
  branching by language so zh users land on /docs/zh/...
- zh-Hans: unify Cloud "Coming soon" wording to "即将推出"; translate
  step_workspace.preview.more_meta ("and more" -> "等等")
- onboarding-flow: derive forward navigation from ONBOARDING_STEP_ORDER
  via advanceFrom(curr) so inserting/reordering a step only requires
  editing the canonical array; runtime → agent/first_issue branch keeps
  its bespoke routing with a comment explaining why
- onboarding handler: gate questionnaireAnswers.complete() on
  Version == 2 so a future schema bump can't be silently mis-counted
  against v2 funnel semantics
- add unit tests for step-source / step-role / step-use-case (option
  click, Skip patch, Other free-text) and step-question shell
  (canContinue + pendingOther state machine)

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): rename useCaseFallback to fallbackFromUseCase

ESLint's react-hooks/rules-of-hooks treats any function starting with
"use" as a React hook. The helper is a pure switch — give it a name
that doesn't trip the rule.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 05:35:18 +02:00

353 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useRef, useState } from "react";
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
import { api } from "@multica/core/api";
import {
recommendTemplate,
type AgentTemplateId,
type QuestionnaireAnswers,
} from "@multica/core/onboarding";
import type {
Agent,
AgentRuntime,
CreateAgentRequest,
} from "@multica/core/types";
import { DragStrip } from "@multica/views/platform";
import { StepHeader } from "../components/step-header";
import { useT } from "../../i18n";
/**
* Step 4 — create the user's first agent.
*
* Picks a recommended template from the questionnaire answers
* (`recommendTemplate()` maps role × use_case → one of 4 templates),
* attaches the template's default name + instructions, and ships a
* ready-to-work agent on Create. Layout mirrors Questionnaire /
* Workspace: a 2-column editorial shell with DragStrip + 3-region
* app column (header / scrollable main / footer) + "About agents"
* side panel hidden below lg.
*
* No rename, runtime-swap, or instructions editor on this step —
* every template defaults are good enough to ship immediately, and
* the agent settings page handles all customization post-onboarding.
* Intentional: minimizing surface area keeps time-to-first-agent low.
*
* No skip path either — if the user arrived here they have a runtime
* (Step 3 only routes to Step 4 when a runtime was picked), so
* creating an agent is the purpose of this step. Users who want a
* runtime-less workspace skip out at Step 3.
*/
interface AgentTemplate {
id: AgentTemplateId;
label: string;
defaultName: string;
emoji: string;
blurb: string;
instructions: string;
}
// Defaults stay constant (names + emoji are visual identity, not copy);
// label / blurb / instructions resolve from the bundle at render time.
const TEMPLATE_DEFAULTS: readonly Omit<AgentTemplate, "label" | "blurb" | "instructions">[] = [
{ id: "coding", defaultName: "Atlas", emoji: "⌘" },
{ id: "planning", defaultName: "Orion", emoji: "◐" },
{ id: "writing", defaultName: "Mira", emoji: "✎" },
{ id: "assistant", defaultName: "Vega", emoji: "✦" },
] as const;
function useAgentTemplates(): {
templates: readonly AgentTemplate[];
byId: Record<AgentTemplateId, AgentTemplate>;
} {
const { t } = useT("onboarding");
const templates = TEMPLATE_DEFAULTS.map((d) => ({
...d,
label: t(($) => $.step_agent.templates[d.id].label),
blurb: t(($) => $.step_agent.templates[d.id].blurb),
instructions: t(($) => $.step_agent.templates[d.id].instructions),
})) as readonly AgentTemplate[];
const byId = Object.fromEntries(templates.map((tpl) => [tpl.id, tpl])) as Record<
AgentTemplateId,
AgentTemplate
>;
return { templates, byId };
}
export function StepAgent({
runtime,
questionnaire,
onCreated,
onBack,
}: {
runtime: AgentRuntime;
questionnaire: QuestionnaireAnswers;
onCreated: (agent: Agent) => void | Promise<void>;
onBack?: () => void;
}) {
const { t } = useT("onboarding");
const { templates: AGENT_TEMPLATES, byId: TEMPLATE_BY_ID } = useAgentTemplates();
const recommendedId = recommendTemplate(questionnaire);
const recommended = TEMPLATE_BY_ID[recommendedId];
const [templateId, setTemplateId] =
useState<AgentTemplateId>(recommendedId);
const template = TEMPLATE_BY_ID[templateId];
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
if (creating) return;
setCreating(true);
try {
const req: CreateAgentRequest = {
name: template.defaultName,
description: template.blurb,
instructions: template.instructions,
runtime_id: runtime.id,
visibility: "workspace",
template: templateId,
};
const agent = await api.createAgent(req);
await onCreated(agent);
} catch (err) {
toast.error(
err instanceof Error ? err.message : t(($) => $.step_agent.create_failed),
);
setCreating(false);
}
};
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
return (
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
{/* Left column — DragStrip + 3-region app shell */}
<div className="flex min-h-0 flex-col">
<DragStrip />
{/* Fixed header — Back + progress indicator */}
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
{onBack ? (
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t(($) => $.common.back)}
</button>
) : (
<span aria-hidden className="w-0" />
)}
<div className="flex-1">
<StepHeader currentStep="agent" />
</div>
</header>
{/* Scrollable middle. `useScrollFade` softly masks content at
the header / footer edges as the user scrolls, replacing a
hard divider line. */}
<main
ref={mainRef}
style={fadeStyle}
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[620px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14">
<div className="mb-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_agent.eyebrow)}
</div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{t(($) => $.step_agent.headline)}
</h1>
<p className="mt-4 text-[15.5px] leading-[1.55] text-foreground/80">
{t(($) => $.step_agent.lede_prefix)}
<strong className="font-medium text-foreground">
{recommended.label}
</strong>
{t(($) => $.step_agent.lede_suffix)}
</p>
<div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-2">
{AGENT_TEMPLATES.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={templateId === t.id}
recommended={recommendedId === t.id}
onSelect={() => setTemplateId(t.id)}
/>
))}
</div>
{/* Inline action bar — hint + Create CTA. No skip path:
reaching Step 4 means a runtime was picked at Step 3,
so creating the agent IS this step. */}
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<span className="mr-auto hidden text-xs text-muted-foreground sm:block">
{t(($) => $.step_agent.footer_hint)}
</span>
<Button size="lg" onClick={handleCreate} disabled={creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
{t(($) => $.step_agent.create_action, { name: template.defaultName })}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</main>
</div>
{/* Right — About agents side panel, independent scroll */}
<aside className="hidden min-h-0 border-l bg-muted/40 lg:flex lg:flex-col">
<DragStrip />
<div className="min-h-0 flex-1 overflow-y-auto px-12 py-12">
<AboutAgentsSide />
</div>
</aside>
</div>
);
}
function TemplateCard({
template,
selected,
recommended,
onSelect,
}: {
template: AgentTemplate;
selected: boolean;
recommended: boolean;
onSelect: () => void;
}) {
const { t } = useT("onboarding");
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className={cn(
"flex flex-col items-start gap-3 rounded-lg border bg-card px-4 py-4 text-left transition-all",
selected
? "border-foreground shadow-[inset_0_0_0_1px_var(--color-foreground)]"
: "hover:border-foreground/20 hover:bg-accent/30",
)}
>
<div className="flex w-full items-start justify-between gap-2">
<span
aria-hidden
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted/70 font-serif text-lg text-foreground/80"
>
{template.emoji}
</span>
{recommended && (
<span className="shrink-0 rounded-full bg-brand/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-brand">
{t(($) => $.step_agent.recommended_badge)}
</span>
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-foreground">
{template.label}
</div>
<p className="text-xs leading-snug text-muted-foreground">
{template.blurb}
</p>
</div>
</button>
);
}
function AboutAgentsSide() {
const { t } = useT("onboarding");
return (
<div className="flex max-w-[380px] flex-col gap-8">
<section className="flex flex-col gap-4">
<div className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_agent.about_eyebrow)}
</div>
<h2 className="font-serif text-[22px] font-medium leading-[1.25] tracking-tight text-foreground">
{t(($) => $.step_agent.about_headline)}
</h2>
<p className="text-[14px] leading-[1.6] text-foreground/80">
{t(($) => $.step_agent.about_body)}
</p>
</section>
<section className="flex flex-col gap-4">
<div className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_agent.ways_eyebrow)}
</div>
<div className="flex flex-col gap-4">
<WayItem
glyph="→"
title={t(($) => $.step_agent.way_assign_title)}
body={t(($) => $.step_agent.way_assign_body)}
/>
<WayItem
glyph="@"
title={t(($) => $.step_agent.way_mention_title)}
body={t(($) => $.step_agent.way_mention_body)}
/>
<WayItem
glyph="◯"
title={t(($) => $.step_agent.way_chat_title)}
body={t(($) => $.step_agent.way_chat_body)}
/>
<WayItem
glyph="↻"
title={t(($) => $.step_agent.way_autopilot_title)}
body={t(($) => $.step_agent.way_autopilot_body)}
/>
</div>
</section>
<p className="text-[13px] leading-[1.55] text-muted-foreground">
{t(($) => $.step_agent.add_more_hint)}
</p>
<a
href="https://multica.ai/docs/agents-create"
target="_blank"
rel="noopener noreferrer"
className="self-start text-[13px] text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{t(($) => $.step_agent.docs_link)}
</a>
</div>
);
}
function WayItem({
glyph,
title,
body,
}: {
glyph: string;
title: string;
body: string;
}) {
return (
<div className="grid grid-cols-[22px_1fr] gap-3">
<div
aria-hidden
className="flex h-[20px] w-[20px] items-center justify-center text-[14px] text-muted-foreground"
>
{glyph}
</div>
<div className="flex flex-col gap-1">
<div className="text-[14px] font-medium leading-tight text-foreground">
{title}
</div>
<p className="text-[13px] leading-[1.5] text-muted-foreground">
{body}
</p>
</div>
</div>
);
}