Files
multica/packages/views/onboarding/steps/step-workspace.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

636 lines
22 KiB
TypeScript

"use client";
import { type ReactNode, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
BookOpenText,
Bot,
FolderKanban,
Inbox,
ListTodo,
Lock,
MoreHorizontal,
Monitor,
Plus,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
import type { Workspace } from "@multica/core/types";
import { isImeComposing } from "@multica/core/utils";
import { DragStrip } from "@multica/views/platform";
import { StepHeader } from "../components/step-header";
import { RadioMark } from "../components/option-card";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useT } from "../../i18n";
import {
WORKSPACE_SLUG_REGEX,
isWorkspaceSlugConflict,
nameToWorkspaceSlug,
} from "../../workspace/slug";
import { isReservedSlug } from "@multica/core/paths";
/**
* Step 2 — create your first workspace, or continue with one set up in
* an earlier session.
*
* Shares Questionnaire's editorial two-column skeleton: 3-region app
* shell on the left, side panel on the right. One **unified footer CTA**
* handles both paths — `Open X` when the user picks an existing
* workspace, `Create X` when they name a new one. The name / slug
* fields are inlined here (not via the shared `CreateWorkspaceForm`)
* because the footer-driven interaction needs externalized submit; the
* shared form's own button would fight the footer CTA.
*
* The create-fields block doubles as a pedagogical preview: the URL is
* rendered as a `multica.ai/[slug]` pill, and a live `Issues will look
* like ACME-123` line shows the user what their issue IDs will read
* like before they've created anything.
*
* Resume path ships two picker cards (existing + create-new) and the
* user toggles between them. No-existing path just shows the create
* fields directly.
*/
function issuePrefix(slug: string): string {
// Mirrors the server's default prefix derivation — first 4 chars of
// the slug, uppercased. Falls back to "WS" when the slug is empty so
// the preview line never collapses to a single dangling "-".
const head = slug.trim().replace(/[^a-z0-9]/g, "").slice(0, 4);
return (head || "ws").toUpperCase();
}
export function StepWorkspace({
existing,
onCreated,
onBack,
}: {
existing?: Workspace | null;
onCreated: (workspace: Workspace) => void | Promise<void>;
onBack?: () => void;
}) {
const { t } = useT("onboarding");
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
const reusing = existing ?? null;
// Resume path only: user picks which card. `null` = neither yet, so
// the footer CTA stays disabled. Clicking either card toggles — a
// second click on the same card deselects it. No-existing path
// ignores this state entirely.
const [mode, setMode] = useState<"existing" | "create" | null>(null);
const pickExisting = () =>
setMode((m) => (m === "existing" ? null : "existing"));
const pickCreate = () =>
setMode((m) => (m === "create" ? null : "create"));
// Form state for the create path. Mirrors CreateWorkspaceForm's
// internals: slug auto-fills from name until the user manually edits
// it; server-side slug conflicts show inline. Kept at this level so
// the footer CTA can read `canCreate` and trigger `handleCreate`.
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugServerError, setSlugServerError] = useState<string | null>(null);
const slugTouched = useRef(false);
const slugValidationError =
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
? t(($) => $.step_workspace.slug_format_error)
: null;
const slugReservedError =
slug.length > 0 && isReservedSlug(slug)
? t(($) => $.step_workspace.slug_reserved_error)
: null;
const slugError = slugValidationError ?? slugReservedError ?? slugServerError;
const canCreate =
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
const handleNameChange = (value: string) => {
setName(value);
if (!slugTouched.current) {
setSlug(nameToWorkspaceSlug(value));
setSlugServerError(null);
}
};
const handleSlugChange = (value: string) => {
slugTouched.current = true;
setSlug(value);
setSlugServerError(null);
};
const createWorkspace = useCreateWorkspace();
const handleCreate = () => {
if (!canCreate || createWorkspace.isPending) return;
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess: onCreated,
onError: (error) => {
if (isWorkspaceSlugConflict(error)) {
setSlugServerError(t(($) => $.step_workspace.slug_taken_error));
toast.error(t(($) => $.step_workspace.slug_conflict_toast));
return;
}
toast.error(
error instanceof Error && error.message
? error.message
: t(($) => $.step_workspace.create_failed_toast),
);
},
},
);
};
// Compute the footer CTA from whichever path the user is on. `null`
// is only reachable in the resume path; `existing` is only valid
// when we actually have a `reusing` workspace; everything else
// (including the no-existing path) funnels through `create`.
const isCreating = createWorkspace.isPending;
const creatingActive = !reusing || mode === "create";
const existingActive = Boolean(reusing) && mode === "existing";
let hint: string;
let continueLabel: string;
let continueDisabled: boolean;
let onContinue: () => void;
if (existingActive && reusing) {
hint = t(($) => $.step_workspace.hint_opening, { name: reusing.name });
continueLabel = t(($) => $.step_workspace.cta_open, { name: reusing.name });
continueDisabled = isCreating;
onContinue = () => onCreated(reusing);
} else if (creatingActive) {
if (isCreating) {
hint = t(($) => $.step_workspace.hint_creating_pending, {
name: name.trim() || t(($) => $.step_workspace.hint_creating_fallback),
});
continueLabel = t(($) => $.step_workspace.cta_creating);
continueDisabled = true;
onContinue = () => {};
} else if (canCreate) {
hint = t(($) => $.step_workspace.hint_creating, { name: name.trim() });
continueLabel = t(($) => $.step_workspace.cta_create_named, { name: name.trim() });
continueDisabled = false;
onContinue = handleCreate;
} else {
hint = t(($) => $.step_workspace.hint_name_first);
continueLabel = t(($) => $.step_workspace.cta_create_workspace);
continueDisabled = true;
onContinue = () => {};
}
} else {
hint = t(($) => $.step_workspace.hint_pick);
continueLabel = t(($) => $.common.continue);
continueDisabled = true;
onContinue = () => {};
}
const createFields = (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label
htmlFor="ws-name"
className="text-xs font-medium text-muted-foreground"
>
{t(($) => $.step_workspace.name_label)}
</Label>
<Input
id="ws-name"
autoFocus
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t(($) => $.step_workspace.name_placeholder)}
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleCreate();
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label
htmlFor="ws-slug"
className="text-xs font-medium text-muted-foreground"
>
{t(($) => $.step_workspace.url_label)}
</Label>
<div className="flex items-center rounded-md border bg-muted transition-colors focus-within:border-foreground">
<span className="select-none pl-3 font-mono text-sm text-muted-foreground">
{"multica.ai/"}
</span>
<Input
id="ws-slug"
type="text"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder={t(($) => $.step_workspace.slug_placeholder)}
className="border-0 bg-transparent font-mono shadow-none focus-visible:ring-0"
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleCreate();
}}
/>
</div>
{slugError && <p className="text-xs text-destructive">{slugError}</p>}
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">
{t(($) => $.step_workspace.issue_prefix_label)}
</div>
<div className="text-sm leading-[1.55] text-muted-foreground">
{t(($) => $.step_workspace.issue_prefix_prefix)}
<span className="font-mono text-foreground">
{issuePrefix(slug)}-123
</span>
{t(($) => $.step_workspace.issue_prefix_suffix)}
</div>
</div>
</div>
);
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 />
<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}
disabled={isCreating}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t(($) => $.common.back)}
</button>
) : (
<span aria-hidden className="w-0" />
)}
<div className="flex-1">
<StepHeader currentStep="workspace" />
</div>
</header>
<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">
{reusing
? t(($) => $.step_workspace.eyebrow_resume)
: t(($) => $.step_workspace.eyebrow_first)}
</div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
{reusing
? t(($) => $.step_workspace.headline_resume, { name: reusing.name })
: t(($) => $.step_workspace.headline_first)}
</h1>
<p className="mt-4 text-[15.5px] leading-[1.55] text-foreground/80">
{reusing
? t(($) => $.step_workspace.lede_resume)
: t(($) => $.step_workspace.lede_first)}
</p>
<div className="mt-10">
{reusing ? (
<div className="flex flex-col gap-3">
<ExistingWorkspaceCard
workspace={reusing}
selected={mode === "existing"}
onSelect={pickExisting}
/>
<CreateNewWorkspaceCard
selected={mode === "create"}
onSelect={pickCreate}
>
{createFields}
</CreateNewWorkspaceCard>
</div>
) : (
createFields
)}
</div>
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<span
aria-live="polite"
className="mr-auto text-xs text-muted-foreground"
>
{hint}
</span>
<Button size="lg" disabled={continueDisabled} onClick={onContinue}>
{continueLabel}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</main>
</div>
{/* Right — side panel.
Swap sides based on what the user is currently picking:
switching to "create" in the resume path swaps the preview
from "your existing workspace + what's next" to the generic
"what lives inside / things you'll do here" so the preview
stays honest to the user's current choice. */}
<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">
{reusing && mode !== "create" ? (
<ExistingWorkspaceSide workspace={reusing} />
) : (
<CreateWorkspaceSide />
)}
</div>
</aside>
</div>
);
}
function ExistingWorkspaceCard({
workspace,
selected,
onSelect,
}: {
workspace: Workspace;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className={cn(
"flex w-full items-center gap-4 rounded-lg border bg-card px-5 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",
)}
>
<WorkspaceAvatar name={workspace.name} size="lg" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="truncate text-[14.5px] font-medium text-foreground">
{workspace.name}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{`multica.ai/${workspace.slug}`}
</div>
</div>
<RadioMark selected={selected} />
</button>
);
}
/**
* Collapsible "Create a new workspace" radio card — shown in the resume
* path alongside the existing-workspace card. Clicking the header
* toggles selection; selected state expands to reveal the name / slug
* fields (passed in as children by the caller). Submission is driven
* by the parent's footer CTA, not a button inside this card.
*/
function CreateNewWorkspaceCard({
selected,
onSelect,
children,
}: {
selected: boolean;
onSelect: () => void;
children: ReactNode;
}) {
const { t } = useT("onboarding");
return (
<div
className={cn(
"overflow-hidden rounded-lg border bg-card transition-all",
selected
? "border-foreground shadow-[inset_0_0_0_1px_var(--color-foreground)]"
: "hover:border-foreground/20",
)}
>
<button
type="button"
role="radio"
aria-checked={selected}
aria-expanded={selected}
onClick={onSelect}
className="flex w-full items-center gap-4 px-5 py-4 text-left"
>
<div
aria-hidden
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
>
<Plus className="h-4 w-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="truncate text-[14.5px] font-medium text-foreground">
{t(($) => $.step_workspace.create_new_title)}
</div>
<div className="truncate text-xs text-muted-foreground">
{t(($) => $.step_workspace.create_new_subtitle)}
</div>
</div>
<RadioMark selected={selected} />
</button>
{selected && <div className="border-t px-5 py-5">{children}</div>}
</div>
);
}
function CreateWorkspaceSide() {
const { t } = useT("onboarding");
return (
<div className="flex flex-col gap-6">
<div className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_workspace.side_create_eyebrow)}
</div>
<WorkspacePreviewCard
name={t(($) => $.step_workspace.side_preview_name)}
slug={t(($) => $.step_workspace.side_preview_slug)}
/>
<div className="mt-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_workspace.side_things_eyebrow)}
</div>
<div className="flex flex-col gap-3.5">
<PerkRow>{t(($) => $.step_workspace.perk_assign)}</PerkRow>
<PerkRow>{t(($) => $.step_workspace.perk_chat)}</PerkRow>
<PerkRow>{t(($) => $.step_workspace.perk_invite)}</PerkRow>
<PerkRow>{t(($) => $.step_workspace.perk_switch)}</PerkRow>
</div>
</div>
);
}
function ExistingWorkspaceSide({ workspace }: { workspace: Workspace }) {
const { t } = useT("onboarding");
return (
<div className="flex flex-col gap-6">
<div className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_workspace.side_existing_eyebrow)}
</div>
<WorkspacePreviewCard name={workspace.name} slug={workspace.slug} />
<div className="mt-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.step_workspace.side_next_eyebrow)}
</div>
<div className="flex flex-col gap-3.5">
<PerkRow>{t(($) => $.step_workspace.next_runtime)}</PerkRow>
<PerkRow>{t(($) => $.step_workspace.next_agent)}</PerkRow>
<PerkRow>{t(($) => $.step_workspace.next_starter)}</PerkRow>
</div>
</div>
);
}
/**
* Visual preview of the sidebar the user is about to land on — same
* icons, same labels as the live `<AppSidebar />`, so the onboarding
* card doubles as "this is what your sidebar will look like." Entity
* set mirrors the Workspace + Configure groups, lifting Members from
* Settings to a first-class row because it's the most intuitive way
* to express "workspaces are multi-player."
*/
function WorkspacePreviewCard({
name,
slug,
}: {
name: string;
slug: string;
}) {
const { t } = useT("onboarding");
return (
<div className="overflow-hidden rounded-xl border bg-card shadow-xs">
<div className="flex items-center gap-3 border-b px-4 py-3.5">
<WorkspaceAvatar name={name} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="truncate text-[14px] font-medium text-foreground">
{name}
</div>
<div className="truncate font-mono text-[11.5px] text-muted-foreground">
{`multica.ai/${slug}`}
</div>
</div>
<Lock
aria-hidden
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
/>
</div>
<div className="flex flex-col">
<EntityRow
icon={<Inbox className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.inbox_label)}
meta={t(($) => $.step_workspace.preview.inbox_meta)}
/>
<EntityRow
icon={<ListTodo className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.issues_label)}
meta={t(($) => $.step_workspace.preview.issues_meta)}
/>
<EntityRow
icon={<Bot className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.agents_label)}
meta={t(($) => $.step_workspace.preview.agents_meta)}
/>
<EntityRow
icon={<FolderKanban className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.projects_label)}
meta={t(($) => $.step_workspace.preview.projects_meta)}
/>
<EntityRow
icon={<Zap className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.autopilot_label)}
meta={t(($) => $.step_workspace.preview.autopilot_meta)}
/>
<EntityRow
icon={<Monitor className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.runtimes_label)}
meta={t(($) => $.step_workspace.preview.runtimes_meta)}
/>
<EntityRow
icon={<BookOpenText className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.skills_label)}
meta={t(($) => $.step_workspace.preview.skills_meta)}
/>
<EntityRow
dim
icon={<MoreHorizontal className="h-4 w-4" />}
label={t(($) => $.step_workspace.preview.more_label)}
meta={t(($) => $.step_workspace.preview.more_meta)}
/>
</div>
</div>
);
}
function EntityRow({
icon,
label,
meta,
dim,
}: {
icon: ReactNode;
label: string;
meta: string;
/** Visually de-emphasized — used for the "and more" row at the bottom. */
dim?: boolean;
}) {
return (
<div className="flex items-center gap-3 px-4 py-2.5 [&:not(:last-child)]:border-b">
<span
aria-hidden
className={cn(
"shrink-0",
dim ? "text-muted-foreground/60" : "text-muted-foreground",
)}
>
{icon}
</span>
<span
className={cn(
"flex-1 text-[13.5px]",
dim ? "text-muted-foreground" : "text-foreground",
)}
>
{label}
</span>
<span
className={cn(
"font-mono text-[11.5px]",
dim ? "text-muted-foreground/70" : "text-muted-foreground",
)}
>
{meta}
</span>
</div>
);
}
function PerkRow({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-[18px_1fr] items-start gap-3">
<span
aria-hidden
className="mt-[11px] h-px w-3 shrink-0 bg-muted-foreground/40"
/>
<div className="text-[13.5px] leading-[1.55] text-foreground/85">
{children}
</div>
</div>
);
}