mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(onboarding): v3 — thin server, frontend-orchestrated welcome (#3008)
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal
Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:
- Who I am (built-in workspace assistant, not onboarding-only)
- What Multica is + docs/source/issues URLs as knowledge sources
- What I can do (CLI = manifest, `multica --help` is the source of truth)
- Tone (concise, like a colleague, match user's language)
Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.
Side effects required to make `onboarded_at == null` an honest signal:
- CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
The "member exists ⟹ onboarded_at != null" invariant is intentionally
broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
this — comments updated to reflect the new contract.
- AcceptInvitation still marks (invitee skips the modal in someone
else's workspace). Code comment added warning future removers.
- resolvePostAuthDestination flips to workspace-presence-first: a user
with a workspace lands in it regardless of `onboarded_at`, so the
modal can pick up an interrupted setup on relogin.
Other backend changes:
- `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
- `onboardingAssistantInstructions` rewritten to the 3-section identity
- `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
cap, empty-falls-back-to onboardingIssueDescription)
Frontend changes:
- Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
persisted step)
- `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
- `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
bootstrap, `onboarded_at` stays NULL so the modal fires
- Runtime step next-button copy → "Start exploring" / "开始探索"
- New `packages/views/workspace/onboarding-helper-modal.tsx`:
Base UI Dialog, dismissible=false, three localized cards, mutation
invalidates agents + issues queries then navigates to the seeded issue
- Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
`apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`
Tests:
- Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
TestCreateWorkspace_DoesNotMarkOnboarded
- Frontend: onboarding-helper-modal.test.tsx covers all four gating
conditions, three-card behavior, mutation pending state, and the
"no close button" invariant
Compatibility:
- Already-onboarded users: zero impact (modal can't fire)
- Invitees: AcceptInvitation still marks → modal can't fire
- Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
- Old desktop / web clients: legacy teammate-step path keeps working
(bootstrap accepts missing starter_prompt) — the new modal only fires
on the new frontend bundle
- Avatar SVG kept (asterisk variant) — no migration of existing Helper
agents, only newly-created Helpers pick up the new instructions/description
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open
On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).
Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.
Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* docs(onboarding): add v2 refactor plan
Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)
Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.
PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.
Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* refactor(server): collapse onboarding side effects to service layer
Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:
- MarkUserOnboarded + claim starter_content_state +
optional install-runtime fallback seed: was inline in
BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
AcceptInvitation, and CompleteOnboarding.
- install-runtime issue seeding: was inline in CreateWorkspace and
AcceptInvitation as a "no runtime yet" fallback.
After this refactor:
- MarkUserOnboarded is called from exactly one place (the service).
- install-runtime issue is seeded from exactly one place (the service).
- CreateWorkspace deliberately does not seed — the new
/ensure-onboarding-content endpoint (also added here) lets the
workspace-entry init component request the seed on first mount, so
workspaces created but never opened don't accumulate stale issues.
- The PatchOnboarding handler now accepts the new runtime_id /
runtime_skipped fields and rejects (uuid, skipped=true) up front.
- UserResponse exposes the two new persisted fields so the frontend
can read them off `me` without an extra round-trip.
Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.
Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat(core): API + types for persisted onboarding runtime choice
User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.
Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal
Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:
branch 0 me.onboarded_at != null → ensure install-runtime issue
fallback, render nothing
branch 1 me.onboarding_runtime_skipped → SkipBootstrapping component:
loading veil → bootstrap →
navigate. On failure shows
a Retry UI instead of
silently freezing the veil
branch 2 me.onboarding_runtime_id → render Modal with the
runtime id from `me` (no
internal list query)
branch 3 (none of the above) → useEffect navigate back to
/onboarding so the user
walks Step 3 again
The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.
Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat(onboarding): Step 3 records user choice instead of calling bootstrap
handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.
PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.
The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat(onboarding): v3 — thin server, frontend-orchestrated welcome
Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.
V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
- runtime path: find-or-create Multica Helper via generic createAgent
with bilingual instructions from `templates/helper-instructions.ts`,
blocking modal with 3 starter cards, pick -> createIssue + navigate
- skip path: provision install-runtime (in_progress) -> agent-guide
(todo, body embeds install-runtime mention chip) -> follow-up comment
on install-runtime mentioning agent-guide; then pop celebration
modal with 🎉 emoji pop animation, 2 read-only preview cards, single
[Got it] CTA that navigates to install-runtime
Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
deprecation shim in onboarding_shim.go for desktop < v3 during the
rollout window — handlers inlined to qtx.* calls, no service layer
Localization
- Persisted strings (issue titles/bodies, Helper instructions/
description, comment prefix) live as TS const `{en, zh}` maps in
`packages/views/onboarding/templates/` — i18n bundle staleness can no
longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
`packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
null for new users until they pick a preference)
Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
`postCommentDeduped`) so React StrictMode double-mount can't fire two
parallel API calls that the server would then 409
Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety
Welcome modal polish (the post-Step-3 surface this branch already
introduced):
Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
fade. New copy: "Hi, welcome to Multica / I'm your first Agent
assistant" + capability hint sentence so users discover assignment +
chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
border-primary + ring selection pattern used by compact-runtime-row;
bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
Helper to paste an HTML welcome page into the issue comment — works
on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
"All set!" + "Sit tight ☕ — your {agentName} is on it" + inbox/chat
hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
bundle being loaded); subtitle stays in onboarding.json.
Skip path
- Body restructured into three independent ```md blocks (Name /
Description / Instructions) so each picks up the markdown renderer's
per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
[identifier](mention://issue/uuid) so it renders as the styled
IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.
Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
useWelcomeStore.reset() so user B logging into the same browser
doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
currentWorkspace.id === signal.workspaceId — prevents firing the
modal in workspace B when the signal was parked for workspace A
(desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
pendingIssueSeed, pendingCommentSeed) for the three API calls so
React 18+ StrictMode dev double-mount can't race-create duplicates.
Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
read i18n.language (not me.language, which is null for new users
who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
avatar (kept the bouncy variant for the skip 🎉 hero where the
celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
one only).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix(views): i18n hardcoded "Close" in welcome FullScreenError
CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cb90249eac
commit
fbd965e5bf
@@ -3,9 +3,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { pickLocale } from "@multica/core/i18n";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
@@ -118,25 +120,31 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web layout
|
||||
// hard gate via overlays (desktop has no URL bar, so we open the
|
||||
// onboarding overlay instead of router.replace):
|
||||
// onboarded + has workspace → no overlay, dashboard
|
||||
// un-onboarded (any wsCount):
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// onboarded + no workspace → /workspaces/new overlay
|
||||
//
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
// V3 invariant: `onboarded_at != null` is the only path into the
|
||||
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
|
||||
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
|
||||
// somehow has a workspace but no onboarded mark must be sent back to
|
||||
// /onboarding — we also clear the active workspace so the dashboard
|
||||
// doesn't render under the overlay with stale workspace context.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (hasOnboarded && wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
// Stale workspace context (if any) would leak X-Workspace-Slug
|
||||
// headers into onboarding-time API calls. Clear it before opening
|
||||
// the overlay.
|
||||
setCurrentWorkspace(null, null);
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
@@ -258,6 +266,9 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
// Drop any post-onboarding welcome signal so user B logging in next
|
||||
// doesn't inherit user A's pending modal state.
|
||||
useWelcomeStore.getState().reset();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
@@ -34,6 +36,15 @@ export function WorkspaceRouteLayout() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
// While a WindowOverlay is open (onboarding, accept-invite, new-workspace),
|
||||
// the underlying tab is still mounted in the React tree — so this layout
|
||||
// and its WelcomeAfterOnboarding Modal would render UNDER the overlay.
|
||||
// Because the modal uses a Portal that targets document.body, it ends up
|
||||
// rendered LATER in the DOM and visually outranks the overlay's z-50.
|
||||
// Suppress the modal whenever any overlay is active; the moment the
|
||||
// overlay closes the welcome hook re-evaluates and pops if its store
|
||||
// signal is still set.
|
||||
const overlayActive = useWindowOverlayStore((s) => s.overlay !== null);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
|
||||
useEffect(() => {
|
||||
@@ -85,6 +96,14 @@ export function WorkspaceRouteLayout() {
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
{/* Reads the welcome-store transient signal parked by
|
||||
* OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay
|
||||
* (onboarding / accept-invite / new-workspace) is open so the modal
|
||||
* doesn't portal-jump in front of an active pre-workspace flow.
|
||||
* Once the overlay closes the hook re-evaluates and pops the
|
||||
* Modal — unless the store signal has already been consumed, in
|
||||
* which case the hook renders null. */}
|
||||
{!overlayActive && <WelcomeAfterOnboarding />}
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
@@ -31,6 +32,18 @@ export default function WorkspaceLayout({
|
||||
if (!isAuthLoading && !user) router.replace(paths.login());
|
||||
}, [isAuthLoading, user, router]);
|
||||
|
||||
// Hard onboarding gate. Authenticated user but onboarded_at NULL means
|
||||
// they bypassed /onboarding (typed the URL, deeplink, etc.). Redirect
|
||||
// back so the questionnaire + Step 3 finish. The reverse gate lives in
|
||||
// `apps/web/app/(auth)/onboarding/page.tsx` — onboarded users hitting
|
||||
// /onboarding bounce out to their workspace. Together those two effects
|
||||
// make `onboarded_at` the single source of truth for "may access /<slug>/*".
|
||||
useEffect(() => {
|
||||
if (user && user.onboarded_at == null) {
|
||||
router.replace(paths.onboarding());
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Resolve workspace by slug from the React Query list cache.
|
||||
// Enabled only when user is authenticated — otherwise the list query isn't seeded.
|
||||
const { data: workspace, isFetched: listFetched } = useQuery({
|
||||
@@ -86,6 +99,11 @@ export default function WorkspaceLayout({
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
{children}
|
||||
{/* Reads the welcome-store transient signal parked by
|
||||
* OnboardingFlow.handleRuntimeNext. Runtime path → loading veil →
|
||||
* blocking Modal with Helper + starter cards. Skip path → Modal
|
||||
* with two seeded issues. No signal → null. */}
|
||||
<WelcomeAfterOnboarding />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useMemo } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser";
|
||||
import type { LocaleResources, SupportedLocale } from "@multica/core/i18n";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import packageJson from "../package.json";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
@@ -66,7 +67,16 @@ export function WebProviders({
|
||||
wsUrl={deriveWsUrl()}
|
||||
cookieAuth={cookieAuth}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
onLogout={() => {
|
||||
// welcome-store holds the transient post-onboarding signal. Must
|
||||
// clear on logout so user B logging into the same browser doesn't
|
||||
// inherit user A's signal and have <WelcomeAfterOnboarding /> fire
|
||||
// listAgents / createIssue against a workspace user B doesn't even
|
||||
// belong to. The store's own docstring promises this reset; this
|
||||
// is where it gets wired.
|
||||
useWelcomeStore.getState().reset();
|
||||
clearLoggedInCookie();
|
||||
}}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Onboarding v3 — Thin Server, Frontend-Orchestrated Welcome
|
||||
|
||||
## 1. 背景
|
||||
|
||||
两次错位重构(MUL-2438 + da0ecb6a)叠加产出了一个味道很重的 onboarding:
|
||||
- `users.onboarded_at` 在 4 个 handler 里分别 `MarkUserOnboarded`,每处副作用还不一样
|
||||
- 同样的"种 install-runtime issue"在 4 个调用点重复,靠 advisory lock 在底层做去重
|
||||
- Step 3 Connect 与 Skip 路径不对称(一个延迟 mark,一个立即 mark)
|
||||
- 工作区 Modal 自己 query runtime 列表挑第一个,完全丢弃用户在 Step 3 的选择
|
||||
- `onboarded_at` 字段同时承担"完成状态"和"Modal 触发条件"双语义
|
||||
|
||||
最初的 v2 方案(本 wip 分支的 PR 1-7)走"持久化字段 + 工作区 init 4 分支 dispatcher"路线 — 把 Step 3 的选择 PATCH 进 `users.onboarding_runtime_id` / `onboarding_runtime_skipped`,工作区入口读这两个字段决定弹什么。可工作,但把过去 React 内存里的瞬态意图变成了数据库的持久化字段,后端业务复杂度不降反升:多了一个 `OnboardingService`,多了一个 `WorkspaceContentService`,handler 多了一个 `EnsureOnboardingContent` endpoint,前端多了 4 分支 dispatcher 组件。
|
||||
|
||||
数据分析师确认生产里 0 个 mid-flow 用户(`有 workspace + onboarded_at IS NULL`),v2 加的两字段没有真实使用,可以安全删除。
|
||||
|
||||
v3 在 v2 之上做更激进的清理:**后端 onboarding 业务复杂度归零,Helper agent / starter issue / 中英文案全部在前端 hook 里调通用 `createAgent` / `createIssue` 完成**。
|
||||
|
||||
## 2. 设计 — 5 条核心原则
|
||||
|
||||
1. **`users.onboarded_at` 是唯一 onboarding 状态字段**(删 v2 加的两字段)
|
||||
2. **Onboarding 阶段(Step 1-3)纯收集**,Step 3 按钮 = mark + welcome-store.set + navigate
|
||||
3. **后端 onboarding 业务复杂度归零** — 删 `BootstrapOnboarding*` / `OnboardingService` / `WorkspaceContentService` / `EnsureOnboardingContent` 全套;后端 onboarding 只剩 mark + 问卷
|
||||
4. **进工作区 hook 看 welcome-store**,有信号 → 按 runtime/skip 分两路调通用 `createAgent` / `createIssue`
|
||||
5. **Helper instructions / starter prompt / install-runtime issue 描述(EN/ZH)全部在前端 TS 模块**(`packages/views/onboarding/templates/`)
|
||||
|
||||
外加双向路由 hard gate:`apps/web/app/[workspaceSlug]/layout.tsx` 拦未 onboarded → `/onboarding`;`apps/web/app/(auth)/onboarding/page.tsx` 拦已 onboarded → workspace。桌面端用 `App.tsx` 的 overlay 决策达成同样效果(no URL bar, no router.replace)。
|
||||
|
||||
## 3. 4 类用户链路
|
||||
|
||||
### 3.1 新用户,Step 3 选 runtime
|
||||
|
||||
```
|
||||
Step 3 [开始探索]
|
||||
→ completeOnboarding("full", workspace.id) ─ 后端只 mark
|
||||
→ welcomeStore.set({wsId, choice:"runtime", runtimeId})
|
||||
→ navigate(/<slug>/issues)
|
||||
workspace layout gate: onboarded_at != NULL ✓ 放行
|
||||
<WelcomeAfterOnboarding /> consumes welcome-store:
|
||||
phase 1: full-screen loading "Preparing your Helper…"
|
||||
api.listAgents → 查重名 Multica Helper(workspace 可见的)
|
||||
→ 找到 → 复用
|
||||
→ 没找到 → api.createAgent({name, instructions: HELPER_INSTRUCTIONS[lang], runtime_id, ...})
|
||||
phase 2: blocking Modal(无关闭按钮 / Escape no-op / outside-click no-op)
|
||||
Helper avatar + name + description + 3 张 starter cards
|
||||
phase 3: 用户挑卡 → api.createIssue({title, description: prompt,
|
||||
assignee_type:"agent", assignee_id: agent.id}) → navigate(issue)
|
||||
```
|
||||
|
||||
### 3.2 新用户,Step 3 点 Skip
|
||||
|
||||
```
|
||||
Step 3 [跳过]
|
||||
→ completeOnboarding("runtime_skipped", workspace.id)
|
||||
→ welcomeStore.set({wsId, choice:"skip"})
|
||||
→ navigate(/<slug>/issues)
|
||||
workspace layout gate ✓ 放行
|
||||
<WelcomeAfterOnboarding /> consumes signal:
|
||||
Modal 打开(可关闭)
|
||||
后台并发 api.createIssue × 2:
|
||||
- Connect a runtime to start using agents
|
||||
- Create your first Multica Agent
|
||||
渲染 2 张 issue 卡;点卡 → navigate;关 Modal → 留在 /issues 列表
|
||||
```
|
||||
|
||||
### 3.3 被邀请用户
|
||||
|
||||
```
|
||||
AcceptInvitation handler:
|
||||
事务内 qtx.CreateMember + qtx.MarkUserOnboarded(必须保留!)
|
||||
navigate(/<slug>/issues) 不带 welcome-store 信号
|
||||
workspace layout gate ✓ 放行
|
||||
<WelcomeAfterOnboarding /> 无信号 → 渲染 null
|
||||
```
|
||||
|
||||
### 3.4 老用户回访
|
||||
|
||||
```
|
||||
resolvePostAuthDestination: hasOnboarded ✓ + workspace[0] → /<slug>/issues
|
||||
layout gate ✓ 放行
|
||||
welcome-store 无信号 → 不弹
|
||||
```
|
||||
|
||||
## 4. 数据 / 字段终态
|
||||
|
||||
| 字段 | 含义 | 谁读 |
|
||||
|---|---|---|
|
||||
| `users.onboarded_at` | 唯一 onboarding 完成信号 | layout hard gate × 2 |
|
||||
| `users.onboarding_questionnaire` | 问卷答案 | OnboardingFlow 预填 |
|
||||
| (PostHog `OnboardingCompleted` event) | Skip vs Connect 历史分析 | 数据分析 |
|
||||
|
||||
**删掉**:`onboarding_runtime_id`、`onboarding_runtime_skipped` 列 + `user_onboarding_runtime_choice_check` CHECK 约束。Welcome 触发改用前端 Zustand transient store(`packages/core/onboarding/welcome-store.ts`),read-once-then-clear 语义。
|
||||
|
||||
## 5. 关键设计决策
|
||||
|
||||
### D1:NavigationAdapter 不支持 state → Zustand transient store
|
||||
|
||||
`packages/views/navigation/types.ts` 的 `push(path: string): void` 不接 state,所以不能用 `navigate(path, {state})` 模式。改用 `packages/core/onboarding/welcome-store.ts`:Step 3 完成时 `set({...})`,Welcome hook mount 时 `consume()`(一次读 + 清空)。store 非 persist → 刷新即丢 = Welcome Modal 不会被刷新重弹,符合"一次性体验"预期。
|
||||
|
||||
### D2:Desktop 路由 hard gate 走 overlay 而非 router.replace
|
||||
|
||||
桌面端 onboarding 是 `WindowOverlay`,不是 react-router 路由。`apps/desktop/src/renderer/src/App.tsx` 的 overlay 决策 effect 增加规则:`!hasOnboarded` 一律 `setCurrentWorkspace(null,null) + open onboarding overlay`(不管 wsCount)。web 端在 `layout.tsx` 用 `router.replace(paths.onboarding())` 直接跳。两套实现在效果上等价。
|
||||
|
||||
### D3:`resolvePostAuthDestination` 改回 onboarded-first
|
||||
|
||||
V2 改成了 workspace-presence-first(为了不把 mid-flow 用户踢回 onboarding),v3 还原成 onboarded-first。理由:layout hard gate 会在 `!onboarded` 时强制 redirect,resolver 直接走 workspace 只是浪费一次 navigation;mid-flow 在 v3 下概率极低(仅 Step 2 完关 app 这一窗口)。
|
||||
|
||||
### D4:Helper instructions / 长文案放 TS 模块,不放 i18n JSON
|
||||
|
||||
instructions 是 94 行 markdown,issue 描述是 60+ 行带列表 / 代码块。放在 `packages/views/onboarding/templates/` 下 3 个 TS 模块,每个导出 `{en, zh}` 字符串 const。短文案(Modal 标题 / 副标题 / 按钮 / 卡片标题)仍走 i18n JSON。
|
||||
|
||||
### D5:重名 Helper 防重
|
||||
|
||||
刷新 / StrictMode 双 mount 都可能让 hook 多次进入。前端用:
|
||||
1. `useRef` 一次性锁 prevents 同 mount 内重复触发
|
||||
2. Welcome 信号 `consume()` 一读即清,即使 React 多次 mount 也只第一次拿到信号
|
||||
3. 进 `createAgent` 前先 `api.listAgents` 查重名 + visibility="workspace" + !archived,有则复用
|
||||
|
||||
### D6:失败处理
|
||||
|
||||
- runtime 路径:阻塞 Modal + 失败 retry UI(无关闭),用户卡死时 `onAbandon` 把信号清空恢复路由
|
||||
- skip 路径:Modal 可关闭,每条 issue 失败显示 per-card retry
|
||||
- `onboarded_at` 已 set,失败不阻塞 — 用户最坏情况是手动建 agent / 关 Modal 用空 workspace
|
||||
|
||||
## 6. 文件改动盘点(相对 main)
|
||||
|
||||
### 删
|
||||
- `server/internal/service/onboarding.go`
|
||||
- `server/internal/service/workspace_content.go`
|
||||
- `server/internal/handler/onboarding.go`:`BootstrapOnboardingRuntime`、`BootstrapOnboardingNoRuntime` handler 整体 + 相关常量(`onboardingAssistantInstructions` 94 行 markdown、`onboardingAssistantName`、`onboardingIssueTitle`、`onboardingIssueDescription`、`onboardingAssistantAvatarURL`、`onboardingAssistantDescription`、`onboardingAgentTemplate`)
|
||||
- `server/internal/handler/workspace.go`:`EnsureOnboardingContent` handler
|
||||
- `server/cmd/server/router.go`:3 条路由(`runtime-bootstrap` / `no-runtime-bootstrap` / `ensure-onboarding-content`)
|
||||
- `server/internal/handler/handler.go`:`OnboardingService` / `WorkspaceContent` 字段 + 实例化
|
||||
- `server/internal/handler/onboarding_test.go`:5 个 Bootstrap* 测试 + 2 个 PatchOnboarding 测试(只留 JoinCloudWaitlist 测试)
|
||||
- `packages/views/workspace/workspace-onboarding-init.tsx` + 测试
|
||||
- `packages/views/workspace/onboarding-helper-modal.tsx` + 测试
|
||||
- `packages/core/onboarding/store.ts`:`recordOnboardingRuntimeChoice` / `recordOnboardingRuntimeSkipped` / `bootstrapRuntimeOnboarding` / `bootstrapNoRuntimeOnboarding`
|
||||
- `packages/core/api/client.ts`:`bootstrapOnboardingRuntime` / `bootstrapOnboardingNoRuntime` / `ensureOnboardingContent` + Response interfaces + EMPTY consts
|
||||
- `packages/core/api/schemas.ts`:两个 Bootstrap schemas + User schema 的两字段
|
||||
- `packages/core/types/workspace.ts`:User 的两个新字段
|
||||
- `packages/views/locales/{en,zh-Hans}/onboarding.json`:`onboarding_helper_modal.*` 和 `workspace_init.*` keys
|
||||
|
||||
### 改
|
||||
- `server/migrations/098_user_onboarding_runtime_choice.up.sql`:反转为 DROP CONSTRAINT/COLUMN IF EXISTS(同名文件、保留 098 序号)
|
||||
- `server/migrations/098_user_onboarding_runtime_choice.down.sql`:no-op + 注释说明不可逆
|
||||
- `server/pkg/db/queries/user.sql`:`PatchUserOnboarding` 回退到只接 questionnaire
|
||||
- `server/internal/handler/onboarding.go`:`CompleteOnboarding` 简化为直接 `qtx.MarkUserOnboarded`(无 service 包装)+ `PatchOnboarding` 只接 questionnaire
|
||||
- `server/internal/handler/invitation.go`:`AcceptInvitation` 改回直接 `qtx.MarkUserOnboarded`(保留 mark 调用 — 注释强调 layout gate 依赖)
|
||||
- `server/internal/handler/workspace.go`:`CreateWorkspace` 删 `ClaimStarterContentStateIfUnset` 调用(后端不再触碰 `starter_content_state`)
|
||||
- `server/internal/handler/auth.go`:`UserResponse` / `userToResponse` 删两字段
|
||||
- `server/internal/handler/workspace_test.go`:测试断言保留,注释更新引用 layout gate 而非 OnboardingHelperModal
|
||||
- `apps/web/app/[workspaceSlug]/layout.tsx`:加 hard gate effect + 改挂 `<WelcomeAfterOnboarding />`
|
||||
- `apps/desktop/src/renderer/src/App.tsx`:overlay 决策加规则 — `!hasOnboarded` 一律开 onboarding overlay 并先 `setCurrentWorkspace(null,null)`
|
||||
- `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`:`<WorkspaceOnboardingInit />` → `<WelcomeAfterOnboarding />`
|
||||
- `packages/core/onboarding/index.ts`:精简 exports + 加 welcome-store exports
|
||||
- `packages/core/paths/resolve.ts`:回到 onboarded-first 优先级
|
||||
- `packages/core/paths/resolve.test.ts`:断言同步翻新
|
||||
- `packages/views/onboarding/onboarding-flow.tsx`:`handleRuntimeNext` 改为 await `completeOnboarding` + `welcomeStore.set` + navigate
|
||||
- `apps/web/test/helpers.tsx`:mockUser 删两字段
|
||||
- `packages/views/package.json` exports:删 v2 两个 workspace 入口,加 `./workspace/welcome-after-onboarding`
|
||||
|
||||
### 加
|
||||
- `packages/core/onboarding/welcome-store.ts` + 测试:Zustand transient store
|
||||
- `packages/views/onboarding/templates/helper-instructions.ts`:Helper agent 系统提示词,EN/ZH 各一份
|
||||
- `packages/views/onboarding/templates/install-runtime-issue.ts`:Skip 路径第 1 条 issue 文案(从 server 的 EN/ZH 双语描述完整搬过来)
|
||||
- `packages/views/onboarding/templates/create-agent-guide-issue.ts`:Skip 路径第 2 条新 issue 文案(EN/ZH)
|
||||
- `packages/views/onboarding/templates/index.ts`:barrel + `pickContentLang(language)`
|
||||
- `packages/views/workspace/welcome-after-onboarding.tsx`:替代 v2 两个组件的统一 Welcome 体验
|
||||
- `packages/views/workspace/welcome-after-onboarding.test.tsx`:5 个测试覆盖 runtime / skip / 重名复用 / navigate / 渲染门
|
||||
- `packages/views/locales/{en,zh-Hans}/onboarding.json`:新增 `welcome_after_onboarding.{loading_helper,error_*,retry,dismiss_error,runtime.{title,subtitle,helper_description,cards.*},skip.{title,subtitle,open_issue,close,cards.*}}`
|
||||
|
||||
## 7. 老用户兼容性
|
||||
|
||||
生产数据(数据分析师 read-only audit):
|
||||
- `users_with_workspace` = 33,172
|
||||
- 其中 `onboarded_at IS NULL` = **0**
|
||||
|
||||
→ 没有 mid-flow 用户需要 backfill 或邮件通知。migration 098 反转 SQL 在生产里是 no-op(没有列要删,因为生产从未运行过 v2 的 add-column);在 dev 数据库上 IF EXISTS 保护让任何状态都能干净收敛到"无字段"。
|
||||
|
||||
老用户(`onboarded_at != NULL`)100% 安全:
|
||||
- web layout hard gate `if (user.onboarded_at == null)` → 永不触发
|
||||
- desktop App.tsx overlay 决策 `!hasOnboarded && wsCount > 0` → 永不触发
|
||||
- welcome-store 在 onboarding-flow.tsx Step 3 之外永不 `set` → Welcome Modal 永不弹
|
||||
|
||||
被邀请用户(invitee)的 `AcceptInvitation` 必须保留 `qtx.MarkUserOnboarded` — 注释里强调:"DO NOT REMOVE 否则 layout gate 把 invitee 踢回 /onboarding"。
|
||||
|
||||
## 8. 验收 — invariant + deprecation shim
|
||||
|
||||
每条可用 grep 或单元测试机械验证:
|
||||
|
||||
1. `onboarded_at` 的写入收敛到 3 个 handler:`CompleteOnboarding`(v3 主路径)、`AcceptInvitation`(invitee 必须)、`BootstrapOnboarding*`(**shim,见 §10**)。全部直接调 `qtx.MarkUserOnboarded`,无 service 包装。`grep -rln "qtx.MarkUserOnboarded\|h.Queries.MarkUserOnboarded" server/internal/handler/ | grep -v _test.go`
|
||||
2. v3 前端永不调旧 bootstrap endpoint:`grep -rn "bootstrapOnboardingRuntime\|bootstrapOnboardingNoRuntime\|ensureOnboardingContent" packages/ apps/` → 0 命中
|
||||
3. v2 字段全删:`grep -rn "onboarding_runtime_id\|onboarding_runtime_skipped" packages/ apps/` → 0 命中
|
||||
4. Welcome Modal 不再自查环境:`grep -rn "runtimeListOptions" packages/views/workspace/` → 0 命中
|
||||
5. welcome-store 信号只来自 onboarding-flow,只被 welcome-after-onboarding 消费(`grep -rln "useWelcomeStore"` 应该只命中 4 个文件:store、welcome 组件、onboarding-flow、各自的测试)
|
||||
6. 老用户登录 → 直接进工作区 → 不弹 Modal、无 loading veil(场景 4 手动验证)
|
||||
|
||||
## 10. Deprecation shim — `BootstrapOnboarding*`
|
||||
|
||||
**为什么保留**:v3 server 发布到 desktop auto-update 完成之间有 ~30 分钟真空期,期间老桌面会调旧 endpoint。删了 endpoint → 老桌面 404 → 新用户 onboarding 死循环。
|
||||
|
||||
**位置**:`server/internal/handler/onboarding_shim.go`(独立文件,所有 deprecated 代码都在这里;v3 主路径 `onboarding.go` 不含一行 shim 代码)。
|
||||
|
||||
**约束**:
|
||||
- 简化版实现:handler 直接 `qtx.MarkUserOnboarded` + `qtx.CreateAgent` + `qtx.CreateIssue` + `qtx.SetStarterContentState`,不重新引入 `OnboardingService` / `WorkspaceContentService` 抽象层(那是 v2 的复杂度,已经死了)
|
||||
- 文案常量完整保留(`onboardingAssistantInstructions` 94 行 markdown、`enNoRuntimeIssueDescription` 等),和前端 `packages/views/onboarding/templates/` 双轨并行,**这两份必须保持同步**直到 shim 删除
|
||||
- `claimStarterContentStateIfUnset` 局部 helper 也保留,因为老桌面靠这个字段抑制 legacy starter-content dialog
|
||||
- 5 个回归测试(`TestBootstrapOnboarding*`)保留,保护 shim 行为不被无意改动
|
||||
|
||||
**删除条件**:`X-Client-Version` telemetry 确认无任何活跃 desktop 在调这俩 endpoint(典型窗口:2-3 个 release 后)。删的时候一并删:
|
||||
- `server/internal/handler/onboarding_shim.go` 整文件
|
||||
- `server/cmd/server/router.go` 里 2 条 deprecated 注释 + 路由
|
||||
- `server/internal/handler/onboarding_test.go` 末尾 5 个 `TestBootstrapOnboarding*`
|
||||
|
||||
## 9. 未来工作
|
||||
|
||||
- `starter_content_state` 列(老桌面端兼容)— v3 后端不再触碰,但列保留(老桌面读 NULL → 渲染 legacy 导入 dialog)。可在 desktop 0.2.x 之前的版本全部 EOL 后单独 PR 删
|
||||
- `OnboardingCompletionPath` 里的 `cloud_waitlist` enum 值实际无人发送 — 可清理
|
||||
- `apps/web/app/[workspaceSlug]/layout.test.tsx`(新增)+ `apps/desktop/src/renderer/src/App.test.tsx`(新增用例)覆盖两端 hard gate 的 redirect 行为 — 当前由手动 E2E 覆盖
|
||||
@@ -139,8 +139,6 @@ import {
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
@@ -177,30 +175,6 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
@@ -415,43 +389,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapOnboardingRuntime(payload: {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
}): Promise<OnboardingRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrapOnboardingNoRuntime(payload: {
|
||||
workspace_id: string;
|
||||
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/no-runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
|
||||
@@ -206,17 +206,6 @@ export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
|
||||
@@ -9,9 +9,11 @@ export type {
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
|
||||
export {
|
||||
useWelcomeStore,
|
||||
type WelcomeSignal,
|
||||
} from "./welcome-store";
|
||||
|
||||
@@ -26,19 +26,19 @@ const ALL_ROLES: Role[] = [
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate (v2)", () => {
|
||||
describe("recommendTemplate", () => {
|
||||
describe("engineer × use_case tiebreaker", () => {
|
||||
it.each<UseCase>(["manage_team", "plan_research"])(
|
||||
"engineer × %s → planning",
|
||||
"engineer × [%s] → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
expect(
|
||||
recommendTemplate({ role: "engineer", use_case: [use_case] }),
|
||||
).toBe("planning");
|
||||
},
|
||||
);
|
||||
it("engineer × write_publish → writing", () => {
|
||||
it("engineer × [write_publish] → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "engineer", use_case: "write_publish" }),
|
||||
recommendTemplate({ role: "engineer", use_case: ["write_publish"] }),
|
||||
).toBe("writing");
|
||||
});
|
||||
it.each<UseCase>([
|
||||
@@ -47,32 +47,72 @@ describe("recommendTemplate (v2)", () => {
|
||||
"automate_ops",
|
||||
"evaluate",
|
||||
"other",
|
||||
])("engineer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case })).toBe("coding");
|
||||
])("engineer × [%s] → coding", (use_case) => {
|
||||
expect(
|
||||
recommendTemplate({ role: "engineer", use_case: [use_case] }),
|
||||
).toBe("coding");
|
||||
});
|
||||
it("engineer × null → coding", () => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case: null })).toBe(
|
||||
it("engineer × [] → coding", () => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case: [] })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("product × use_case", () => {
|
||||
it("product × ship_code → coding", () => {
|
||||
describe("multi-select priority (first matching branch wins)", () => {
|
||||
// Engineer + (manage_team OR plan_research) wins over write_publish
|
||||
// wins over the default coding fallback. The order inside the
|
||||
// recommendTemplate switch is the implicit priority.
|
||||
it("engineer × [ship_code, manage_team] → planning (manage_team wins over default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product", use_case: "ship_code" }),
|
||||
recommendTemplate({
|
||||
role: "engineer",
|
||||
use_case: ["ship_code", "manage_team"],
|
||||
}),
|
||||
).toBe("planning");
|
||||
});
|
||||
it("engineer × [write_publish, ship_code] → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({
|
||||
role: "engineer",
|
||||
use_case: ["write_publish", "ship_code"],
|
||||
}),
|
||||
).toBe("writing");
|
||||
});
|
||||
it("engineer × [manage_team, write_publish] → planning (earlier branch wins)", () => {
|
||||
expect(
|
||||
recommendTemplate({
|
||||
role: "engineer",
|
||||
use_case: ["manage_team", "write_publish"],
|
||||
}),
|
||||
).toBe("planning");
|
||||
});
|
||||
it("null × [ship_code, write_publish] → coding (fallback priority)", () => {
|
||||
expect(
|
||||
recommendTemplate({
|
||||
role: null,
|
||||
use_case: ["ship_code", "write_publish"],
|
||||
}),
|
||||
).toBe("coding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("product × use_case", () => {
|
||||
it("product × [ship_code] → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product", use_case: ["ship_code"] }),
|
||||
).toBe("coding");
|
||||
});
|
||||
it.each<UseCase>(["manage_team", "plan_research", "evaluate", "other"])(
|
||||
"product × %s → planning",
|
||||
"product × [%s] → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "product", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
expect(
|
||||
recommendTemplate({ role: "product", use_case: [use_case] }),
|
||||
).toBe("planning");
|
||||
},
|
||||
);
|
||||
it("product × null → planning", () => {
|
||||
expect(recommendTemplate({ role: "product", use_case: null })).toBe(
|
||||
it("product × [] → planning", () => {
|
||||
expect(recommendTemplate({ role: "product", use_case: [] })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
@@ -80,69 +120,71 @@ describe("recommendTemplate (v2)", () => {
|
||||
|
||||
describe("marketing × use_case", () => {
|
||||
it.each<UseCase>(["write_publish", "plan_research"])(
|
||||
"marketing × %s → writing",
|
||||
"marketing × [%s] → writing",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "marketing", use_case })).toBe(
|
||||
"writing",
|
||||
);
|
||||
expect(
|
||||
recommendTemplate({ role: "marketing", use_case: [use_case] }),
|
||||
).toBe("writing");
|
||||
},
|
||||
);
|
||||
it("marketing × manage_team → planning", () => {
|
||||
it("marketing × [manage_team] → planning", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "marketing", use_case: "manage_team" }),
|
||||
recommendTemplate({ role: "marketing", use_case: ["manage_team"] }),
|
||||
).toBe("planning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("single-template roles", () => {
|
||||
it.each(ALL_USE_CASES)("writer × %s → writing", (use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe("writing");
|
||||
});
|
||||
it.each(ALL_USE_CASES)("designer × %s → assistant", (use_case) => {
|
||||
expect(recommendTemplate({ role: "designer", use_case })).toBe(
|
||||
"assistant",
|
||||
it.each(ALL_USE_CASES)("writer × [%s] → writing", (use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case: [use_case] })).toBe(
|
||||
"writing",
|
||||
);
|
||||
});
|
||||
it.each(ALL_USE_CASES)("research × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "research", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
it.each(ALL_USE_CASES)("designer × [%s] → assistant", (use_case) => {
|
||||
expect(
|
||||
recommendTemplate({ role: "designer", use_case: [use_case] }),
|
||||
).toBe("assistant");
|
||||
});
|
||||
it.each(ALL_USE_CASES)("research × [%s] → planning", (use_case) => {
|
||||
expect(
|
||||
recommendTemplate({ role: "research", use_case: [use_case] }),
|
||||
).toBe("planning");
|
||||
});
|
||||
it.each<Role>(["founder", "ops", "student", "other"])(
|
||||
"%s → assistant",
|
||||
(role) => {
|
||||
expect(recommendTemplate({ role, use_case: null })).toBe("assistant");
|
||||
expect(recommendTemplate({ role, use_case: [] })).toBe("assistant");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("role skipped — use_case fallback", () => {
|
||||
it("null × ship_code → coding", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: "ship_code" })).toBe(
|
||||
it("null × [ship_code] → coding", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: ["ship_code"] })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
it("null × write_publish → writing", () => {
|
||||
it("null × [write_publish] → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: null, use_case: "write_publish" }),
|
||||
recommendTemplate({ role: null, use_case: ["write_publish"] }),
|
||||
).toBe("writing");
|
||||
});
|
||||
it.each<UseCase>(["manage_team", "plan_research"])(
|
||||
"null × %s → planning",
|
||||
"null × [%s] → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: null, use_case })).toBe("planning");
|
||||
expect(recommendTemplate({ role: null, use_case: [use_case] })).toBe(
|
||||
"planning",
|
||||
);
|
||||
},
|
||||
);
|
||||
it("both null → assistant", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
it("both empty → assistant", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: [] })).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exhaustive role coverage", () => {
|
||||
it.each(ALL_ROLES)("role=%s returns a valid template id", (role) => {
|
||||
const result = recommendTemplate({ role, use_case: null });
|
||||
const result = recommendTemplate({ role, use_case: [] });
|
||||
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,21 @@ import type { QuestionnaireAnswers, Role, UseCase } from "./types";
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
/**
|
||||
* Pick a recommended agent template based on the v2 questionnaire
|
||||
* Pick a recommended agent template based on the questionnaire
|
||||
* (role × use_case). Role is the primary signal; use_case is a
|
||||
* tiebreaker for roles that legitimately split between templates
|
||||
* (engineer / product / marketing).
|
||||
*
|
||||
* `use_case` is multi-select — when a user picks several, the rules
|
||||
* below use `.includes(...)` against the set. Order of evaluation
|
||||
* inside each role's switch is the implicit priority (first match
|
||||
* wins). For ambiguous overlaps (e.g. engineer who picks both
|
||||
* `manage_team` and `write_publish`) the earlier branch wins, which
|
||||
* matches the prior single-select behavior when only one of those
|
||||
* was selectable.
|
||||
*
|
||||
* Fallback chain when role is skipped or null:
|
||||
* 1. Derive from use_case alone.
|
||||
* 1. Derive from use_case alone (same priority order).
|
||||
* 2. Both unknown → `assistant` (the generic default).
|
||||
*
|
||||
* Pure / deterministic — safe to call on every render.
|
||||
@@ -23,25 +31,25 @@ export function recommendTemplate(
|
||||
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
|
||||
): AgentTemplateId {
|
||||
const role: Role | null = answers.role;
|
||||
const useCase: UseCase | null = answers.use_case;
|
||||
const useCases: readonly UseCase[] = answers.use_case ?? [];
|
||||
|
||||
if (role === null) return fallbackFromUseCase(useCase);
|
||||
if (role === null) return fallbackFromUseCase(useCases);
|
||||
|
||||
switch (role) {
|
||||
case "engineer":
|
||||
if (useCase === "manage_team" || useCase === "plan_research")
|
||||
if (useCases.includes("manage_team") || useCases.includes("plan_research"))
|
||||
return "planning";
|
||||
if (useCase === "write_publish") return "writing";
|
||||
if (useCases.includes("write_publish")) return "writing";
|
||||
return "coding";
|
||||
case "product":
|
||||
if (useCase === "ship_code") return "coding";
|
||||
if (useCases.includes("ship_code")) return "coding";
|
||||
return "planning";
|
||||
case "designer":
|
||||
return "assistant";
|
||||
case "writer":
|
||||
return "writing";
|
||||
case "marketing":
|
||||
if (useCase === "write_publish" || useCase === "plan_research")
|
||||
if (useCases.includes("write_publish") || useCases.includes("plan_research"))
|
||||
return "writing";
|
||||
return "planning";
|
||||
case "research":
|
||||
@@ -54,16 +62,10 @@ export function recommendTemplate(
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackFromUseCase(useCase: UseCase | null): AgentTemplateId {
|
||||
switch (useCase) {
|
||||
case "ship_code":
|
||||
return "coding";
|
||||
case "write_publish":
|
||||
return "writing";
|
||||
case "manage_team":
|
||||
case "plan_research":
|
||||
return "planning";
|
||||
default:
|
||||
return "assistant";
|
||||
}
|
||||
function fallbackFromUseCase(useCases: readonly UseCase[]): AgentTemplateId {
|
||||
if (useCases.includes("ship_code")) return "coding";
|
||||
if (useCases.includes("write_publish")) return "writing";
|
||||
if (useCases.includes("manage_team") || useCases.includes("plan_research"))
|
||||
return "planning";
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import type { OnboardingStep } from "./types";
|
||||
* intro, not a persisted step. It doesn't show a progress indicator
|
||||
* for the same reason — users shouldn't think of reading the intro
|
||||
* as progress toward completing setup.
|
||||
*
|
||||
* Note: "teammate" (the old "Create your first agent" step) is no longer
|
||||
* part of the in-flow sequence. Helper agent creation now happens after
|
||||
* onboarding exits, via the workspace OnboardingHelperModal — see
|
||||
* `packages/views/workspace/onboarding-helper-modal.tsx`.
|
||||
*/
|
||||
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"source",
|
||||
@@ -20,5 +25,4 @@ export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"use_case",
|
||||
"workspace",
|
||||
"runtime",
|
||||
"teammate",
|
||||
] as const;
|
||||
|
||||
@@ -22,20 +22,32 @@ export async function saveQuestionnaire(
|
||||
useAuthStore.getState().setUser(user);
|
||||
// Mirror the three cohort signals into person properties so every
|
||||
// PostHog event on this user can be broken down by source / role /
|
||||
// use_case without re-joining the DB.
|
||||
if (answers.source || answers.role || answers.use_case) {
|
||||
// use_case without re-joining the DB. source / use_case are arrays
|
||||
// (multi-select); PostHog accepts array property values, and
|
||||
// breakdowns split each element into its own group.
|
||||
const sourceList = answers.source ?? [];
|
||||
const useCaseList = answers.use_case ?? [];
|
||||
if (sourceList.length > 0 || answers.role || useCaseList.length > 0) {
|
||||
setPersonProperties({
|
||||
...(answers.source ? { source: answers.source } : {}),
|
||||
...(sourceList.length > 0 ? { source: sourceList } : {}),
|
||||
...(answers.role ? { role: answers.role } : {}),
|
||||
...(answers.use_case ? { use_case: answers.use_case } : {}),
|
||||
...(useCaseList.length > 0 ? { use_case: useCaseList } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize onboarding. POST /complete marks `onboarded_at` atomically
|
||||
* (COALESCE-guarded for idempotency). We then refresh the auth store
|
||||
* so every gate sees the updated user.
|
||||
* (COALESCE-guarded for idempotency) and emits the `onboarding_completed`
|
||||
* analytics event exactly once. We then refresh the auth store so every
|
||||
* gate sees the updated user — most importantly the workspace layout
|
||||
* hard gate that redirects un-onboarded users back to /onboarding.
|
||||
*
|
||||
* v3 contract: this is the ONLY mechanism that flips `onboarded_at`
|
||||
* from the frontend. All Helper-agent / starter-issue creation is now
|
||||
* done by the welcome hook in the workspace shell using generic
|
||||
* `createAgent` / `createIssue` calls, AFTER this call has returned
|
||||
* and the user has been navigated into the workspace.
|
||||
*
|
||||
* `completionPath` is the client's view of which Step-3 exit the user
|
||||
* took; the server funnel-splits `onboarding_completed` on this value.
|
||||
@@ -53,37 +65,6 @@ export async function completeOnboarding(
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-connected onboarding path. The server creates or reuses the
|
||||
* default Multica Helper agent and the single onboarding issue, then
|
||||
* marks onboarding complete.
|
||||
*/
|
||||
export async function bootstrapRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingRuntime({
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-skipped onboarding path. The server creates or reuses one
|
||||
* install-runtime onboarding issue and marks onboarding complete.
|
||||
*/
|
||||
export async function bootstrapNoRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspace_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingNoRuntime({
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interest in cloud runtimes. Pure side effect — does NOT
|
||||
* complete onboarding; the user still has to pick a real Step 3
|
||||
|
||||
@@ -5,7 +5,6 @@ export type OnboardingStep =
|
||||
| "use_case"
|
||||
| "workspace"
|
||||
| "runtime"
|
||||
| "teammate"
|
||||
| "agent"
|
||||
| "first_issue";
|
||||
|
||||
@@ -59,20 +58,32 @@ export type UseCase =
|
||||
| "other";
|
||||
|
||||
/**
|
||||
* v2 questionnaire shape. `*_skipped: true` distinguishes an explicit
|
||||
* Skip click from a slot the user never reached. Both states are
|
||||
* "unknown" for recommendation purposes; the skip marker exists for
|
||||
* analytics and so future re-prompts can avoid nagging users who
|
||||
* already declined.
|
||||
* Questionnaire shape. `source` and `use_case` allow multiple values
|
||||
* (users hear about us through several channels and use Multica for
|
||||
* several things); `role` stays single-select since the agent template
|
||||
* recommendation wants a primary identity.
|
||||
*
|
||||
* `*_skipped: true` distinguishes an explicit Skip click from a slot
|
||||
* the user never reached. Both states are "unknown" for recommendation
|
||||
* purposes; the skip marker exists for analytics and so future
|
||||
* re-prompts can avoid nagging users who already declined.
|
||||
*
|
||||
* Backward compat: prior versions of this app wrote `source` and
|
||||
* `use_case` as a single string. `mergeQuestionnaire` in
|
||||
* `onboarding-flow.tsx` upgrades those rows to single-element arrays
|
||||
* on read; the server's `questionnaireAnswers.UnmarshalJSON` does the
|
||||
* same. `version` stays at 2 — the JSONB column is schema-less so a
|
||||
* mechanical bump would only show up in analytics, not in storage,
|
||||
* and we keep one funnel cohort.
|
||||
*/
|
||||
export interface QuestionnaireAnswers {
|
||||
source: Source | null;
|
||||
source: Source[];
|
||||
source_other: string | null;
|
||||
source_skipped: boolean;
|
||||
role: Role | null;
|
||||
role_other: string | null;
|
||||
role_skipped: boolean;
|
||||
use_case: UseCase | null;
|
||||
use_case: UseCase[];
|
||||
use_case_other: string | null;
|
||||
use_case_skipped: boolean;
|
||||
version: 2;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useWelcomeStore, type WelcomeSignal } from "./welcome-store";
|
||||
|
||||
const sample: WelcomeSignal = {
|
||||
workspaceId: "ws-1",
|
||||
choice: "runtime",
|
||||
runtimeId: "rt-1",
|
||||
};
|
||||
|
||||
describe("welcome-store", () => {
|
||||
beforeEach(() => {
|
||||
useWelcomeStore.getState().reset();
|
||||
});
|
||||
|
||||
it("starts empty and not dismissed", () => {
|
||||
expect(useWelcomeStore.getState().signal).toBeNull();
|
||||
expect(useWelcomeStore.getState().dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it("set() stores the signal and clears any prior dismissed flag", () => {
|
||||
useWelcomeStore.getState().dismiss();
|
||||
expect(useWelcomeStore.getState().dismissed).toBe(true);
|
||||
useWelcomeStore.getState().set(sample);
|
||||
expect(useWelcomeStore.getState().signal).toEqual(sample);
|
||||
// A fresh set() means a fresh Welcome session — dismissed resets so
|
||||
// the next mount renders the new signal.
|
||||
expect(useWelcomeStore.getState().dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it("dismiss() marks dismissed without clearing the signal", () => {
|
||||
useWelcomeStore.getState().set(sample);
|
||||
useWelcomeStore.getState().dismiss();
|
||||
expect(useWelcomeStore.getState().signal).toEqual(sample);
|
||||
expect(useWelcomeStore.getState().dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it("reset() clears signal AND dismissed (used on logout)", () => {
|
||||
useWelcomeStore.getState().set(sample);
|
||||
useWelcomeStore.getState().dismiss();
|
||||
useWelcomeStore.getState().reset();
|
||||
expect(useWelcomeStore.getState().signal).toBeNull();
|
||||
expect(useWelcomeStore.getState().dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it("skip-path signals omit runtimeId", () => {
|
||||
const skip: WelcomeSignal = { workspaceId: "ws-2", choice: "skip" };
|
||||
useWelcomeStore.getState().set(skip);
|
||||
expect(useWelcomeStore.getState().signal).toEqual(skip);
|
||||
expect(useWelcomeStore.getState().signal?.runtimeId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Welcome signal — written by Step 3 of the onboarding flow when the user
|
||||
* clicks "Start exploring" or "Skip", consumed exactly once by the
|
||||
* `<WelcomeAfterOnboarding />` component mounted inside the workspace
|
||||
* shell.
|
||||
*
|
||||
* The signal is the bridge between the onboarding screen and the workspace
|
||||
* shell that succeeds it. `onboarded_at` is already true by the time we
|
||||
* land in the workspace — this carries the *transient* "I just finished
|
||||
* Step 3, here is what I chose" intent without persisting it on the user
|
||||
* row (the v2 design persisted it as fields and leaked complexity
|
||||
* everywhere).
|
||||
*
|
||||
* `choice` is "runtime" or "skip":
|
||||
* - "runtime": user picked a runtime in Step 3. `runtimeId` is set. The
|
||||
* workspace welcome hook creates a Multica Helper agent on that runtime
|
||||
* and presents starter cards.
|
||||
* - "skip": user explicitly skipped. `runtimeId` is undefined. The hook
|
||||
* seeds two issues (install-runtime guide + create-agent guide) and
|
||||
* shows them in a Modal.
|
||||
*/
|
||||
export interface WelcomeSignal {
|
||||
workspaceId: string;
|
||||
choice: "runtime" | "skip";
|
||||
runtimeId?: string;
|
||||
}
|
||||
|
||||
interface WelcomeStoreState {
|
||||
signal: WelcomeSignal | null;
|
||||
/**
|
||||
* True after the user has explicitly engaged with the Welcome surface —
|
||||
* picked a starter card, opened a seeded issue, closed the Skip modal.
|
||||
* Components MUST treat `signal && !dismissed` as the render condition;
|
||||
* `dismiss()` is what removes the modal from view.
|
||||
*/
|
||||
dismissed: boolean;
|
||||
set: (signal: WelcomeSignal) => void;
|
||||
/**
|
||||
* Mark the Welcome surface as engaged-with. Idempotent. Keeps the
|
||||
* signal in the store as a record but components will stop rendering.
|
||||
* We don't fully clear the store on dismiss so a debug peek (or a
|
||||
* future analytics hook) can still see which path the user took.
|
||||
*/
|
||||
dismiss: () => void;
|
||||
/**
|
||||
* Full reset — clear signal AND dismissed. Used by tests and by the
|
||||
* user logout flow (so a different user logging into the same browser
|
||||
* doesn't inherit a stale signal).
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand store, deliberately NOT persisted. Reloading the page drops the
|
||||
* signal — that's the correct behavior: the user already finished
|
||||
* onboarding (onboarded_at is set), and Welcome was a one-shot UI. We
|
||||
* never want a refresh to re-trigger Helper agent creation.
|
||||
*
|
||||
* Why subscription model instead of read-once-consume: React 18+ StrictMode
|
||||
* dev mode mounts components twice (mount → unmount → mount). If the
|
||||
* component consumed the store in a `useState` initializer, the second
|
||||
* mount would see an already-empty store and render null — Welcome would
|
||||
* never appear in dev. Subscribing to a stable store + tracking dismissal
|
||||
* in the store sidesteps both mounts seeing the same state.
|
||||
*/
|
||||
export const useWelcomeStore = create<WelcomeStoreState>((set) => ({
|
||||
signal: null,
|
||||
dismissed: false,
|
||||
set: (signal) => set({ signal, dismissed: false }),
|
||||
dismiss: () => set({ dismissed: true }),
|
||||
reset: () => set({ signal: null, dismissed: false }),
|
||||
}));
|
||||
@@ -19,26 +19,28 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("!onboarded → /onboarding regardless of workspace count", () => {
|
||||
// Un-onboarded users are routed back to the onboarding flow. The
|
||||
// "un-onboarded but in workspace" state is now physically impossible
|
||||
// (backend invariant + migration 065 backfill), but the resolver still
|
||||
// does the right thing if it ever appears: send the user to onboarding
|
||||
// rather than dropping them into a workspace with `onboarded_at` null.
|
||||
it("!onboarded → /onboarding (even with a workspace)", () => {
|
||||
// V3 invariant: onboarded_at is the single source of truth for
|
||||
// workspace access. A user holding workspaces but flagged !onboarded
|
||||
// (rare mid-flow state: closed app between Step 2 and Step 3) gets
|
||||
// routed to /onboarding so they can finish; the layout hard gate
|
||||
// would redirect them anyway.
|
||||
const ws = [makeWs("acme")];
|
||||
expect(resolvePostAuthDestination(ws, false)).toBe(paths.onboarding());
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.onboarding(),
|
||||
);
|
||||
});
|
||||
|
||||
it("onboarded + has workspace → /<first.slug>/issues", () => {
|
||||
it("onboarded + workspace[0] → /<first.slug>/issues", () => {
|
||||
const ws = [makeWs("acme"), makeWs("beta")];
|
||||
expect(resolvePostAuthDestination(ws, true)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
});
|
||||
|
||||
it("onboarded + zero workspaces → /workspaces/new", () => {
|
||||
it("onboarded + no workspace → /workspaces/new", () => {
|
||||
// Already-onboarded user without any workspace — usually a returning
|
||||
// user whose last workspace got deleted or who left it. They skip
|
||||
// re-onboarding and go straight to workspace creation.
|
||||
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,20 +3,27 @@ import { useAuthStore } from "../auth";
|
||||
import { paths } from "./paths";
|
||||
|
||||
/**
|
||||
* Priority:
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
* Priority (onboarded-first):
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded + workspace[0] → /<first.slug>/issues
|
||||
* hasOnboarded + no workspace → /workspaces/new
|
||||
*
|
||||
* `onboarded_at` is the single source of truth for whether the user has
|
||||
* passed first-contact. Backend transactions (CreateWorkspace,
|
||||
* AcceptInvitation) atomically set this field whenever a user joins a
|
||||
* `member` row, so "has workspace but !onboarded" is now a
|
||||
* physically impossible state — see migration 065 for the existing-data
|
||||
* backfill that closed the door retroactively.
|
||||
* V3 invariant: `onboarded_at != null` is the single source of truth for
|
||||
* "may access /<slug>/*". The web workspace layout and the desktop App.tsx
|
||||
* overlay decision both gate on this — sending an un-onboarded user
|
||||
* straight to /issues would just be redirected back to /onboarding by
|
||||
* the layout gate, costing a navigation round-trip. Check onboarded
|
||||
* first.
|
||||
*
|
||||
* Callers that need invitation-aware routing (callback / login) handle the
|
||||
* "un-onboarded with pending invites" branch themselves before calling
|
||||
* In v3 "has workspace but !onboarded" is physically rare (a user can
|
||||
* only land in that state by closing the app between Step 2 and Step 3
|
||||
* — both questionnaire and runtime picker steps run after workspace
|
||||
* creation but before CompleteOnboarding). OnboardingFlow's Step 2
|
||||
* already recognizes existing workspaces and offers "Continue with
|
||||
* {name}", so the recovery is seamless.
|
||||
*
|
||||
* Callers that need invitation-aware routing (callback / login) handle
|
||||
* the "un-onboarded with pending invites" branch themselves before calling
|
||||
* this resolver — this resolver only deals with the post-invite-check
|
||||
* destination.
|
||||
*/
|
||||
|
||||
@@ -25,19 +25,40 @@
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Onboarding: step / phase entry — 400ms fade + 4px rise.
|
||||
* Matches the design prototype's .fade-in. Applied on mount so every new
|
||||
* step (and intra-step phase switch, via key=phase remount) plays once.
|
||||
* `both` fill-mode commits the `from` styles pre-animation to avoid a
|
||||
* single-frame flash at natural state before the animation grabs. */
|
||||
/* Onboarding: step / phase entry — 400ms fade.
|
||||
* Applied on mount so every new step (and intra-step phase switch, via
|
||||
* key=phase remount) plays once. `both` fill-mode commits the `from`
|
||||
* styles pre-animation to avoid a single-frame flash at natural state
|
||||
* before the animation grabs.
|
||||
*
|
||||
* Earlier iteration also included a 4px translateY rise. Removed because
|
||||
* the transform on h-full step roots was getting counted into the
|
||||
* parent's scrollable overflow (web onboarding page + desktop
|
||||
* WindowOverlay both wrap with overflow-y-auto), producing a brief
|
||||
* scrollbar flash on each step entry. Pure opacity has no such side
|
||||
* effect. */
|
||||
@keyframes onboarding-enter {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-onboarding-enter {
|
||||
animation: onboarding-enter 0.4s ease both;
|
||||
}
|
||||
|
||||
/* Welcome-after-onboarding Modal: emoji pops in with two quick scale
|
||||
* bounces so the celebration registers visually without being
|
||||
* disruptive. ~700ms total. */
|
||||
@keyframes welcome-emoji-pop {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
35% { transform: scale(1.25); opacity: 1; }
|
||||
55% { transform: scale(0.92); }
|
||||
75% { transform: scale(1.12); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.animate-welcome-emoji-pop {
|
||||
animation: welcome-emoji-pop 0.7s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Onboarding completion: success badge spring-pop.
|
||||
* Lands with a subtle overshoot (scale 1.12 → 1) so the circle feels
|
||||
* physical rather than linearly interpolated. Paired with the drawn
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
} from "./utils/preview";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
import { withFragmentNavShim } from "./utils/iframe-fragment-nav";
|
||||
import { HtmlPreviewBody } from "./html-preview-body";
|
||||
import { CodeBlockStatic } from "./code-block-static";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -400,16 +400,11 @@ function PreviewContent({
|
||||
attachmentId={state.attachmentId!}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<iframe
|
||||
srcDoc={withFragmentNavShim(text)}
|
||||
// `allow-scripts` without `allow-same-origin` — scripts run
|
||||
// in an opaque origin and cannot read cookies / localStorage
|
||||
// / parent state, nor escape via top-nav / popups / forms.
|
||||
// Required so JS-driven charts (echarts / Plotly / vanilla
|
||||
// SVG injection) render instead of showing a blank `<svg>`.
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full bg-background"
|
||||
<HtmlPreviewBody
|
||||
source={{ kind: "inline", html: text }}
|
||||
title={state.filename}
|
||||
className="h-full w-full"
|
||||
iframeClassName="rounded-none border-0"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ import { paths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
import { useT } from "../i18n";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
import { withFragmentNavShim } from "./utils/iframe-fragment-nav";
|
||||
import { HtmlPreviewBody } from "./html-preview-body";
|
||||
|
||||
const PREVIEW_HEIGHT = "h-[480px]";
|
||||
const ERROR_PLACEHOLDER_HEIGHT = "h-20";
|
||||
@@ -51,7 +51,11 @@ export function HtmlAttachmentPreview({
|
||||
onDownload,
|
||||
}: HtmlAttachmentPreviewProps) {
|
||||
const { t } = useT("editor");
|
||||
// Subscribe to the same React Query cache key the body consumes so the
|
||||
// toolbar can pin itself open during error. Re-subscribing is free — the
|
||||
// useQuery dedupe means no extra fetch.
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
const isError = !query.isLoading && (!!query.error || !query.data?.text);
|
||||
// useWorkspaceSlug — NOT useWorkspacePaths. The Paths-bound variant throws
|
||||
// when there's no slug; we want to render gracefully (just hide the
|
||||
// new-tab button) when the component is somehow mounted outside a
|
||||
@@ -59,10 +63,6 @@ export function HtmlAttachmentPreview({
|
||||
const slug = useWorkspaceSlug();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const text = query.data?.text;
|
||||
const isLoading = query.isLoading;
|
||||
const isError = !isLoading && (!!query.error || !text);
|
||||
|
||||
// Only enable the new-tab button when the workspace slug is resolvable —
|
||||
// outside a workspace context the path is meaningless. Prefer desktop's
|
||||
// tab system; on web fall back to window.open against the public shareable
|
||||
@@ -85,36 +85,13 @@ export function HtmlAttachmentPreview({
|
||||
className="group/html-preview relative my-1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-border bg-muted/30 text-xs text-muted-foreground",
|
||||
PREVIEW_HEIGHT,
|
||||
)}
|
||||
>
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border border-border bg-muted/30 px-3 text-xs text-muted-foreground",
|
||||
ERROR_PLACEHOLDER_HEIGHT,
|
||||
)}
|
||||
data-testid="html-attachment-preview-error"
|
||||
>
|
||||
<span className="truncate">{t(($) => $.attachment.preview_failed)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={withFragmentNavShim(text)}
|
||||
sandbox="allow-scripts"
|
||||
title={filename}
|
||||
className={cn(
|
||||
"block w-full rounded-md border border-border bg-background",
|
||||
PREVIEW_HEIGHT,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<HtmlPreviewBody
|
||||
source={{ kind: "attachment", attachmentId }}
|
||||
title={filename}
|
||||
className={PREVIEW_HEIGHT}
|
||||
placeholderClassName={isError ? ERROR_PLACEHOLDER_HEIGHT : PREVIEW_HEIGHT}
|
||||
errorTestId="html-attachment-preview-error"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-2 top-2 flex items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 shadow-sm transition-opacity",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
code_block: {
|
||||
copy_code: "Copy code",
|
||||
show_preview: "Show preview",
|
||||
show_source: "Show source",
|
||||
fullscreen: "Fullscreen",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// CodeBlockStatic depends on lowlight which has a heavy import surface and a
|
||||
// jsdom-incompatible code path. Stub to keep the source-view test focused on
|
||||
// the toggle wiring rather than highlighting.
|
||||
vi.mock("./code-block-static", () => ({
|
||||
CodeBlockStatic: ({ body }: { body: string }) => (
|
||||
<pre data-testid="code-block-static">{body}</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
import { HtmlBlockPreview } from "./html-block-preview";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("HtmlBlockPreview — preview / source toggle", () => {
|
||||
it("renders the iframe with sandbox and the fragment-nav shim in srcdoc", () => {
|
||||
render(<HtmlBlockPreview html="<p>hi</p>" />);
|
||||
// Two iframes exist after mount — the inline 480px one and the
|
||||
// (hidden) Dialog one. Both carry the same srcdoc.
|
||||
const frames = document.querySelectorAll("iframe");
|
||||
expect(frames.length).toBeGreaterThanOrEqual(1);
|
||||
const frame = frames[0]!;
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
const srcdoc = frame.getAttribute("srcdoc") ?? "";
|
||||
expect(srcdoc.startsWith("<p>hi</p>")).toBe(true);
|
||||
expect(srcdoc).toContain("scrollIntoView");
|
||||
});
|
||||
|
||||
it("switches to source view and back when the toggle is clicked", () => {
|
||||
render(<HtmlBlockPreview html="<p>hi</p>" />);
|
||||
// Preview mode: iframe present, code-block-static absent.
|
||||
expect(document.querySelector("iframe")).toBeTruthy();
|
||||
expect(screen.queryByTestId("code-block-static")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Show source"));
|
||||
expect(screen.getByTestId("code-block-static").textContent).toBe("<p>hi</p>");
|
||||
// The inline iframe is gone in source mode; the Dialog's iframe stays
|
||||
// unmounted because the Dialog is closed.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Show preview"));
|
||||
expect(document.querySelector("iframe")).toBeTruthy();
|
||||
expect(screen.queryByTestId("code-block-static")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HtmlBlockPreview — Maximize → Dialog", () => {
|
||||
it("does not render the Fullscreen button in source view (only when iframe is visible)", () => {
|
||||
render(<HtmlBlockPreview html="<p>hi</p>" />);
|
||||
expect(screen.getByTitle("Fullscreen")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTitle("Show source"));
|
||||
expect(screen.queryByTitle("Fullscreen")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the fullscreen Dialog with a second iframe carrying the same srcdoc", () => {
|
||||
render(<HtmlBlockPreview html="<p>hi</p>" />);
|
||||
// Before clicking Fullscreen, the Dialog has not mounted its content
|
||||
// (base-ui dialog renders Popup lazily).
|
||||
expect(document.querySelectorAll("iframe").length).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Fullscreen"));
|
||||
|
||||
const frames = document.querySelectorAll("iframe");
|
||||
expect(frames.length).toBe(2);
|
||||
// Both iframes wrap the same body via the fragment-nav shim.
|
||||
for (const f of frames) {
|
||||
const srcdoc = f.getAttribute("srcdoc") ?? "";
|
||||
expect(srcdoc.startsWith("<p>hi</p>")).toBe(true);
|
||||
expect(srcdoc).toContain("scrollIntoView");
|
||||
expect(f.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
* HtmlBlockPreview — readonly rendering of fenced ```html code blocks.
|
||||
*
|
||||
* Default view is "preview" (iframe) per the V2 plan; user can flip to
|
||||
* "source" to see the highlighted markup and Copy it.
|
||||
* "source" to see the highlighted markup and Copy it. Maximize opens the
|
||||
* same iframe in a full-screen Dialog.
|
||||
*
|
||||
* Mounted by ReadonlyContent's `code` renderer for `lang === "html"`. The
|
||||
* `pre` renderer in ReadonlyContent recognizes this component by reference
|
||||
@@ -16,11 +17,21 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Code as CodeIcon, Copy, Eye } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Code as CodeIcon,
|
||||
Copy,
|
||||
Eye,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { useT } from "../i18n";
|
||||
import { CodeBlockIframe } from "./code-block-iframe";
|
||||
import { CodeBlockStatic } from "./code-block-static";
|
||||
import { HtmlPreviewBody } from "./html-preview-body";
|
||||
|
||||
const CODE_BLOCK_IFRAME_HEIGHT = "h-[480px]";
|
||||
|
||||
@@ -37,6 +48,7 @@ export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
|
||||
const { t } = useT("editor");
|
||||
const [view, setView] = useState<"preview" | "source">("preview");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!html) return;
|
||||
@@ -80,6 +92,17 @@ export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{view === "preview" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFullscreen(true)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t(($) => $.code_block.fullscreen)}
|
||||
aria-label={t(($) => $.code_block.fullscreen)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
@@ -95,14 +118,27 @@ export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
|
||||
</button>
|
||||
</div>
|
||||
{view === "preview" ? (
|
||||
<CodeBlockIframe
|
||||
html={html}
|
||||
<HtmlPreviewBody
|
||||
source={{ kind: "inline", html }}
|
||||
title="HTML preview"
|
||||
heightClassName={CODE_BLOCK_IFRAME_HEIGHT}
|
||||
className={CODE_BLOCK_IFRAME_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<CodeBlockStatic language="xml" body={html} />
|
||||
)}
|
||||
<Dialog open={fullscreen} onOpenChange={setFullscreen}>
|
||||
<DialogContent
|
||||
className="!max-w-6xl !h-[min(90vh,calc(100vh-2rem))] w-full p-0 gap-0 overflow-hidden"
|
||||
aria-label={t(($) => $.code_block.fullscreen)}
|
||||
>
|
||||
<HtmlPreviewBody
|
||||
source={{ kind: "inline", html }}
|
||||
title="HTML preview"
|
||||
className="h-full w-full"
|
||||
iframeClassName="rounded-none border-0"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HtmlPreviewBody — single source of truth for rendering an HTML document
|
||||
* inside a sandboxed iframe, regardless of whether the source is an inline
|
||||
* string (```html fenced code block) or an attachment id (HTML attachment).
|
||||
*
|
||||
* The two paths used to duplicate iframe + sandbox + fragment-nav shim logic
|
||||
* across html-block-preview.tsx, html-attachment-preview.tsx, and
|
||||
* attachment-preview-modal.tsx. This component owns:
|
||||
*
|
||||
* - srcDoc + sandbox="allow-scripts" via CodeBlockIframe
|
||||
* - withFragmentNavShim() so anchor links inside the iframe scroll instead
|
||||
* of silently failing in the sandbox's opaque origin
|
||||
* - loading / typed-error placeholders for attachment sources
|
||||
*
|
||||
* Callers (the toolbar wrappers) keep their own chrome — Copy / Source
|
||||
* toggle for code blocks, Download / Open-in-new-tab for attachments — and
|
||||
* just slot HtmlPreviewBody as the body.
|
||||
*/
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "@multica/core/api";
|
||||
import { useT } from "../i18n";
|
||||
import { CodeBlockIframe } from "./code-block-iframe";
|
||||
import { withFragmentNavShim } from "./utils/iframe-fragment-nav";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
|
||||
export type HtmlSource =
|
||||
| { kind: "inline"; html: string }
|
||||
| { kind: "attachment"; attachmentId: string };
|
||||
|
||||
interface HtmlPreviewBodyProps {
|
||||
source: HtmlSource;
|
||||
/** iframe title attribute (a11y). */
|
||||
title: string;
|
||||
/** Tailwind height/sizing classes for the iframe (e.g. "h-[480px]" or "h-full"). */
|
||||
className?: string;
|
||||
/** Override iframe styling (border / radius). Tailwind-merge resolves
|
||||
* conflicts so callers can pass "rounded-none border-0" for full-screen. */
|
||||
iframeClassName?: string;
|
||||
/** Sizing for loading / error placeholders. Defaults to className. */
|
||||
placeholderClassName?: string;
|
||||
/** Optional data-testid on the error placeholder — preserved for
|
||||
* html-attachment-preview.test.tsx which asserts the testid is present. */
|
||||
errorTestId?: string;
|
||||
}
|
||||
|
||||
export function HtmlPreviewBody({
|
||||
source,
|
||||
title,
|
||||
className,
|
||||
iframeClassName,
|
||||
placeholderClassName,
|
||||
errorTestId,
|
||||
}: HtmlPreviewBodyProps) {
|
||||
if (source.kind === "inline") {
|
||||
return (
|
||||
<CodeBlockIframe
|
||||
html={withFragmentNavShim(source.html)}
|
||||
title={title}
|
||||
heightClassName={className}
|
||||
className={iframeClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AttachmentBody
|
||||
attachmentId={source.attachmentId}
|
||||
title={title}
|
||||
className={className}
|
||||
iframeClassName={iframeClassName}
|
||||
placeholderClassName={placeholderClassName ?? className}
|
||||
errorTestId={errorTestId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentBody({
|
||||
attachmentId,
|
||||
title,
|
||||
className,
|
||||
iframeClassName,
|
||||
placeholderClassName,
|
||||
errorTestId,
|
||||
}: {
|
||||
attachmentId: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
iframeClassName?: string;
|
||||
placeholderClassName?: string;
|
||||
errorTestId?: string;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-border bg-muted/30 text-xs text-muted-foreground",
|
||||
placeholderClassName,
|
||||
)}
|
||||
>
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.error || !query.data) {
|
||||
const message =
|
||||
query.error instanceof PreviewTooLargeError
|
||||
? t(($) => $.attachment.preview_too_large)
|
||||
: query.error instanceof PreviewUnsupportedError
|
||||
? t(($) => $.attachment.preview_unsupported)
|
||||
: t(($) => $.attachment.preview_failed);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border border-border bg-muted/30 px-3 text-xs text-muted-foreground",
|
||||
placeholderClassName,
|
||||
)}
|
||||
data-testid={errorTestId}
|
||||
>
|
||||
<span className="truncate">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockIframe
|
||||
html={withFragmentNavShim(query.data.text)}
|
||||
title={title}
|
||||
heightClassName={className}
|
||||
className={iframeClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,15 +22,22 @@ import { useNavigation } from "../navigation";
|
||||
* - Not logged in → /login
|
||||
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
|
||||
* - Logged in but URL slug doesn't resolve to any workspace →
|
||||
* `resolvePostAuthDestination(list, hasOnboarded)`:
|
||||
* • un-onboarded → /onboarding
|
||||
* • onboarded with workspaces → first workspace
|
||||
* • onboarded with zero workspaces → /workspaces/new
|
||||
* `resolvePostAuthDestination(list, hasOnboarded)` (workspace-presence first;
|
||||
* see paths/resolve.ts for the full table)
|
||||
*
|
||||
* The "un-onboarded but in workspace" state is now physically impossible:
|
||||
* CreateWorkspace and AcceptInvitation both atomically set `onboarded_at`
|
||||
* inside the same transaction that inserts the `member` row.
|
||||
* Existing dirty rows from PR #1868 are cleaned by migration 065.
|
||||
* The "un-onboarded but in workspace" state IS valid now — it's the
|
||||
* mid-flow window between "user picked a runtime on the onboarding screen
|
||||
* and got dropped into the workspace" and "user picked a starter prompt in
|
||||
* the workspace OnboardingHelperModal, which fires BootstrapOnboardingRuntime
|
||||
* and marks onboarded". This guard deliberately does NOT redirect that
|
||||
* state out: it only redirects when the URL slug doesn't resolve,
|
||||
* regardless of onboarded. The blocking modal inside the workspace shell
|
||||
* handles completion.
|
||||
*
|
||||
* (Older comment claimed this state was physically impossible because
|
||||
* CreateWorkspace and AcceptInvitation atomically marked onboarded.
|
||||
* CreateWorkspace no longer marks; AcceptInvitation still does — invitees
|
||||
* skip the modal entirely.)
|
||||
*
|
||||
* We read the workspace list query state directly (rather than relying on
|
||||
* useCurrentWorkspace's null return) so we can distinguish "list loading"
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"code_block": {
|
||||
"copy_code": "Copy code",
|
||||
"show_preview": "Show preview",
|
||||
"show_source": "Show source"
|
||||
"show_source": "Show source",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
"file_card": {
|
||||
"uploading": "Uploading {{filename}}"
|
||||
|
||||
@@ -179,19 +179,61 @@
|
||||
"add_more_hint": "Add more agents anytime. A small team of specialized agents beats one jack-of-all-trades.",
|
||||
"docs_link": "Creating your first agent →"
|
||||
},
|
||||
"step_teammate": {
|
||||
"eyebrow": "First teammate",
|
||||
"headline": "Create your first teammate: Multica Helper",
|
||||
"lede": "Multica Helper is a starter agent connected to {{runtime}}. It will open your first guided issue and help you learn the core workflow.",
|
||||
"avatar_label": "Multica Helper avatar",
|
||||
"name": "Multica Helper",
|
||||
"role": "Onboarding guide",
|
||||
"point_issue": "Starts from one onboarding issue",
|
||||
"point_context": "Uses your workspace context and issue details",
|
||||
"point_customize": "You can rename or replace it later",
|
||||
"footer_hint": "We'll create one agent and one onboarding issue.",
|
||||
"create_action": "Create Multica Helper",
|
||||
"create_failed": "Failed to create Multica Helper"
|
||||
"welcome_after_onboarding": {
|
||||
"loading_helper": "Preparing your Helper…",
|
||||
"error_title": "Couldn't finish setup",
|
||||
"error_generic": "Something went wrong. Try again.",
|
||||
"retry": "Try again",
|
||||
"dismiss_error": "Dismiss",
|
||||
"error_close": "Close",
|
||||
"user_context_heading": "About me",
|
||||
"user_context_role_label": "Role",
|
||||
"user_context_use_case_label": "What I want to do with Multica",
|
||||
"runtime": {
|
||||
"greeting": "Hi, welcome to Multica",
|
||||
"subtitle": "I'm your first Agent assistant.",
|
||||
"capabilities": "Ask me anything about how to use Multica. You can also have me get things done in your workspace — create issues, set up cron tasks, build new agents…",
|
||||
"section_label": "Pick one or more tasks to start with:",
|
||||
"assign_empty": "Pick one or more",
|
||||
"assign_count": "Assign {{count}} task to me →",
|
||||
"assign_count_plural": "Assign {{count}} tasks to me →",
|
||||
"cards": {
|
||||
"intro": {
|
||||
"subtitle": "Let me explain in 1–2 paragraphs what Multica is and what it can do."
|
||||
},
|
||||
"tour": {
|
||||
"subtitle": "I'll walk you through how issue, agent, squad, autopilot fit together."
|
||||
},
|
||||
"welcome_page": {
|
||||
"subtitle": "I'll write you a small HTML welcome page to celebrate the kickoff."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"title": "All set!",
|
||||
"subtitle": "Sit tight — your {{agentName}} is on it ☕",
|
||||
"tip_inbox": "When there's progress, you'll see it in your Inbox.",
|
||||
"tip_chat": "Want to chat live? Click the chat bubble at the bottom-right.",
|
||||
"got_it": "Got it →"
|
||||
}
|
||||
},
|
||||
"skip": {
|
||||
"loading": "Setting up your workspace…",
|
||||
"title": "Welcome to Multica!",
|
||||
"subtitle": "We've added two tasks to help you with the next step.",
|
||||
"got_it": "Got it",
|
||||
"status_in_progress": "In progress",
|
||||
"status_todo": "Todo",
|
||||
"cards": {
|
||||
"install_runtime": {
|
||||
"title": "Connect a runtime",
|
||||
"subtitle": "Install Codex or Kimi to bring agents online."
|
||||
},
|
||||
"create_agent": {
|
||||
"title": "Create your first agent",
|
||||
"subtitle": "Once a runtime is online, build your first Multica agent."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"step_workspace": {
|
||||
"eyebrow_first": "Your first workspace",
|
||||
@@ -284,7 +326,8 @@
|
||||
"hint_selected": "Selected: {{name}}",
|
||||
"hint_pick": "Pick an agent runtime above to continue.",
|
||||
"hint_waiting": "Waiting for the first result…",
|
||||
"hint_skip_or_refresh": "Refresh after installing one, or skip to enter your workspace."
|
||||
"hint_skip_or_refresh": "Refresh after installing one, or skip to enter your workspace.",
|
||||
"start_exploring": "Start exploring"
|
||||
},
|
||||
"step_platform": {
|
||||
"eyebrow": "Connect a computer",
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"code_block": {
|
||||
"copy_code": "复制代码",
|
||||
"show_preview": "显示预览",
|
||||
"show_source": "显示源码"
|
||||
"show_source": "显示源码",
|
||||
"fullscreen": "放大"
|
||||
},
|
||||
"file_card": {
|
||||
"uploading": "正在上传 {{filename}}"
|
||||
|
||||
@@ -179,19 +179,61 @@
|
||||
"add_more_hint": "随时添加更多智能体。一支专长各异的小团队胜过一个万能选手。",
|
||||
"docs_link": "创建第一个智能体 →"
|
||||
},
|
||||
"step_teammate": {
|
||||
"eyebrow": "第一个队友",
|
||||
"headline": "创建你的第一个队友:Multica Helper",
|
||||
"lede": "Multica Helper 是连接到 {{runtime}} 的入门智能体。它会打开一个上手引导 issue,带你走完核心流程。",
|
||||
"avatar_label": "Multica Helper 头像",
|
||||
"name": "Multica Helper",
|
||||
"role": "上手引导",
|
||||
"point_issue": "只从一个上手引导 issue 开始",
|
||||
"point_context": "读取工作区上下文和 issue 详情",
|
||||
"point_customize": "之后可以改名或替换",
|
||||
"footer_hint": "我们会创建一个智能体和一个上手引导 issue。",
|
||||
"create_action": "创建 Multica Helper",
|
||||
"create_failed": "创建 Multica Helper 失败"
|
||||
"welcome_after_onboarding": {
|
||||
"loading_helper": "正在为你准备 Helper……",
|
||||
"error_title": "准备失败",
|
||||
"error_generic": "出了点问题,请重试。",
|
||||
"retry": "重试",
|
||||
"dismiss_error": "知道了",
|
||||
"error_close": "关闭",
|
||||
"user_context_heading": "关于我",
|
||||
"user_context_role_label": "角色",
|
||||
"user_context_use_case_label": "想用 Multica 做",
|
||||
"runtime": {
|
||||
"greeting": "Hi,欢迎来到 Multica",
|
||||
"subtitle": "我是你的第一个 Agent 助手。",
|
||||
"capabilities": "你可以问我任何关于 Multica 的使用,也可以让我帮你在工作区完成任务 —— 建 issue、定时任务、再建一个 agent……",
|
||||
"section_label": "选一个或多个任务,让我开始干活:",
|
||||
"assign_empty": "选一个或多个",
|
||||
"assign_count": "分配 {{count}} 个任务给我 →",
|
||||
"assign_count_plural": "分配 {{count}} 个任务给我 →",
|
||||
"cards": {
|
||||
"intro": {
|
||||
"subtitle": "让我用 1-2 段话讲清楚 Multica 是什么、能干嘛。"
|
||||
},
|
||||
"tour": {
|
||||
"subtitle": "陪你走一遍 issue、agent、squad、autopilot 怎么配合。"
|
||||
},
|
||||
"welcome_page": {
|
||||
"subtitle": "给你写一个小 HTML 页面,庆祝上手 Multica。"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"title": "都搞定了!",
|
||||
"subtitle": "稍作等待,你的 {{agentName}} 正在为你工作 ☕",
|
||||
"tip_inbox": "它有进展时,你能在「收件箱」收到通知。",
|
||||
"tip_chat": "想直接聊聊?点右下角的对话气泡就行。",
|
||||
"got_it": "知道了 →"
|
||||
}
|
||||
},
|
||||
"skip": {
|
||||
"loading": "正在为你准备工作区……",
|
||||
"title": "欢迎来到 Multica!",
|
||||
"subtitle": "我们为你创建了两个任务,帮助你进行下一步操作。",
|
||||
"got_it": "知道了",
|
||||
"status_in_progress": "进行中",
|
||||
"status_todo": "待办",
|
||||
"cards": {
|
||||
"install_runtime": {
|
||||
"title": "连接一个运行时",
|
||||
"subtitle": "安装 Codex 或 Kimi 让 agent 上线。"
|
||||
},
|
||||
"create_agent": {
|
||||
"title": "创建你的第一个 agent",
|
||||
"subtitle": "运行时上线后,把第一个 agent 建出来。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"step_workspace": {
|
||||
"eyebrow_first": "你的第一个工作区",
|
||||
@@ -283,7 +325,8 @@
|
||||
"hint_selected": "已选择:{{name}}",
|
||||
"hint_pick": "在上方选一个 agent 运行时继续。",
|
||||
"hint_waiting": "等待第一个结果...",
|
||||
"hint_skip_or_refresh": "装好后点刷新,或跳过进入工作区。"
|
||||
"hint_skip_or_refresh": "装好后点刷新,或跳过进入工作区。",
|
||||
"start_exploring": "开始探索"
|
||||
},
|
||||
"step_platform": {
|
||||
"eyebrow": "连接一台电脑",
|
||||
|
||||
@@ -12,22 +12,30 @@ const OTHER_INPUT_MAX_LENGTH = 80;
|
||||
* explicit Continue button gates the transition so users can change
|
||||
* their mind before committing). The `Other` variant swaps its
|
||||
* label area for a free-text input when selected.
|
||||
*
|
||||
* `mode` controls ARIA role: `"radio"` for single-select questions
|
||||
* (role), `"checkbox"` for multi-select (source, use case). Visual
|
||||
* style is identical — the border/shadow treatment already conveys
|
||||
* "selected"; multi-select cards just additionally don't deselect
|
||||
* other cards when clicked, which is the parent's responsibility.
|
||||
*/
|
||||
export function IconOptionCard({
|
||||
icon,
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
mode = "radio",
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
mode?: "radio" | "checkbox";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
role={mode}
|
||||
aria-checked={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
@@ -65,6 +73,7 @@ export function IconOtherOptionCard({
|
||||
onOtherChange,
|
||||
onConfirm,
|
||||
placeholder,
|
||||
mode = "radio",
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
@@ -74,10 +83,11 @@ export function IconOtherOptionCard({
|
||||
onOtherChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
placeholder: string;
|
||||
mode?: "radio" | "checkbox";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
role={mode}
|
||||
aria-checked={selected}
|
||||
onClick={() => {
|
||||
if (!selected) onSelect();
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { captureEvent } from "@multica/core/analytics";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
completeOnboarding,
|
||||
ONBOARDING_STEP_ORDER,
|
||||
saveQuestionnaire,
|
||||
useWelcomeStore,
|
||||
type OnboardingStep,
|
||||
type QuestionnaireAnswers,
|
||||
} from "@multica/core/onboarding";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import type { AgentRuntime, Workspace } from "@multica/core/types";
|
||||
import { StepWelcome } from "./steps/step-welcome";
|
||||
import { StepSource } from "./steps/step-source";
|
||||
@@ -25,22 +23,38 @@ import { StepUseCase } from "./steps/step-use-case";
|
||||
import { StepWorkspace } from "./steps/step-workspace";
|
||||
import { StepRuntimeConnect } from "./steps/step-runtime-connect";
|
||||
import { StepPlatformFork } from "./steps/step-platform-fork";
|
||||
import { StepTeammate } from "./steps/step-teammate";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
|
||||
source: null,
|
||||
source: [],
|
||||
source_other: null,
|
||||
source_skipped: false,
|
||||
role: null,
|
||||
role_other: null,
|
||||
role_skipped: false,
|
||||
use_case: null,
|
||||
use_case: [],
|
||||
use_case_other: null,
|
||||
use_case_skipped: false,
|
||||
version: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Coerce a stored questionnaire slot into the array shape used by the
|
||||
* current UI. Earlier versions of this app wrote `source` / `use_case`
|
||||
* as a single string; tolerate that on read so a user who started
|
||||
* onboarding before this change doesn't see their previous answer
|
||||
* disappear on re-entry. Empty string and null both collapse to [].
|
||||
*/
|
||||
function coerceToArray<T extends string>(value: unknown): T[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((v): v is T => typeof v === "string" && v.length > 0);
|
||||
}
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return [value as T];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge persisted answers into the empty default. Re-entry pre-fills
|
||||
* answered slots but treats `*_skipped` as fresh (the user can answer
|
||||
@@ -50,22 +64,44 @@ const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
|
||||
function mergeQuestionnaire(
|
||||
raw: Record<string, unknown>,
|
||||
): QuestionnaireAnswers {
|
||||
const merged = { ...EMPTY_QUESTIONNAIRE, ...(raw as Partial<QuestionnaireAnswers>) };
|
||||
const merged = {
|
||||
...EMPTY_QUESTIONNAIRE,
|
||||
...(raw as Partial<QuestionnaireAnswers>),
|
||||
};
|
||||
return {
|
||||
...merged,
|
||||
source: coerceToArray<QuestionnaireAnswers["source"][number]>(raw.source),
|
||||
use_case: coerceToArray<QuestionnaireAnswers["use_case"][number]>(
|
||||
raw.use_case,
|
||||
),
|
||||
source_skipped: false,
|
||||
role_skipped: false,
|
||||
use_case_skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Shell's onComplete contract:
|
||||
* onComplete(workspace?, issueId?) — if an issue id is present, navigate
|
||||
* straight into that onboarding issue; otherwise navigate into the
|
||||
* workspace issues list. Runtime-connected onboarding creates one
|
||||
* Multica Helper agent plus one issue; runtime-skipped onboarding creates one
|
||||
* self-serve install-runtime issue.
|
||||
* workspace issues list.
|
||||
*
|
||||
* Three exit shapes feed onComplete:
|
||||
* - Skip-existing (Welcome): completeOnboarding marks onboarded; navigate
|
||||
* to the existing workspace's issue list.
|
||||
* - Runtime-skipped (no runtime on Step 3): completeOnboarding marks
|
||||
* onboarded; we push a {choice:"skip"} welcome signal and navigate
|
||||
* to the workspace. The welcome hook in the workspace shell creates
|
||||
* the install-runtime / create-agent guide issues on landing.
|
||||
* - Runtime-connected (runtime picked on Step 3): completeOnboarding
|
||||
* marks onboarded; we push a {choice:"runtime", runtimeId} welcome
|
||||
* signal and navigate. The welcome hook creates the Multica Helper
|
||||
* agent on the picked runtime and shows the starter-card Modal.
|
||||
*
|
||||
* V3 contract: this file never touches createAgent / createIssue. The
|
||||
* "what runs in the workspace shell after onboarding" decision is in
|
||||
* `packages/views/workspace/welcome-after-onboarding.tsx`.
|
||||
*/
|
||||
export function OnboardingFlow({
|
||||
onComplete,
|
||||
@@ -93,11 +129,8 @@ export function OnboardingFlow({
|
||||
const storedQuestionnaire = mergeQuestionnaire(user.onboarding_questionnaire);
|
||||
const [answers, setAnswers] = useState<QuestionnaireAnswers>(storedQuestionnaire);
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [step, setStep] = useState<OnboardingStep>("welcome");
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
const [runtime, setRuntime] = useState<AgentRuntime | null>(null);
|
||||
|
||||
// Fetched at Step 0 + Step 2. Step 2 uses it to detect a pre-existing
|
||||
// workspace from an earlier abandoned onboarding (so StepWorkspace shows
|
||||
@@ -200,48 +233,35 @@ export function OnboardingFlow({
|
||||
const handleRuntimeNext = useCallback(
|
||||
async (rt: AgentRuntime | null) => {
|
||||
if (!workspace) return;
|
||||
if (!rt) {
|
||||
// No runtime -> no agent execution yet. Create one focused
|
||||
// install-runtime onboarding issue so the user lands on a
|
||||
// concrete next step.
|
||||
try {
|
||||
const result = await bootstrapNoRuntimeOnboarding(workspace.id);
|
||||
await qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });
|
||||
onComplete(workspace, result.issue_id || undefined);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
|
||||
);
|
||||
}
|
||||
// Step 3 in v3 does exactly two things:
|
||||
// 1. Mark onboarded server-side (the workspace layout hard gate
|
||||
// will redirect us back to /onboarding without this).
|
||||
// 2. Park a transient welcome signal for the workspace shell to
|
||||
// consume on the next render, telling it what the user chose.
|
||||
// Helper-agent creation and starter-issue creation happen in the
|
||||
// workspace shell's welcome hook, AFTER navigation, via the generic
|
||||
// createAgent / createIssue endpoints.
|
||||
try {
|
||||
await completeOnboarding(
|
||||
rt ? "full" : "runtime_skipped",
|
||||
workspace.id,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntime(rt);
|
||||
advanceFrom("runtime");
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: workspace.id,
|
||||
choice: rt ? "runtime" : "skip",
|
||||
...(rt ? { runtimeId: rt.id } : {}),
|
||||
});
|
||||
onComplete(workspace, undefined);
|
||||
},
|
||||
[workspace, qc, onComplete, t, advanceFrom],
|
||||
[workspace, onComplete, t],
|
||||
);
|
||||
|
||||
const handleCreateTeammate = useCallback(async () => {
|
||||
if (!workspace || !runtime) return;
|
||||
|
||||
try {
|
||||
const result = await bootstrapRuntimeOnboarding(workspace.id, runtime.id);
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }),
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }),
|
||||
]);
|
||||
onComplete(workspace, result.issue_id || undefined);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.step_teammate.create_failed),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, [workspace, runtime, qc, onComplete, t]);
|
||||
|
||||
const handleBack = useCallback((from: OnboardingStep) => {
|
||||
const idx = ONBOARDING_STEP_ORDER.indexOf(from);
|
||||
if (idx <= 0) {
|
||||
@@ -339,16 +359,6 @@ export function OnboardingFlow({
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "teammate" && workspace && runtime) {
|
||||
return (
|
||||
<StepTeammate
|
||||
runtime={runtime}
|
||||
onCreate={handleCreateTeammate}
|
||||
onBack={() => handleBack("teammate")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,9 +143,9 @@ describe("StepPlatformFork", () => {
|
||||
expect(
|
||||
within(dialog).getByText(/waiting for your computer/i),
|
||||
).toBeInTheDocument();
|
||||
// Connect & continue stays disabled while no runtime is selected.
|
||||
// Start exploring stays disabled while no runtime is selected.
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: /connect & continue/i }),
|
||||
within(dialog).getByRole("button", { name: /start exploring/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("StepPlatformFork", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
const connect = within(dialog).getByRole("button", {
|
||||
name: /connect & continue/i,
|
||||
name: /start exploring/i,
|
||||
});
|
||||
expect(connect).toBeEnabled();
|
||||
await user.click(connect);
|
||||
|
||||
@@ -418,7 +418,7 @@ function CliInstallDialog({
|
||||
{t(($) => $.common.cancel)}
|
||||
</Button>
|
||||
<Button disabled={!canConnect} onClick={onConnect}>
|
||||
{t(($) => $.step_platform.cli_dialog_connect)}
|
||||
{t(($) => $.step_runtime.start_exploring)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ function renderShell(overrides: Partial<React.ComponentProps<typeof StepQuestion
|
||||
number={1}
|
||||
question="Test question"
|
||||
options={OPTIONS}
|
||||
selectedSlug={null}
|
||||
selectedSlugs={[]}
|
||||
otherValue=""
|
||||
onOtherChange={onOtherChange}
|
||||
otherPlaceholder="type here"
|
||||
@@ -61,7 +61,7 @@ describe("StepQuestion", () => {
|
||||
|
||||
it("with a non-Other slug selected, Continue is enabled and fires onAdvance", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAdvance } = renderShell({ selectedSlug: "a" });
|
||||
const { onAdvance } = renderShell({ selectedSlugs: ["a"] });
|
||||
const continueBtn = screen.getByRole("button", { name: /continue/i });
|
||||
expect(continueBtn).toBeEnabled();
|
||||
await user.click(continueBtn);
|
||||
@@ -80,7 +80,7 @@ describe("StepQuestion", () => {
|
||||
it("Other selected with non-blank otherValue → Continue is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAdvance } = renderShell({
|
||||
selectedSlug: "other",
|
||||
selectedSlugs: ["other"],
|
||||
otherValue: "hello",
|
||||
});
|
||||
const continueBtn = screen.getByRole("button", { name: /continue/i });
|
||||
@@ -90,7 +90,7 @@ describe("StepQuestion", () => {
|
||||
});
|
||||
|
||||
it("Other selected with whitespace-only otherValue → Continue is disabled", () => {
|
||||
renderShell({ selectedSlug: "other", otherValue: " " });
|
||||
renderShell({ selectedSlugs: ["other"], otherValue: " " });
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("StepQuestion", () => {
|
||||
number={1}
|
||||
question="Test"
|
||||
options={OPTIONS}
|
||||
selectedSlug={null}
|
||||
selectedSlugs={[]}
|
||||
otherValue=""
|
||||
onOtherChange={vi.fn()}
|
||||
otherPlaceholder="type"
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface QuestionOption {
|
||||
* not in a sticky bottom footer — the form usually only fills the top
|
||||
* third of the viewport, and a footer pinned to the page bottom left a
|
||||
* large dead zone between the options and the action buttons.
|
||||
*
|
||||
* Supports both single-select (`multiSelect=false`, the default — Role)
|
||||
* and multi-select (`multiSelect=true` — Source, Use case). The
|
||||
* contract is always an array of selected slugs and an `onAnswer(slug)`
|
||||
* callback fired when a card is clicked; the parent decides whether to
|
||||
* replace (single) or toggle (multi) the selection in its own state.
|
||||
*/
|
||||
export function StepQuestion({
|
||||
step,
|
||||
@@ -40,7 +46,7 @@ export function StepQuestion({
|
||||
eyebrow,
|
||||
question,
|
||||
options,
|
||||
selectedSlug,
|
||||
selectedSlugs,
|
||||
otherValue,
|
||||
onOtherChange,
|
||||
otherPlaceholder,
|
||||
@@ -48,22 +54,25 @@ export function StepQuestion({
|
||||
onAdvance,
|
||||
onSkip,
|
||||
onBack,
|
||||
multiSelect = false,
|
||||
}: {
|
||||
step: OnboardingStep;
|
||||
number: number;
|
||||
eyebrow?: string;
|
||||
question: string;
|
||||
options: readonly QuestionOption[];
|
||||
selectedSlug: string | null;
|
||||
selectedSlugs: readonly string[];
|
||||
otherValue: string;
|
||||
onOtherChange: (value: string) => void;
|
||||
otherPlaceholder: string;
|
||||
/** Record the selection in the parent — does NOT advance. */
|
||||
/** Record the click in the parent — does NOT advance. Parent
|
||||
* decides replace vs. toggle based on its own multi/single mode. */
|
||||
onAnswer: (slug: string) => void;
|
||||
/** Commit the current selection and move to the next step. */
|
||||
onAdvance: () => void;
|
||||
onSkip: () => void;
|
||||
onBack?: () => void;
|
||||
multiSelect?: boolean;
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
const [pendingOther, setPendingOther] = useState(false);
|
||||
@@ -81,22 +90,43 @@ export function StepQuestion({
|
||||
onAnswer(option.slug);
|
||||
};
|
||||
|
||||
const selectedOption = options.find((o) => o.slug === selectedSlug) ?? null;
|
||||
const otherActive = selectedOption?.isOther || pendingOther;
|
||||
const otherOption = options.find((o) => o.isOther) ?? null;
|
||||
const otherSelected = otherOption
|
||||
? selectedSlugs.includes(otherOption.slug)
|
||||
: false;
|
||||
const otherActive = otherSelected || pendingOther;
|
||||
const otherFilled = (otherValue ?? "").trim().length > 0;
|
||||
// Continue is enabled when:
|
||||
// - a non-Other option is selected, OR
|
||||
// - at least one non-Other option is selected, OR
|
||||
// - Other is selected AND the free-text input has content.
|
||||
const canContinue = selectedSlug !== null && (!otherActive || otherFilled);
|
||||
// In multi-select Other can stack with regular picks; we treat the
|
||||
// non-Other selections as sufficient to advance even if Other's
|
||||
// text is empty.
|
||||
const hasNonOtherSelection = selectedSlugs.some(
|
||||
(slug) => slug !== otherOption?.slug,
|
||||
);
|
||||
const canContinue =
|
||||
selectedSlugs.length > 0 &&
|
||||
(hasNonOtherSelection || !otherActive || otherFilled);
|
||||
|
||||
const confirmAdvance = () => {
|
||||
if (canContinue) onAdvance();
|
||||
};
|
||||
|
||||
const selectedLabel = selectedOption?.label ?? null;
|
||||
// Footer label:
|
||||
// - single select: name the picked option ("hint_selected").
|
||||
// - multi-select with 1 pick: same.
|
||||
// - multi-select with >1 pick: show count via the existing
|
||||
// "hint_continue" key (locale-friendly, doesn't grow a new key
|
||||
// just for "N selected").
|
||||
const singlePicked =
|
||||
selectedSlugs.length === 1
|
||||
? options.find((o) => o.slug === selectedSlugs[0]) ?? null
|
||||
: null;
|
||||
const singlePickedLabel = singlePicked?.label ?? null;
|
||||
const footerHint = canContinue
|
||||
? selectedLabel
|
||||
? t(($) => $.step_runtime.hint_selected, { name: selectedLabel })
|
||||
? singlePickedLabel
|
||||
? t(($) => $.step_runtime.hint_selected, { name: singlePickedLabel })
|
||||
: t(($) => $.step_question.hint_continue)
|
||||
: t(($) => $.step_question.hint_pick);
|
||||
|
||||
@@ -140,7 +170,7 @@ export function StepQuestion({
|
||||
</h1>
|
||||
|
||||
<fieldset
|
||||
role="radiogroup"
|
||||
role={multiSelect ? "group" : "radiogroup"}
|
||||
aria-label={question}
|
||||
className="mt-10 m-0 grid grid-cols-1 gap-3 p-0 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
@@ -156,14 +186,16 @@ export function StepQuestion({
|
||||
onOtherChange={onOtherChange}
|
||||
onConfirm={confirmAdvance}
|
||||
placeholder={otherPlaceholder}
|
||||
mode={multiSelect ? "checkbox" : "radio"}
|
||||
/>
|
||||
) : (
|
||||
<IconOptionCard
|
||||
key={option.slug}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
selected={selectedSlug === option.slug && !otherActive}
|
||||
selected={selectedSlugs.includes(option.slug)}
|
||||
onSelect={() => handleSelect(option)}
|
||||
mode={multiSelect ? "checkbox" : "radio"}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -10,13 +10,13 @@ import { StepRole } from "./step-role";
|
||||
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
|
||||
|
||||
const EMPTY: QuestionnaireAnswers = {
|
||||
source: null,
|
||||
source: [],
|
||||
source_other: null,
|
||||
source_skipped: false,
|
||||
role: null,
|
||||
role_other: null,
|
||||
role_skipped: false,
|
||||
use_case: null,
|
||||
use_case: [],
|
||||
use_case_other: null,
|
||||
use_case_skipped: false,
|
||||
version: 2,
|
||||
|
||||
@@ -48,6 +48,13 @@ export function StepRole({
|
||||
{ slug: "other", icon: <MoreHorizontal className="h-4 w-4" />, label: t(($) => $.questions.role.other), isOther: true },
|
||||
];
|
||||
|
||||
// Role stays single-select — the agent template recommender treats
|
||||
// role as the primary identity signal; allowing several would force
|
||||
// the recommender to pick a tiebreaker the user never expressed.
|
||||
const selectedSlug =
|
||||
answers.role ?? (answers.role_other ? "other" : null);
|
||||
const selected: readonly string[] = selectedSlug ? [selectedSlug] : [];
|
||||
|
||||
return (
|
||||
<StepQuestion
|
||||
step="role"
|
||||
@@ -55,7 +62,7 @@ export function StepRole({
|
||||
eyebrow={t(($) => $.questions.eyebrow_about_you)}
|
||||
question={t(($) => $.questions.role.question)}
|
||||
options={options}
|
||||
selectedSlug={answers.role ?? (answers.role_other ? "other" : null)}
|
||||
selectedSlugs={selected}
|
||||
otherValue={answers.role_other ?? ""}
|
||||
onOtherChange={(v) => onChange({ role_other: v })}
|
||||
otherPlaceholder={t(($) => $.questions.role.other_placeholder)}
|
||||
|
||||
@@ -307,7 +307,7 @@ function FancyView({
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t(($) => $.common.continue)}
|
||||
{t(($) => $.step_runtime.start_exploring)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,13 @@ import { StepSource } from "./step-source";
|
||||
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
|
||||
|
||||
const EMPTY: QuestionnaireAnswers = {
|
||||
source: null,
|
||||
source: [],
|
||||
source_other: null,
|
||||
source_skipped: false,
|
||||
role: null,
|
||||
role_other: null,
|
||||
role_skipped: false,
|
||||
use_case: null,
|
||||
use_case: [],
|
||||
use_case_other: null,
|
||||
use_case_skipped: false,
|
||||
version: 2,
|
||||
@@ -41,52 +41,80 @@ function renderStep(answers: QuestionnaireAnswers = EMPTY) {
|
||||
return { onChange, onAdvance, onSkip, onBack };
|
||||
}
|
||||
|
||||
describe("StepSource", () => {
|
||||
describe("StepSource (multi-select)", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("selecting a non-Other option patches the slug and clears Other/skip", async () => {
|
||||
it("clicking a non-Other option appends the slug to the array", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange, onAdvance } = renderStep();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /linkedin/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /linkedin/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
source: "social_linkedin",
|
||||
source_other: null,
|
||||
source: ["social_linkedin"],
|
||||
source_skipped: false,
|
||||
});
|
||||
// A click only records — it must NOT auto-advance.
|
||||
expect(onAdvance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Skip clears the slug + other and marks the step skipped, then calls onSkip", async () => {
|
||||
it("clicking an already-selected option removes it (toggle)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderStep({
|
||||
...EMPTY,
|
||||
source: ["social_linkedin"],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /linkedin/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
source: [],
|
||||
source_skipped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("multi-select stacks several picks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderStep({
|
||||
...EMPTY,
|
||||
source: ["social_linkedin"],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /twitter/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
source: ["social_linkedin", "social_x"],
|
||||
source_skipped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("Skip clears the array + other and marks the step skipped, then calls onSkip", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange, onSkip } = renderStep();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skip/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
source: null,
|
||||
source: [],
|
||||
source_other: null,
|
||||
source_skipped: true,
|
||||
});
|
||||
expect(onSkip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Other: clicking marks source='other' and the free-text input writes to source_other", async () => {
|
||||
it("Other: clicking adds 'other' to the array; free-text writes to source_other", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderStep();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /^other$/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /^other$/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
source: "other",
|
||||
source: ["other"],
|
||||
source_skipped: false,
|
||||
});
|
||||
|
||||
const input = await screen.findByPlaceholderText(/podcast/i);
|
||||
await user.type(input, "x");
|
||||
// The last onChange call from typing routes through onOtherChange:
|
||||
expect(onChange).toHaveBeenLastCalledWith({ source_other: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,43 @@ export function StepSource({
|
||||
{ slug: "other", icon: <MoreHorizontal className="h-4 w-4" />, label: t(($) => $.questions.source.other), isOther: true },
|
||||
];
|
||||
|
||||
// Multi-select: source already had several "yes I heard via X" intents
|
||||
// collapsed into a single radio pick. Letting users tick multiple
|
||||
// channels keeps attribution honest without making them rank them.
|
||||
// `other` is a Source enum value so it stacks into the array alongside
|
||||
// regular picks; the free-text input lives in `source_other`.
|
||||
const selected: readonly string[] = [
|
||||
...(answers.source ?? []),
|
||||
...(!answers.source?.includes("other") && answers.source_other
|
||||
? ["other"]
|
||||
: []),
|
||||
];
|
||||
|
||||
const toggle = (slug: string) => {
|
||||
const current = answers.source ?? [];
|
||||
if (slug === "other") {
|
||||
// Toggling Other: add/remove from the array; clear the text when
|
||||
// removing so a re-tick starts from an empty input.
|
||||
if (current.includes("other")) {
|
||||
onChange({
|
||||
source: current.filter((s) => s !== "other"),
|
||||
source_other: null,
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
source: [...current, "other"],
|
||||
source_skipped: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const typed = slug as Source;
|
||||
const next = current.includes(typed)
|
||||
? current.filter((s) => s !== typed)
|
||||
: [...current, typed];
|
||||
onChange({ source: next, source_skipped: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<StepQuestion
|
||||
step="source"
|
||||
@@ -61,27 +98,18 @@ export function StepSource({
|
||||
eyebrow={t(($) => $.questions.eyebrow_about_you)}
|
||||
question={t(($) => $.questions.source.question)}
|
||||
options={options}
|
||||
selectedSlug={answers.source ?? (answers.source_other ? "other" : null)}
|
||||
selectedSlugs={selected}
|
||||
otherValue={answers.source_other ?? ""}
|
||||
onOtherChange={(v) => onChange({ source_other: v })}
|
||||
otherPlaceholder={t(($) => $.questions.source.other_placeholder)}
|
||||
onAnswer={(slug) => {
|
||||
if (slug === "other") {
|
||||
onChange({ source: "other", source_skipped: false });
|
||||
} else {
|
||||
onChange({
|
||||
source: slug as Source,
|
||||
source_other: null,
|
||||
source_skipped: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onAnswer={toggle}
|
||||
onAdvance={onAdvance}
|
||||
onSkip={() => {
|
||||
onChange({ source: null, source_other: null, source_skipped: true });
|
||||
onChange({ source: [], source_other: null, source_skipped: true });
|
||||
onSkip();
|
||||
}}
|
||||
onBack={onBack}
|
||||
multiSelect
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ComponentType } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
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 type { AgentRuntime } from "@multica/core/types";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { StepHeader } from "../components/step-header";
|
||||
import { RuntimeAsidePanel } from "../components/runtime-aside-panel";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const MULTICA_HELPER_AVATAR_URL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E";
|
||||
|
||||
export function StepTeammate({
|
||||
runtime,
|
||||
onCreate,
|
||||
onBack,
|
||||
}: {
|
||||
runtime: AgentRuntime;
|
||||
onCreate: () => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const fadeStyle = useScrollFade(mainRef);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate();
|
||||
} catch {
|
||||
// The parent owns the toast. This step only restores the button state.
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
|
||||
<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={creating}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t(($) => $.common.back)}
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<StepHeader currentStep="teammate" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
ref={mainRef}
|
||||
style={fadeStyle}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[660px] 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_teammate.eyebrow)}
|
||||
</div>
|
||||
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
|
||||
{t(($) => $.step_teammate.headline)}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[580px] text-[15.5px] leading-[1.55] text-muted-foreground">
|
||||
{t(($) => $.step_teammate.lede, { runtime: runtime.name })}
|
||||
</p>
|
||||
|
||||
<section className="mt-10 overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex flex-col gap-6 p-6 sm:flex-row sm:items-center">
|
||||
<img
|
||||
src={MULTICA_HELPER_AVATAR_URL}
|
||||
alt={t(($) => $.step_teammate.avatar_label)}
|
||||
className="h-24 w-24 shrink-0 rounded-2xl shadow-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{t(($) => $.step_teammate.name)}
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full border bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
{t(($) => $.step_teammate.role)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-2 rounded-md bg-muted/70 px-3 py-2 text-sm text-muted-foreground">
|
||||
<ProviderLogo provider={runtime.provider} className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{runtime.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
runtime.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid border-t bg-muted/20 sm:grid-cols-3">
|
||||
<TeammatePoint
|
||||
icon={MessageSquareText}
|
||||
label={t(($) => $.step_teammate.point_issue)}
|
||||
/>
|
||||
<TeammatePoint
|
||||
icon={Sparkles}
|
||||
label={t(($) => $.step_teammate.point_context)}
|
||||
/>
|
||||
<TeammatePoint
|
||||
icon={Settings2}
|
||||
label={t(($) => $.step_teammate.point_customize)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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"
|
||||
>
|
||||
{t(($) => $.step_teammate.footer_hint)}
|
||||
</span>
|
||||
<Button size="lg" disabled={creating} onClick={handleCreate}>
|
||||
{creating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
{t(($) => $.step_teammate.create_action)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<RuntimeAsidePanel />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeammatePoint({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[104px] items-start gap-3 border-t p-5 first:border-t-0 sm:border-l sm:border-t-0 sm:first:border-l-0">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-background text-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm leading-[1.45] text-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,13 @@ import { StepUseCase } from "./step-use-case";
|
||||
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
|
||||
|
||||
const EMPTY: QuestionnaireAnswers = {
|
||||
source: null,
|
||||
source: [],
|
||||
source_other: null,
|
||||
source_skipped: false,
|
||||
role: null,
|
||||
role_other: null,
|
||||
role_skipped: false,
|
||||
use_case: null,
|
||||
use_case: [],
|
||||
use_case_other: null,
|
||||
use_case_skipped: false,
|
||||
version: 2,
|
||||
@@ -39,23 +39,34 @@ function renderStep(answers: QuestionnaireAnswers = EMPTY) {
|
||||
return { onChange, onAdvance, onSkip };
|
||||
}
|
||||
|
||||
describe("StepUseCase", () => {
|
||||
describe("StepUseCase (multi-select)", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("selecting a use case patches the slug and clears Other/skip", async () => {
|
||||
it("clicking a use case appends it to the array", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange, onAdvance } = renderStep();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /ship code/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /ship code/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
use_case: "ship_code",
|
||||
use_case_other: null,
|
||||
use_case: ["ship_code"],
|
||||
use_case_skipped: false,
|
||||
});
|
||||
expect(onAdvance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking an already-selected use case removes it (toggle)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderStep({ ...EMPTY, use_case: ["ship_code"] });
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /ship code/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
use_case: [],
|
||||
use_case_skipped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("Skip clears slot and marks use_case_skipped", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange, onSkip } = renderStep();
|
||||
@@ -63,7 +74,7 @@ describe("StepUseCase", () => {
|
||||
await user.click(screen.getByRole("button", { name: /skip/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
use_case: null,
|
||||
use_case: [],
|
||||
use_case_other: null,
|
||||
use_case_skipped: true,
|
||||
});
|
||||
@@ -74,9 +85,9 @@ describe("StepUseCase", () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderStep();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /^other$/i }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /^other$/i }));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
use_case: "other",
|
||||
use_case: ["other"],
|
||||
use_case_skipped: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,40 @@ export function StepUseCase({
|
||||
{ slug: "other", icon: <MoreHorizontal className="h-4 w-4" />, label: t(($) => $.questions.use_case.other), isOther: true },
|
||||
];
|
||||
|
||||
// Multi-select: a user is realistically here for several things at
|
||||
// once (ship code AND manage team, etc). Keeping it a single radio
|
||||
// forced a primary that often isn't truthful; the template recommender
|
||||
// already supports multi via includes(...) priority.
|
||||
const selected: readonly string[] = [
|
||||
...(answers.use_case ?? []),
|
||||
...(!answers.use_case?.includes("other") && answers.use_case_other
|
||||
? ["other"]
|
||||
: []),
|
||||
];
|
||||
|
||||
const toggle = (slug: string) => {
|
||||
const current = answers.use_case ?? [];
|
||||
if (slug === "other") {
|
||||
if (current.includes("other")) {
|
||||
onChange({
|
||||
use_case: current.filter((s) => s !== "other"),
|
||||
use_case_other: null,
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
use_case: [...current, "other"],
|
||||
use_case_skipped: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const typed = slug as UseCase;
|
||||
const next = current.includes(typed)
|
||||
? current.filter((s) => s !== typed)
|
||||
: [...current, typed];
|
||||
onChange({ use_case: next, use_case_skipped: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<StepQuestion
|
||||
step="use_case"
|
||||
@@ -51,27 +85,18 @@ export function StepUseCase({
|
||||
eyebrow={t(($) => $.questions.eyebrow_about_you)}
|
||||
question={t(($) => $.questions.use_case.question)}
|
||||
options={options}
|
||||
selectedSlug={answers.use_case ?? (answers.use_case_other ? "other" : null)}
|
||||
selectedSlugs={selected}
|
||||
otherValue={answers.use_case_other ?? ""}
|
||||
onOtherChange={(v) => onChange({ use_case_other: v })}
|
||||
otherPlaceholder={t(($) => $.questions.use_case.other_placeholder)}
|
||||
onAnswer={(slug) => {
|
||||
if (slug === "other") {
|
||||
onChange({ use_case: "other", use_case_skipped: false });
|
||||
} else {
|
||||
onChange({
|
||||
use_case: slug as UseCase,
|
||||
use_case_other: null,
|
||||
use_case_skipped: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onAnswer={toggle}
|
||||
onAdvance={onAdvance}
|
||||
onSkip={() => {
|
||||
onChange({ use_case: null, use_case_other: null, use_case_skipped: true });
|
||||
onChange({ use_case: [], use_case_other: null, use_case_skipped: true });
|
||||
onSkip();
|
||||
}}
|
||||
onBack={onBack}
|
||||
multiSelect
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { HELPER_DESCRIPTION, HELPER_INSTRUCTIONS } from "./helper-instructions";
|
||||
|
||||
const HELPER_AGENT_NAME = "Multica Helper";
|
||||
|
||||
/**
|
||||
* Skip path, issue 2/2: "Create your first Multica Agent".
|
||||
*
|
||||
* Companion to install-runtime-issue.ts. The body is a FUNCTION (not a
|
||||
* static const) because it needs to embed:
|
||||
* - A mention chip pointing at the install-runtime issue (so the user
|
||||
* can jump to it from this issue) — requires the install-runtime
|
||||
* issue's identifier + uuid, only known after that issue is created.
|
||||
* - The full Helper markdown block in the user's language (so the
|
||||
* embedded ```md fence matches the surrounding body language).
|
||||
*
|
||||
* Caller MUST create install-runtime first, then call this with its ids.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Step 2 of the skip-path bundle. Bilingual title.
|
||||
*/
|
||||
export const CREATE_AGENT_GUIDE_ISSUE_TITLE = {
|
||||
en: "Step 2 — Create your first Multica Agent",
|
||||
zh: "第 2 步 —— 创建你的第一个 Multica Agent",
|
||||
} as const;
|
||||
|
||||
interface BodyOpts {
|
||||
lang: "en" | "zh";
|
||||
installRuntimeIdentifier: string;
|
||||
installRuntimeId: string;
|
||||
}
|
||||
|
||||
export function getCreateAgentGuideBody(opts: BodyOpts): string {
|
||||
const mention = `[${opts.installRuntimeIdentifier}](mention://issue/${opts.installRuntimeId})`;
|
||||
if (opts.lang === "zh") {
|
||||
return zhBody(mention);
|
||||
}
|
||||
return enBody(mention);
|
||||
}
|
||||
|
||||
function enBody(installRuntimeMention: string): string {
|
||||
return `Once your runtime is online (see ${installRuntimeMention}), build your first agent — Multica Helper. The prompt below is pre-written; just copy.
|
||||
|
||||
## 1. Open the new-agent screen
|
||||
|
||||
Go to **Agents** in the sidebar → click **New Agent**.
|
||||
|
||||
## 2. Pick the runtime you just installed
|
||||
|
||||
Select the runtime under "Runtime". If nothing shows up, the runtime isn't online yet — finish the install steps in ${installRuntimeMention}.
|
||||
|
||||
## 3. Copy each block into the matching field
|
||||
|
||||
**Name**
|
||||
\`\`\`md
|
||||
${HELPER_AGENT_NAME}
|
||||
\`\`\`
|
||||
|
||||
**Description**
|
||||
\`\`\`md
|
||||
${HELPER_DESCRIPTION.en}
|
||||
\`\`\`
|
||||
|
||||
**Instructions**
|
||||
\`\`\`md
|
||||
${HELPER_INSTRUCTIONS.en}
|
||||
\`\`\`
|
||||
|
||||
## 4. Save → assign an issue
|
||||
|
||||
Hit **Create**. The new agent shows up in the workspace agent list.
|
||||
|
||||
Now create an issue (or reassign an existing one) → set assignee = Multica Helper → set status to **todo**. The runtime picks the task up within a few seconds and starts working. Watch progress in the issue's task panel.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **Skills** — reusable instruction packs you can attach to any agent.
|
||||
- **Squads** — groups of agents that can be assigned together.
|
||||
- **Autopilots** — scheduled or webhook-triggered runs.
|
||||
- **Docs** — https://multica.ai/docs.`;
|
||||
}
|
||||
|
||||
function zhBody(installRuntimeMention: string): string {
|
||||
return `等运行时上线(见 ${installRuntimeMention})之后,把第一个 agent —— Multica Helper —— 建出来。下面的提示词已经写好,直接复制即可。
|
||||
|
||||
## 1. 打开新建 agent 页
|
||||
|
||||
在侧边栏点 **Agents** → 点 **New Agent**。
|
||||
|
||||
## 2. 选你刚装好的运行时
|
||||
|
||||
在 "Runtime" 下选它。如果什么都没有,说明运行时还没上线 —— 先按 ${installRuntimeMention} 把安装步骤走完。
|
||||
|
||||
## 3. 把下面三段分别复制到对应字段
|
||||
|
||||
**名称**
|
||||
\`\`\`md
|
||||
${HELPER_AGENT_NAME}
|
||||
\`\`\`
|
||||
|
||||
**描述**
|
||||
\`\`\`md
|
||||
${HELPER_DESCRIPTION.zh}
|
||||
\`\`\`
|
||||
|
||||
**指令**
|
||||
\`\`\`md
|
||||
${HELPER_INSTRUCTIONS.zh}
|
||||
\`\`\`
|
||||
|
||||
## 4. 保存 → 分派 issue
|
||||
|
||||
点 **Create**。新 agent 会出现在 workspace 的 agent 列表里。
|
||||
|
||||
接着创建一个 issue(或把已有 issue 重新分派)→ 把 assignee 设成 Multica Helper → 状态切到 **todo**。运行时会在几秒内接走任务并开始工作。在 issue 的任务面板里看进度。
|
||||
|
||||
## 接下来去哪
|
||||
|
||||
- **Skills** —— 可复用的指令包,可挂到任何 agent 上。
|
||||
- **Squads** —— 可一起被分派的一组 agent。
|
||||
- **Autopilots** —— 定时或 webhook 触发的运行。
|
||||
- **文档** —— https://multica.ai/docs。`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* System prompt for the auto-created "Multica Helper" agent.
|
||||
*
|
||||
* Written to `agent.instructions` when the welcome hook calls
|
||||
* `api.createAgent` after a user finishes Step 3 with a runtime selected.
|
||||
* That field becomes the agent's `## Agent Identity` block in the
|
||||
* generated CLAUDE.md / AGENTS.md / GEMINI.md, read on every task the
|
||||
* Helper runs — not just the first onboarding issue.
|
||||
*
|
||||
* Structure (matches the design product reviewed):
|
||||
* 1. Identity
|
||||
* 2. What Multica is — concept map + docs / source / GitHub feedback
|
||||
* 3. What you can do — toolbox = `multica` CLI; `multica --help` is the
|
||||
* manifest; never invent commands
|
||||
* 4. Tone — concise; match user's language; never fabricate
|
||||
*
|
||||
* Intentionally NOT here (the brief already injects these):
|
||||
* - CLI command examples (## Available Commands)
|
||||
* - "Use CLI, not curl" hard rule
|
||||
* - @mention loop rules
|
||||
* - Per-task workflow
|
||||
* - Output via comment add
|
||||
* - Attachment handling
|
||||
*
|
||||
* Lives in views (not core) because it's UI copy bound to the welcome
|
||||
* Modal experience — i18n-adjacent content that ships with the frontend.
|
||||
* Stays in a TS module rather than i18n JSON because markdown of this
|
||||
* length renders poorly inside a JSON value.
|
||||
*/
|
||||
|
||||
const en = `You are Multica Helper, the built-in AI assistant for this Multica workspace. Your role is to help any member use Multica better — answer questions, give advice, and execute workspace operations on their behalf.
|
||||
|
||||
## What Multica is
|
||||
|
||||
Multica is an open-source, AI-native team workspace (source: https://github.com/multica-ai/multica). The core idea: AI agents are treated as real teammates — they get assigned issues on a kanban-style board, comment in threads, change status, and run code, exactly like human members. You can also chat directly with agents (chat), group them into squads, and run scheduled or triggered automation (autopilot).
|
||||
|
||||
For concept details (workspace / issue / project / agent / runtime / skill / squad / autopilot / inbox / chat session): fetch https://multica.ai/docs via WebFetch — that's authoritative. For the "why" or implementation, fetch the GitHub repo above. Never paraphrase concepts from memory.
|
||||
|
||||
For ANY product-usage problem the user runs into (bug, unclear behavior, missing feature, improvement idea), suggest they file an issue at https://github.com/multica-ai/multica/issues — that's the official feedback channel.
|
||||
|
||||
## What you can do
|
||||
|
||||
Your toolbox is the \`multica\` CLI. It's already on your PATH and authenticated as the workspace owner.
|
||||
|
||||
Your full capability surface = whatever \`multica --help\` shows. Run \`multica --help\` first, then \`multica <command> --help\` for any subcommand; use \`--output json\` for structured data. The CLI is your manifest — never invent commands or flags.
|
||||
|
||||
A few things you can actually do (non-exhaustive — \`--help\` is the source of truth):
|
||||
- Create issues, post comments
|
||||
- Create or iterate on agents
|
||||
- Manage projects, squads, autopilots, skills, runtimes, etc.
|
||||
|
||||
## Tone
|
||||
|
||||
Be concise and direct, like a colleague. Respond in the user's language (Chinese in, Chinese out). When pointing at a UI location, name the exact path ("Settings → Agents → New"); when pointing at a doc, link to the specific page, not the homepage. Never fabricate URLs, flags, or file paths.`;
|
||||
|
||||
const zh = `你是 Multica Helper,这个 Multica workspace 内置的 AI 助手。你的角色是帮助任何成员更好地使用 Multica —— 回答问题、给出建议、代为执行 workspace 操作。
|
||||
|
||||
## Multica 是什么
|
||||
|
||||
Multica 是一个开源、AI 原生的团队工作区(源码:https://github.com/multica-ai/multica)。核心思想:AI agent 被当作真正的队友 —— 在看板上被分派 issue、在讨论里发评论、修改状态、运行代码,与人类成员完全一样。你也可以直接和 agent 聊天(chat),把它们组合成小队(squad),运行定时或事件触发的自动化(autopilot)。
|
||||
|
||||
概念细节(workspace / issue / project / agent / runtime / skill / squad / autopilot / inbox / chat session)请用 WebFetch 抓取 https://multica.ai/docs —— 那是权威来源。关于"为什么"或实现细节,请抓取上面 GitHub 仓库。不要凭记忆复述概念。
|
||||
|
||||
任何产品使用问题(bug、行为不清晰、缺少功能、改进建议),建议用户去 https://github.com/multica-ai/multica/issues 开 issue —— 那是官方反馈渠道。
|
||||
|
||||
## 你能做什么
|
||||
|
||||
你的工具箱是 \`multica\` CLI。它已经在你的 PATH 上,以 workspace owner 身份认证。
|
||||
|
||||
你的全部能力 = \`multica --help\` 显示的内容。先跑 \`multica --help\`,再跑 \`multica <command> --help\` 看子命令;用 \`--output json\` 拿结构化数据。CLI 是你的清单 —— 不要编造命令或参数。
|
||||
|
||||
几件你确实能做的事(不完全列举 —— \`--help\` 是权威):
|
||||
- 创建 issue、发评论
|
||||
- 创建或迭代 agent
|
||||
- 管理 project、squad、autopilot、skill、runtime 等
|
||||
|
||||
## 语气
|
||||
|
||||
像同事一样,简洁、直接。用用户的语言回复(中文进,中文出)。指向 UI 位置时给出精确路径(如 "Settings → Agents → New");指向文档时链接到具体页面,而不是首页。绝不编造 URL、参数或文件路径。`;
|
||||
|
||||
export const HELPER_INSTRUCTIONS = { en, zh } as const;
|
||||
export type HelperInstructionsLang = keyof typeof HELPER_INSTRUCTIONS;
|
||||
|
||||
/**
|
||||
* Short Helper agent description. Used in TWO places:
|
||||
* 1. The `description` field on the auto-created Helper agent (runtime
|
||||
* path's `api.createAgent` call)
|
||||
* 2. The `## Description` section of the markdown block embedded in the
|
||||
* skip-path create-agent-guide issue body (so the user can copy/paste)
|
||||
*
|
||||
* Both consumers must stay in the same language as the user's locale —
|
||||
* hence the bilingual map. Kept short and product-y, no agent jargon.
|
||||
*/
|
||||
export const HELPER_DESCRIPTION = {
|
||||
en: "Multica usage assistant. Ask how to use it, help create/view tasks, configure agents, and more.",
|
||||
zh: "Multica 使用助手。可以询问用法、帮助创建/查看任务、配置 agent 等。",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The 3 starter tasks the Runtime-path Welcome Modal offers a user after
|
||||
* Multica Helper is created. Each card maps to ONE issue being created
|
||||
* via `api.createIssue` with:
|
||||
* - title = STARTER_PROMPT.title[lang]
|
||||
* - description = STARTER_PROMPT.prompt[lang]
|
||||
* - assignee = the Helper agent
|
||||
*
|
||||
* Title + prompt are persisted to the DB so they live as TS constants
|
||||
* (NOT i18n JSON) — anything written to the DB must be available at
|
||||
* write time without depending on the i18n bundle. Card subtitles are
|
||||
* UI-only (shown inside the Modal preview) and stay in
|
||||
* `locales/.../onboarding.json` under `welcome_after_onboarding.runtime.cards.*.subtitle`.
|
||||
*/
|
||||
|
||||
export const STARTER_CARD_IDS = ["intro", "tour", "welcome_page"] as const;
|
||||
export type StarterCardId = (typeof STARTER_CARD_IDS)[number];
|
||||
|
||||
interface StarterPrompt {
|
||||
title: { en: string; zh: string };
|
||||
prompt: { en: string; zh: string };
|
||||
}
|
||||
|
||||
export const HELPER_STARTER_PROMPTS: Record<StarterCardId, StarterPrompt> = {
|
||||
intro: {
|
||||
title: {
|
||||
en: "Introduce Multica to me",
|
||||
zh: "简单介绍一下 Multica",
|
||||
},
|
||||
prompt: {
|
||||
en: "Introduce Multica to me in 1–2 paragraphs. Cover what it is, the core concepts (workspace / issue / agent / runtime), and how it differs from tools like Linear or Jira.",
|
||||
zh: "用 1-2 段话简单介绍 Multica 给我。讲清楚它是什么、核心概念有哪些(workspace / issue / agent / runtime)、和 Linear / Jira 之类的工具核心区别在哪。",
|
||||
},
|
||||
},
|
||||
tour: {
|
||||
title: {
|
||||
en: "Walk me through the core features",
|
||||
zh: "带我熟悉每个功能",
|
||||
},
|
||||
prompt: {
|
||||
en: "Walk me through Multica's core features — issue, agent, squad, autopilot, chat. Pick one realistic scenario I might run into and explain how all these pieces fit together.",
|
||||
zh: "陪我熟悉 Multica 的每个核心功能 —— issue、agent、squad、autopilot、chat。挑一个我可能用得上的真实场景,讲讲这几个东西是怎么配合的。",
|
||||
},
|
||||
},
|
||||
welcome_page: {
|
||||
title: {
|
||||
en: "Make me a welcome page",
|
||||
zh: "帮我做一个欢迎页",
|
||||
},
|
||||
prompt: {
|
||||
en: "Make a small HTML page (with some CSS, maybe a touch of animation) that welcomes me to Multica. Paste the full HTML in a comment on this issue so I can copy it straight from there.",
|
||||
zh: "用 HTML + 简单 CSS 给我做一个欢迎页,庆祝我刚加入 Multica。可以加点小动效。做完后把完整 HTML 贴到这个 issue 的评论里,我直接复制就能用。",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
HELPER_INSTRUCTIONS,
|
||||
HELPER_DESCRIPTION,
|
||||
type HelperInstructionsLang,
|
||||
} from "./helper-instructions";
|
||||
export {
|
||||
INSTALL_RUNTIME_ISSUE_TITLE,
|
||||
INSTALL_RUNTIME_ISSUE_BODY,
|
||||
FOLLOWUP_COMMENT_PREFIX,
|
||||
} from "./install-runtime-issue";
|
||||
export {
|
||||
CREATE_AGENT_GUIDE_ISSUE_TITLE,
|
||||
getCreateAgentGuideBody,
|
||||
} from "./create-agent-guide-issue";
|
||||
export {
|
||||
HELPER_STARTER_PROMPTS,
|
||||
STARTER_CARD_IDS,
|
||||
type StarterCardId,
|
||||
} from "./helper-starter-prompts";
|
||||
export {
|
||||
buildUserContextSection,
|
||||
type UserContextLabels,
|
||||
type QuestionnaireRaw,
|
||||
} from "./user-context";
|
||||
|
||||
/**
|
||||
* Pick the EN or ZH content for the given user language. Maps any "zh*"
|
||||
* prefix to the Chinese variant; everything else falls back to English.
|
||||
* Mirrors the server-side `noRuntimeIssueDescription` logic.
|
||||
*/
|
||||
export function pickContentLang(
|
||||
language: string | null | undefined,
|
||||
): "en" | "zh" {
|
||||
if (language && language.toLowerCase().startsWith("zh")) return "zh";
|
||||
return "en";
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Skip path, issue 1/2: "Connect a runtime to start using agents".
|
||||
*
|
||||
* Written to a new issue (assigned to the user themselves) by the welcome
|
||||
* hook when the user took the Skip exit on Step 3. Content is the
|
||||
* install-runtime tutorial; the Chinese version uses Kimi CLI as the
|
||||
* recommended quickest path, the English version uses Codex.
|
||||
*
|
||||
* Title is stable — kept identical to the v2 server-side
|
||||
* `NoRuntimeIssueTitle` so any existing dedupe code elsewhere keeps
|
||||
* matching by title.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Step 1 of the skip-path bundle. Bilingual so a Chinese user sees a
|
||||
* Chinese title on the board.
|
||||
*
|
||||
* Note: server's deprecation shim (`onboarding_shim.go:noRuntimeIssueTitle`)
|
||||
* still uses the bare English string for its title-based dedupe — that
|
||||
* codepath only runs for pre-v3 desktop builds and never overlaps with
|
||||
* the v3 frontend population, so the two title-spaces drifting is fine.
|
||||
*/
|
||||
export const INSTALL_RUNTIME_ISSUE_TITLE = {
|
||||
en: "Step 1 — Connect a runtime to start using agents",
|
||||
zh: "第 1 步 —— 连接运行时,开始使用 agent",
|
||||
} as const;
|
||||
|
||||
const en = `Welcome to Multica.
|
||||
|
||||
Agents need a runtime before they can execute work. You can still use Multica as a lightweight project-management workspace while you install one.
|
||||
|
||||
## Try Multica first
|
||||
|
||||
Before the runtime is ready, you can:
|
||||
|
||||
1. Create a project for your current work.
|
||||
2. Create a few issues and move them across backlog, todo, in_progress, and done.
|
||||
3. Add priorities, labels, comments, and subscriptions.
|
||||
4. Use Inbox to track assignments and mentions.
|
||||
|
||||
That gives you the project-management layer first. Once a runtime is connected, agents can start working from the same issues.
|
||||
|
||||
## Install your first agent runtime
|
||||
|
||||
Full guide: https://multica.ai/docs/install-agent-runtime
|
||||
|
||||
For English users, the fastest first path is Codex:
|
||||
|
||||
1. Make sure Node.js is installed.
|
||||
2. Install Codex:
|
||||
npm i -g @openai/codex
|
||||
3. Sign in:
|
||||
codex
|
||||
4. Confirm your terminal can find it:
|
||||
which codex
|
||||
codex --version
|
||||
5. Restart the Multica daemon:
|
||||
multica daemon restart
|
||||
If you use the desktop app, restarting the app is enough.
|
||||
6. Return to Runtimes and refresh. You should see a Codex runtime online.
|
||||
7. Create your first agent from that runtime, then assign an issue to the agent and set status to todo.
|
||||
|
||||
Codex reference: https://developers.openai.com/codex/cli
|
||||
|
||||
When the runtime is connected, you can create Multica Helper for a guided first run.`;
|
||||
|
||||
const zh = `欢迎来到 Multica。
|
||||
|
||||
智能体需要先连上运行时才能执行工作。运行时还没准备好时,你也可以先把 Multica 当作轻量项目管理工具体验起来。
|
||||
|
||||
## 先体验项目管理功能
|
||||
|
||||
运行时安装前,你可以先做这些事:
|
||||
|
||||
1. 为当前工作创建一个项目。
|
||||
2. 新建几个 issue,并在 backlog、todo、in_progress、done 之间流转。
|
||||
3. 给 issue 加优先级、标签、评论和订阅。
|
||||
4. 用收件箱追踪分配给你的事项和 @mention。
|
||||
|
||||
这样你先熟悉项目管理层。连上运行时后,智能体会直接在这些 issue 上开始工作。
|
||||
|
||||
## 安装第一个 Agent 运行时
|
||||
|
||||
完整文档:https://multica.ai/docs/install-agent-runtime
|
||||
|
||||
中文用户建议先装 Kimi CLI:
|
||||
|
||||
1. 在 macOS / Linux 终端安装 Kimi CLI:
|
||||
curl -LsSf https://code.kimi.com/install.sh | bash
|
||||
Windows PowerShell:
|
||||
Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression
|
||||
2. 确认终端能找到 Kimi:
|
||||
kimi --version
|
||||
3. 在你想让 Kimi 工作的项目目录里启动一次:
|
||||
kimi
|
||||
4. 首次启动后输入 /login,按提示完成 Kimi Code 或 API key 配置。
|
||||
5. 重启 Multica 守护进程:
|
||||
multica daemon restart
|
||||
如果你用桌面端,重启 app 即可。
|
||||
6. 回到 Runtimes 页面刷新。你应该能看到一个在线的 Kimi 运行时。
|
||||
7. 用这个运行时创建第一个智能体,再把一个 issue 分配给它,并把状态切到 todo。
|
||||
|
||||
Kimi CLI 官方文档:https://moonshotai.github.io/kimi-cli/zh/guides/getting-started.html
|
||||
|
||||
运行时连上后,你就可以创建 Multica Helper,开始一次有智能体参与的上手引导。`;
|
||||
|
||||
export const INSTALL_RUNTIME_ISSUE_BODY = { en, zh } as const;
|
||||
|
||||
/**
|
||||
* Prefix sentence for the follow-up comment posted on this issue (the one
|
||||
* that links to the create-agent-guide issue via a mention chip). Kept
|
||||
* here as a TS const rather than an i18n JSON key because anything that
|
||||
* gets persisted to the DB must be available at write time without
|
||||
* depending on an i18n bundle having loaded the new key — otherwise a
|
||||
* cold dev server / stale build writes the raw key string into
|
||||
* `comment.content` and the comment is permanently broken.
|
||||
*/
|
||||
export const FOLLOWUP_COMMENT_PREFIX = {
|
||||
en: "Your next step:",
|
||||
zh: "完成后的下一步:",
|
||||
} as const;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildUserContextSection,
|
||||
type UserContextLabels,
|
||||
} from "./user-context";
|
||||
|
||||
const EN_LABELS: UserContextLabels = {
|
||||
heading: "About me",
|
||||
roleLabel: "Role",
|
||||
useCaseLabel: "What I want to do",
|
||||
listSeparator: ", ",
|
||||
role: {
|
||||
engineer: "Engineer / developer",
|
||||
product: "Product manager",
|
||||
other: "Other",
|
||||
},
|
||||
useCase: {
|
||||
ship_code: "Ship code",
|
||||
manage_team: "Manage team",
|
||||
other: "Other",
|
||||
},
|
||||
};
|
||||
|
||||
const ZH_LABELS: UserContextLabels = {
|
||||
heading: "关于我",
|
||||
roleLabel: "角色",
|
||||
useCaseLabel: "想用 Multica 做",
|
||||
listSeparator: "、",
|
||||
role: { engineer: "工程师", product: "产品经理", other: "其他" },
|
||||
useCase: {
|
||||
ship_code: "写代码",
|
||||
manage_team: "管理团队",
|
||||
other: "其他",
|
||||
},
|
||||
};
|
||||
|
||||
describe("buildUserContextSection", () => {
|
||||
it("returns empty string when questionnaire is undefined/null", () => {
|
||||
expect(buildUserContextSection(undefined, EN_LABELS)).toBe("");
|
||||
expect(buildUserContextSection(null, EN_LABELS)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when role + use_case are both missing", () => {
|
||||
expect(buildUserContextSection({}, EN_LABELS)).toBe("");
|
||||
expect(
|
||||
buildUserContextSection(
|
||||
{ role: "", role_other: "", use_case: [], use_case_other: "" },
|
||||
EN_LABELS,
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("renders role only when use_case is empty", () => {
|
||||
const out = buildUserContextSection(
|
||||
{ role: "engineer", use_case: [] },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("**About me**");
|
||||
expect(out).toContain("Role: Engineer / developer");
|
||||
expect(out).not.toContain("What I want to do");
|
||||
});
|
||||
|
||||
it("renders use_case only when role is empty", () => {
|
||||
const out = buildUserContextSection(
|
||||
{ role: "", use_case: ["ship_code"] },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("**About me**");
|
||||
expect(out).toContain("What I want to do: Ship code");
|
||||
expect(out).not.toContain("Role:");
|
||||
});
|
||||
|
||||
it("joins multi-select use_case with the locale separator", () => {
|
||||
expect(
|
||||
buildUserContextSection(
|
||||
{ role: "engineer", use_case: ["ship_code", "manage_team"] },
|
||||
EN_LABELS,
|
||||
),
|
||||
).toContain("Ship code, Manage team");
|
||||
expect(
|
||||
buildUserContextSection(
|
||||
{ role: "engineer", use_case: ["ship_code", "manage_team"] },
|
||||
ZH_LABELS,
|
||||
),
|
||||
).toContain("写代码、管理团队");
|
||||
});
|
||||
|
||||
it("uses role_other free-text when role slug is 'other'", () => {
|
||||
const out = buildUserContextSection(
|
||||
{ role: "other", role_other: "Teacher" },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("Role: Teacher");
|
||||
// The literal "Other" enum label must NOT leak when the free-text
|
||||
// is the actual answer.
|
||||
expect(out).not.toMatch(/Role: Other$/m);
|
||||
});
|
||||
|
||||
it("uses use_case_other free-text alongside other use_case picks", () => {
|
||||
const out = buildUserContextSection(
|
||||
{
|
||||
use_case: ["ship_code", "other"],
|
||||
use_case_other: "study group coordination",
|
||||
},
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("Ship code, study group coordination");
|
||||
});
|
||||
|
||||
it("drops 'other' silently when use_case_other free-text is blank", () => {
|
||||
const out = buildUserContextSection(
|
||||
{ use_case: ["ship_code", "other"], use_case_other: "" },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("Ship code");
|
||||
// Blank free-text on Other => the slug contributes nothing,
|
||||
// not even the generic "Other" label.
|
||||
expect(out).not.toMatch(/, Other$/m);
|
||||
});
|
||||
|
||||
it("tolerates the legacy single-string use_case shape", () => {
|
||||
// Questionnaires written before the multi-select migration stored
|
||||
// use_case as a single string. The coercer wraps it into [x].
|
||||
const out = buildUserContextSection(
|
||||
{ use_case: "ship_code" },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("What I want to do: Ship code");
|
||||
});
|
||||
|
||||
it("falls back to the slug when a label is missing from the map", () => {
|
||||
// The labels map only covers a subset; an unmapped slug renders
|
||||
// raw rather than erroring out (defensive against locale drift).
|
||||
const out = buildUserContextSection(
|
||||
{ role: "unknown_role" },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out).toContain("Role: unknown_role");
|
||||
});
|
||||
|
||||
it("starts with a horizontal rule so it doesn't fuse with the prompt", () => {
|
||||
const out = buildUserContextSection(
|
||||
{ role: "engineer" },
|
||||
EN_LABELS,
|
||||
);
|
||||
expect(out.startsWith("\n\n---\n\n")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Builds a markdown blockquote that documents who the user is on each
|
||||
* starter issue Helper picks up. Source data is the onboarding
|
||||
* questionnaire stashed on `user.onboarding_questionnaire`.
|
||||
*
|
||||
* The block is APPENDED (not prepended) so the original prompt stays
|
||||
* the lead — Helper reads the task instruction first, then the user
|
||||
* context as supplementary info. Returns "" when there's nothing
|
||||
* useful to say (questionnaire empty, everything skipped) so the
|
||||
* caller can unconditionally do:
|
||||
* description: prompt + buildUserContextSection(...)
|
||||
*
|
||||
* Labels (heading, "Role", "Use case", slug→label maps) are passed
|
||||
* in by the caller after resolving from i18n. Keeping this function
|
||||
* pure makes it easy to unit-test and easy to call from the workspace
|
||||
* shell without dragging an i18n hook into a templates module.
|
||||
*/
|
||||
|
||||
export interface UserContextLabels {
|
||||
/** Section heading shown above the list (e.g. "About me" / "关于我"). */
|
||||
heading: string;
|
||||
/** Label for the role line (e.g. "Role" / "角色"). */
|
||||
roleLabel: string;
|
||||
/** Label for the use case line (e.g. "What I want to do" / "想用来做"). */
|
||||
useCaseLabel: string;
|
||||
/** Joiner for multi-select use_case values (", " for EN, "、" for ZH). */
|
||||
listSeparator: string;
|
||||
/** slug → human-readable label for each role enum value. */
|
||||
role: Record<string, string>;
|
||||
/** slug → human-readable label for each use_case enum value. */
|
||||
useCase: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface QuestionnaireRaw {
|
||||
role?: unknown;
|
||||
role_other?: unknown;
|
||||
use_case?: unknown;
|
||||
use_case_other?: unknown;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(v): v is string => typeof v === "string" && v.length > 0,
|
||||
);
|
||||
}
|
||||
// Tolerate the legacy single-string shape so questionnaires written
|
||||
// before the multi-select migration still render correctly.
|
||||
if (typeof value === "string" && value.length > 0) return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function buildUserContextSection(
|
||||
raw: QuestionnaireRaw | undefined | null,
|
||||
labels: UserContextLabels,
|
||||
): string {
|
||||
if (!raw) return "";
|
||||
|
||||
// Role is single-select. "other" slug means the user picked Other and
|
||||
// filled the free-text input — show the free-text instead of the
|
||||
// generic "Other" label.
|
||||
const role = asString(raw.role);
|
||||
const roleOther = asString(raw.role_other);
|
||||
const roleDisplay =
|
||||
role === "other"
|
||||
? roleOther
|
||||
: role
|
||||
? labels.role[role] ?? role
|
||||
: "";
|
||||
|
||||
// Use case is multi-select. The "other" slug stacks alongside regular
|
||||
// picks; the free-text lives in use_case_other. Map each slug to a
|
||||
// human label, drop empties, and join with the locale-appropriate
|
||||
// separator.
|
||||
const useCaseSlugs = asStringArray(raw.use_case);
|
||||
const useCaseOther = asString(raw.use_case_other);
|
||||
const useCaseDisplays = useCaseSlugs
|
||||
.map((slug) =>
|
||||
slug === "other" ? useCaseOther : labels.useCase[slug] ?? slug,
|
||||
)
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
const hasRole = roleDisplay.length > 0;
|
||||
const hasUseCase = useCaseDisplays.length > 0;
|
||||
if (!hasRole && !hasUseCase) return "";
|
||||
|
||||
// Two leading newlines + horizontal rule separate the block from the
|
||||
// prompt above it so renderers don't fuse them into the same paragraph.
|
||||
const lines: string[] = ["", "", "---", "", `> **${labels.heading}**`, ">"];
|
||||
if (hasRole) lines.push(`> ${labels.roleLabel}: ${roleDisplay}`);
|
||||
if (hasUseCase) {
|
||||
lines.push(
|
||||
`> ${labels.useCaseLabel}: ${useCaseDisplays.join(labels.listSeparator)}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"./workspace/create-workspace-form": "./workspace/create-workspace-form.tsx",
|
||||
"./workspace/no-access-page": "./workspace/no-access-page.tsx",
|
||||
"./workspace/new-workspace-page": "./workspace/new-workspace-page.tsx",
|
||||
"./workspace/welcome-after-onboarding": "./workspace/welcome-after-onboarding.tsx",
|
||||
"./workspace/use-workspace-seen": "./workspace/use-workspace-seen.ts",
|
||||
"./layout": "./layout/index.ts",
|
||||
"./auth": "./auth/index.ts",
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enOnboarding from "../locales/en/onboarding.json";
|
||||
import enCommon from "../locales/en/common.json";
|
||||
import { NavigationProvider } from "../navigation";
|
||||
import type { NavigationAdapter } from "../navigation";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import { WelcomeAfterOnboarding } from "./welcome-after-onboarding";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, onboarding: enOnboarding },
|
||||
};
|
||||
|
||||
// `useAuthStore` is a singleton Proxy that requires `registerAuthStore`
|
||||
// to be called before use. In tests we mock the module wholesale so the
|
||||
// component reads a fixed user without ever touching the proxy.
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
name: "Test",
|
||||
email: "[email protected]",
|
||||
avatar_url: null,
|
||||
onboarded_at: "2026-01-01T00:00:00Z",
|
||||
onboarding_questionnaire: {},
|
||||
starter_content_state: null,
|
||||
language: null,
|
||||
profile_description: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector?: (s: { user: typeof mockUser }) => unknown) => {
|
||||
const state = { user: mockUser };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ user: mockUser }) },
|
||||
),
|
||||
registerAuthStore: vi.fn(),
|
||||
createAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockListAgents = vi.fn();
|
||||
const mockCreateAgent = vi.fn();
|
||||
const mockCreateIssue = vi.fn();
|
||||
const mockCreateComment = vi.fn();
|
||||
const mockGetWorkspace = vi.fn();
|
||||
|
||||
// `useCurrentWorkspace` is gated by `WorkspaceSlugProvider`; in tests
|
||||
// we short-circuit to a fixture matching the welcome signal's workspace id
|
||||
// so the cross-workspace guard doesn't drop the component.
|
||||
vi.mock("@multica/core/paths", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
|
||||
"@multica/core/paths",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useCurrentWorkspace: () => ({
|
||||
id: "ws-1",
|
||||
slug: "test-ws",
|
||||
name: "Test WS",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listAgents: (...args: unknown[]) => mockListAgents(...args),
|
||||
createAgent: (...args: unknown[]) => mockCreateAgent(...args),
|
||||
createIssue: (...args: unknown[]) => mockCreateIssue(...args),
|
||||
createComment: (...args: unknown[]) => mockCreateComment(...args),
|
||||
getWorkspace: (...args: unknown[]) => mockGetWorkspace(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const navigationAdapter: NavigationAdapter = {
|
||||
push: (path: string) => mockPush(path),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/test",
|
||||
searchParams: new URLSearchParams(),
|
||||
getShareableUrl: (path: string) => `https://test.local${path}`,
|
||||
};
|
||||
|
||||
function I18nWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<NavigationProvider value={navigationAdapter}>
|
||||
{children}
|
||||
</NavigationProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
qc.setQueryData(["workspaces", "list"], [{ id: "ws-1", slug: "test-ws" }]);
|
||||
return render(<WelcomeAfterOnboarding />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<I18nWrapper>{children}</I18nWrapper>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockListAgents.mockReset();
|
||||
mockCreateAgent.mockReset();
|
||||
mockCreateIssue.mockReset();
|
||||
mockCreateComment.mockReset();
|
||||
mockGetWorkspace.mockReset();
|
||||
mockPush.mockReset();
|
||||
useWelcomeStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("WelcomeAfterOnboarding", () => {
|
||||
it("renders nothing when no welcome signal is present", () => {
|
||||
const { container } = renderWelcome();
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when the signal points at a different workspace", () => {
|
||||
// Cross-workspace guard: store may have a signal parked from
|
||||
// workspace ws-2 while the user is currently viewing ws-1 (the
|
||||
// mocked useCurrentWorkspace returns ws-1). Don't fire here —
|
||||
// otherwise we'd create the Helper / seed issues in ws-2 while the
|
||||
// user looks at ws-1, then navigate them away unexpectedly.
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: "ws-2",
|
||||
choice: "skip",
|
||||
});
|
||||
const { container } = renderWelcome();
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(mockCreateIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("runtime path", () => {
|
||||
it("creates a Helper agent then opens a blocking modal with starter cards", async () => {
|
||||
mockListAgents.mockResolvedValueOnce([]);
|
||||
mockCreateAgent.mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
name: "Multica Helper",
|
||||
description: "Built-in workspace assistant.",
|
||||
avatar_url: null,
|
||||
visibility: "workspace",
|
||||
});
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: "ws-1",
|
||||
choice: "runtime",
|
||||
runtimeId: "rt-1",
|
||||
});
|
||||
|
||||
renderWelcome();
|
||||
|
||||
expect(screen.getByText(/Preparing your Helper/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/welcome to Multica/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockCreateAgent).toHaveBeenCalledTimes(1);
|
||||
const [agentArgs] = mockCreateAgent.mock.calls[0]!;
|
||||
expect(agentArgs.runtime_id).toBe("rt-1");
|
||||
expect(agentArgs.name).toBe("Multica Helper");
|
||||
expect(agentArgs.instructions).toContain("Multica Helper");
|
||||
|
||||
// 3 starter card titles come from HELPER_STARTER_PROMPTS (TS const,
|
||||
// EN under the test's en locale).
|
||||
expect(
|
||||
screen.getByText("Introduce Multica to me"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Walk me through the core features"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Make me a welcome page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reuses an existing Multica Helper agent instead of creating duplicates", async () => {
|
||||
mockListAgents.mockResolvedValueOnce([
|
||||
{
|
||||
id: "agent-existing",
|
||||
name: "Multica Helper",
|
||||
description: "",
|
||||
avatar_url: null,
|
||||
visibility: "workspace",
|
||||
archived_at: null,
|
||||
},
|
||||
]);
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: "ws-1",
|
||||
choice: "runtime",
|
||||
runtimeId: "rt-1",
|
||||
});
|
||||
|
||||
renderWelcome();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/welcome to Multica/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selecting cards then clicking Assign creates one issue per pick and navigates to the first", async () => {
|
||||
mockListAgents.mockResolvedValueOnce([]);
|
||||
mockCreateAgent.mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
name: "Multica Helper",
|
||||
description: "",
|
||||
avatar_url: null,
|
||||
visibility: "workspace",
|
||||
});
|
||||
// Pick 2 cards — `intro` then `welcome_page`. Issues come back in
|
||||
// STARTER_CARD_IDS order (intro first), so navigate target is the
|
||||
// intro issue.
|
||||
mockCreateIssue
|
||||
.mockResolvedValueOnce({
|
||||
id: "issue-intro",
|
||||
workspace_id: "ws-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "issue-welcome",
|
||||
workspace_id: "ws-1",
|
||||
});
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: "ws-1",
|
||||
choice: "runtime",
|
||||
runtimeId: "rt-1",
|
||||
});
|
||||
|
||||
renderWelcome();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("Introduce Multica to me"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// CTA is disabled until at least one card is selected.
|
||||
const ctaEmpty = screen.getByRole("button", { name: /pick one or more/i });
|
||||
expect(ctaEmpty).toBeDisabled();
|
||||
|
||||
// Toggle two cards.
|
||||
fireEvent.click(screen.getByText("Introduce Multica to me"));
|
||||
fireEvent.click(screen.getByText("Make me a welcome page"));
|
||||
|
||||
// CTA enables and reflects the count.
|
||||
const cta = await screen.findByRole("button", { name: /assign 2/i });
|
||||
expect(cta).not.toBeDisabled();
|
||||
fireEvent.click(cta);
|
||||
|
||||
await waitFor(() => expect(mockCreateIssue).toHaveBeenCalledTimes(2));
|
||||
const titles = mockCreateIssue.mock.calls.map(([args]) => args.title);
|
||||
expect(titles).toEqual([
|
||||
"Introduce Multica to me",
|
||||
"Make me a welcome page",
|
||||
]);
|
||||
// Both assigned to the same Helper agent.
|
||||
mockCreateIssue.mock.calls.forEach(([args]) => {
|
||||
expect(args.assignee_type).toBe("agent");
|
||||
expect(args.assignee_id).toBe("agent-1");
|
||||
});
|
||||
|
||||
// After Promise.all resolves we DO NOT navigate immediately — the
|
||||
// Modal switches to a success view (☕ "you're all set, Helper is
|
||||
// on it, here's how to check via Inbox / chat"). The user must
|
||||
// click Got it on that view to navigate.
|
||||
const gotIt = await screen.findByRole("button", { name: /got it/i });
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
fireEvent.click(gotIt);
|
||||
|
||||
// Navigates to the first issue (intro, since it's earlier in
|
||||
// STARTER_CARD_IDS).
|
||||
await waitFor(() =>
|
||||
expect(mockPush).toHaveBeenCalledWith("/test-ws/issues/issue-intro"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skip path", () => {
|
||||
it("provisions install-runtime → agent-guide → follow-up comment, then opens the celebration Modal", async () => {
|
||||
// Sequential provisioning order in the implementation:
|
||||
// 1. install-runtime (Step 1, body is static, becomes the mention
|
||||
// chip target inside agent-guide's body)
|
||||
// 2. agent-guide (Step 2, body embeds install-runtime mention)
|
||||
// 3. comment on install-runtime (mentions agent-guide back)
|
||||
mockCreateIssue
|
||||
.mockResolvedValueOnce({
|
||||
id: "issue-install",
|
||||
identifier: "MUL-1",
|
||||
workspace_id: "ws-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "issue-agent",
|
||||
identifier: "MUL-2",
|
||||
workspace_id: "ws-1",
|
||||
});
|
||||
mockCreateComment.mockResolvedValueOnce({ id: "comment-1" });
|
||||
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: "ws-1",
|
||||
choice: "skip",
|
||||
});
|
||||
|
||||
renderWelcome();
|
||||
|
||||
// Loading veil shows first.
|
||||
expect(screen.getByText(/Setting up your workspace/i)).toBeInTheDocument();
|
||||
|
||||
// Modal appears once all 3 API calls succeed.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Welcome to Multica/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockCreateIssue).toHaveBeenCalledTimes(2);
|
||||
expect(mockCreateComment).toHaveBeenCalledTimes(1);
|
||||
|
||||
// First createIssue call: install-runtime (Step 1).
|
||||
const [firstCall] = mockCreateIssue.mock.calls;
|
||||
expect(firstCall![0].title).toBe(
|
||||
"Step 1 — Connect a runtime to start using agents",
|
||||
);
|
||||
expect(firstCall![0].status).toBe("in_progress");
|
||||
expect(firstCall![0].assignee_type).toBe("member");
|
||||
expect(firstCall![0].assignee_id).toBe("user-1");
|
||||
|
||||
// Second createIssue call: agent-guide (Step 2), body must embed
|
||||
// install-runtime mention chip pointing at MUL-1 / issue-install.
|
||||
const [secondCall] = mockCreateIssue.mock.calls.slice(1);
|
||||
expect(secondCall![0].title).toBe(
|
||||
"Step 2 — Create your first Multica Agent",
|
||||
);
|
||||
expect(secondCall![0].status).toBe("todo");
|
||||
expect(secondCall![0].description).toContain(
|
||||
"[MUL-1](mention://issue/issue-install)",
|
||||
);
|
||||
|
||||
// Follow-up comment posted on install-runtime as a mention chip
|
||||
// pointing at the agent-guide issue.
|
||||
const [commentIssueId, commentContent] =
|
||||
mockCreateComment.mock.calls[0]!;
|
||||
expect(commentIssueId).toBe("issue-install");
|
||||
expect(commentContent).toContain("[MUL-2](mention://issue/issue-agent)");
|
||||
});
|
||||
|
||||
it("silently dismisses without showing the Modal when provisioning fails", async () => {
|
||||
mockCreateIssue.mockRejectedValueOnce(new Error("network down"));
|
||||
useWelcomeStore.getState().set({
|
||||
workspaceId: "ws-1",
|
||||
choice: "skip",
|
||||
});
|
||||
|
||||
renderWelcome();
|
||||
|
||||
// Failure path: loading veil shows, then unmounts as the store is
|
||||
// dismissed. No celebration Modal ever appears.
|
||||
await waitFor(() =>
|
||||
expect(useWelcomeStore.getState().dismissed).toBe(true),
|
||||
);
|
||||
expect(screen.queryByText(/Welcome to Multica/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,971 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import { paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import type { Agent, CreateIssueRequest, Issue } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useT } from "../i18n";
|
||||
import {
|
||||
buildUserContextSection,
|
||||
CREATE_AGENT_GUIDE_ISSUE_TITLE,
|
||||
FOLLOWUP_COMMENT_PREFIX,
|
||||
getCreateAgentGuideBody,
|
||||
HELPER_DESCRIPTION,
|
||||
HELPER_INSTRUCTIONS,
|
||||
HELPER_STARTER_PROMPTS,
|
||||
INSTALL_RUNTIME_ISSUE_BODY,
|
||||
INSTALL_RUNTIME_ISSUE_TITLE,
|
||||
pickContentLang,
|
||||
STARTER_CARD_IDS,
|
||||
type StarterCardId,
|
||||
type UserContextLabels,
|
||||
} from "../onboarding/templates";
|
||||
|
||||
/**
|
||||
* Workspace welcome experience for users who just finished onboarding.
|
||||
*
|
||||
* Mounted in the workspace shell on both web and desktop. Subscribes to
|
||||
* the Zustand welcome-store signal that `OnboardingFlow.handleRuntimeNext`
|
||||
* parks before navigating. Renders null when there is no signal or when
|
||||
* the signal has been dismissed.
|
||||
*
|
||||
* Two sub-templates driven by `signal.choice`:
|
||||
*
|
||||
* "runtime":
|
||||
* 1. Full-screen loading veil ("Preparing your Helper…")
|
||||
* 2. Find-or-create a "Multica Helper" agent on the picked runtime
|
||||
* — `listAgents` first to dedupe against re-entries, then
|
||||
* `createAgent` with the EN/ZH instructions from
|
||||
* `onboarding/templates/helper-instructions.ts`.
|
||||
* 3. Blocking Dialog (no close button, no Escape, no outside-click).
|
||||
* Renders the agent's name + description and 3 starter cards.
|
||||
* 4. User picks a card → `createIssue` with the card's prompt as
|
||||
* body, assigned to the Helper agent → `dismiss()` → navigate.
|
||||
* 5. Failure surfaces a Retry UI; the modal stays put.
|
||||
*
|
||||
* "skip":
|
||||
* 1. Dialog opens immediately (closable).
|
||||
* 2. Background: `createIssue × 2` with the install-runtime and
|
||||
* create-agent-guide bodies, assigned to the user themselves.
|
||||
* 3. Cards render with their static titles + subtitles; per-card
|
||||
* spinner switches to "Open issue" once the issue id arrives.
|
||||
* 4. Click a card → `dismiss()` → navigate. Close the modal →
|
||||
* `dismiss()` → stay on issues list; seeded issues remain.
|
||||
* 5. Per-card failure surfaces a Retry button on that card only.
|
||||
*
|
||||
* Why subscribe-not-consume: React 18+ StrictMode dev mounts components
|
||||
* twice. A consume-on-init pattern would empty the store on the first
|
||||
* mount, then render null on the second. Subscribing + recording
|
||||
* dismissal in the store sidesteps both mounts seeing the same state.
|
||||
* Async work (Helper agent setup, skip-path seed issues) is deduped at
|
||||
* module level via `findOrCreateHelper` / `seedIssueDeduped` so the
|
||||
* double-mount can't race two API calls for the same workspace.
|
||||
*/
|
||||
export function WelcomeAfterOnboarding() {
|
||||
const me = useAuthStore((s) => s.user);
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const signal = useWelcomeStore((s) => s.signal);
|
||||
const dismissed = useWelcomeStore((s) => s.dismissed);
|
||||
const dismiss = useWelcomeStore((s) => s.dismiss);
|
||||
|
||||
// Cross-workspace safety: signal lives in a global store, but this
|
||||
// component mounts inside a workspace-scoped layout. If the user is
|
||||
// currently viewing ws-B while signal points at ws-A (back/forward,
|
||||
// deep-link, desktop multi-tab where stores are shared across tabs in
|
||||
// one renderer), DON'T fire here — would create the Helper agent /
|
||||
// seed issues in ws-A while the user looks at ws-B, then navigate them
|
||||
// away. Render null until the user lands back in the workspace the
|
||||
// signal was parked for.
|
||||
if (
|
||||
!me ||
|
||||
!signal ||
|
||||
dismissed ||
|
||||
!currentWorkspace ||
|
||||
currentWorkspace.id !== signal.workspaceId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (signal.choice === "runtime" && signal.runtimeId) {
|
||||
return (
|
||||
<RuntimeWelcome
|
||||
workspaceId={signal.workspaceId}
|
||||
runtimeId={signal.runtimeId}
|
||||
onAbandon={dismiss}
|
||||
onComplete={dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (signal.choice === "skip") {
|
||||
return (
|
||||
<SkipWelcome
|
||||
workspaceId={signal.workspaceId}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Malformed signal (e.g. choice === "runtime" with no runtimeId) is
|
||||
// a programming error in the producer side; render nothing to avoid
|
||||
// putting the user in a stuck modal.
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime sub-template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HELPER_AGENT_NAME = "Multica Helper";
|
||||
|
||||
const HELPER_AVATAR_URL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='t' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%2323242C'/%3E%3Cstop offset='100%25' stop-color='%2313141A'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='28' fill='url(%23t)'/%3E%3Cg stroke='%23FFFFFF' stroke-width='13' stroke-linecap='round'%3E%3Cline x1='64' y1='32' x2='64' y2='96'/%3E%3Cline x1='32' y1='64' x2='96' y2='64'/%3E%3Cline x1='41.4' y1='41.4' x2='86.6' y2='86.6'/%3E%3Cline x1='86.6' y1='41.4' x2='41.4' y2='86.6'/%3E%3C/g%3E%3C/svg%3E";
|
||||
|
||||
/**
|
||||
* Module-level dedupe for in-flight Helper setup. Keyed on
|
||||
* workspaceId+runtimeId so unrelated welcome flows (different workspaces /
|
||||
* runtimes) don't collide, but React 18+ StrictMode dev-mode double-mount
|
||||
* of the same flow shares one promise — both mounts await the same
|
||||
* listAgents/createAgent round-trip, so we never race-create two Helpers
|
||||
* (server-side UNIQUE (workspace_id, name) would 409 the second attempt
|
||||
* anyway, but the rejected promise would surface a misleading "agent
|
||||
* already exists" error UI in dev).
|
||||
*
|
||||
* `useRef` cannot solve this because each StrictMode mount gets a fresh
|
||||
* React component instance — and therefore a fresh ref.
|
||||
*/
|
||||
const pendingHelperSetup = new Map<string, Promise<Agent>>();
|
||||
|
||||
async function findOrCreateHelper(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
language: string | null,
|
||||
): Promise<Agent> {
|
||||
const key = `${workspaceId}:${runtimeId}`;
|
||||
const existing = pendingHelperSetup.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = (async (): Promise<Agent> => {
|
||||
const agents = await api.listAgents({ workspace_id: workspaceId });
|
||||
const found = agents.find(
|
||||
(a) =>
|
||||
a.name === HELPER_AGENT_NAME &&
|
||||
a.visibility === "workspace" &&
|
||||
!a.archived_at,
|
||||
);
|
||||
if (found) return found;
|
||||
const lang = pickContentLang(language);
|
||||
return api.createAgent({
|
||||
name: HELPER_AGENT_NAME,
|
||||
description: HELPER_DESCRIPTION[lang],
|
||||
instructions: HELPER_INSTRUCTIONS[lang],
|
||||
avatar_url: HELPER_AVATAR_URL,
|
||||
runtime_id: runtimeId,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 6,
|
||||
template: "multica_helper",
|
||||
});
|
||||
})();
|
||||
|
||||
pendingHelperSetup.set(key, promise);
|
||||
// Clear on settle so a manual Retry after a failure runs a fresh probe
|
||||
// rather than re-awaiting the rejected promise. .catch on the cleanup
|
||||
// chain silences unhandled-rejection noise — the original `promise` we
|
||||
// return still propagates rejections to the caller.
|
||||
promise
|
||||
.finally(() => {
|
||||
if (pendingHelperSetup.get(key) === promise) {
|
||||
pendingHelperSetup.delete(key);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level dedupe for skip-path seed issues. Same StrictMode rationale
|
||||
* as `findOrCreateHelper`: each mount has its own useRef, so without a
|
||||
* cross-mount cache the dev double-mount would race two CreateIssue calls
|
||||
* for the same title — the server-side LockAndFindActiveDuplicate gate
|
||||
* returns 409 on the second one, but a 409 in the catch block populates
|
||||
* the per-card error state with a misleading message ("an issue with this
|
||||
* title already exists") even though the issue WAS created.
|
||||
*
|
||||
* Keyed on `${workspaceId}:${title}` because the seed identity is the
|
||||
* issue title (server's duplicate dedupe also keys on title).
|
||||
*/
|
||||
const pendingIssueSeed = new Map<string, Promise<Issue>>();
|
||||
|
||||
function seedIssueDeduped(
|
||||
cacheKey: string,
|
||||
body: CreateIssueRequest,
|
||||
): Promise<Issue> {
|
||||
const existing = pendingIssueSeed.get(cacheKey);
|
||||
if (existing) return existing;
|
||||
const promise = api.createIssue(body);
|
||||
pendingIssueSeed.set(cacheKey, promise);
|
||||
// .finally() returns a NEW promise that mirrors the rejection of the
|
||||
// original. Silence it with .catch so the cleanup chain never surfaces
|
||||
// as an unhandled rejection — the original `promise` we return still
|
||||
// carries the rejection to the caller's await.
|
||||
promise
|
||||
.finally(() => {
|
||||
if (pendingIssueSeed.get(cacheKey) === promise) {
|
||||
pendingIssueSeed.delete(cacheKey);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same module-level dedup pattern for the follow-up comment on the
|
||||
* skip-path install-runtime issue (linking to the create-agent-guide
|
||||
* issue). Keeps the two StrictMode mounts from posting the comment twice.
|
||||
*/
|
||||
const pendingCommentSeed = new Map<string, Promise<unknown>>();
|
||||
|
||||
function postCommentDeduped(
|
||||
cacheKey: string,
|
||||
issueId: string,
|
||||
content: string,
|
||||
): Promise<unknown> {
|
||||
const existing = pendingCommentSeed.get(cacheKey);
|
||||
if (existing) return existing;
|
||||
const promise = api.createComment(issueId, content);
|
||||
pendingCommentSeed.set(cacheKey, promise);
|
||||
promise
|
||||
.finally(() => {
|
||||
if (pendingCommentSeed.get(cacheKey) === promise) {
|
||||
pendingCommentSeed.delete(cacheKey);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
interface RuntimeWelcomeProps {
|
||||
workspaceId: string;
|
||||
runtimeId: string;
|
||||
/** User explicitly gave up after a fatal failure — clear the welcome
|
||||
* signal so we don't re-show the error veil. */
|
||||
onAbandon: () => void;
|
||||
/** Happy-path completion: user picked a starter card, issue is created,
|
||||
* we're about to navigate. Clear the welcome signal so navigating back
|
||||
* to /issues doesn't re-trigger the modal. */
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function RuntimeWelcome({
|
||||
workspaceId,
|
||||
runtimeId,
|
||||
onAbandon,
|
||||
onComplete,
|
||||
}: RuntimeWelcomeProps) {
|
||||
// i18n.language is the LIVE runtime locale, kept in sync with both the
|
||||
// browser fallback and the user's saved `me.language` preference (via
|
||||
// user-locale-sync). Reading me.language directly would miss anonymous
|
||||
// new users whose preference field is still null but whose browser is
|
||||
// in Chinese — the agent instructions and seeded issue body should
|
||||
// follow what they're already reading.
|
||||
const { t, i18n } = useT("onboarding");
|
||||
const navigation = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
// The parent `WelcomeAfterOnboarding` already gated on `me` being
|
||||
// non-null, but we re-read the store here instead of threading it
|
||||
// through props so the questionnaire stays in sync if the user
|
||||
// refreshes their profile mid-flow (PATCH /api/me returns the updated
|
||||
// row and the store is reactive). Used to enrich starter issue
|
||||
// descriptions with a `> About me` block.
|
||||
const me = useAuthStore((s) => s.user);
|
||||
|
||||
// Phase machine: "preparing" (loading veil) → "ready" (modal with agent
|
||||
// + selectable cards) → submitting (cards locked, bottom CTA spins) →
|
||||
// success path navigates away; failure path surfaces an error string.
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [prepError, setPrepError] = useState<Error | null>(null);
|
||||
const [attemptKey, setAttemptKey] = useState(0);
|
||||
|
||||
// Multi-select of starter cards. Empty Set = nothing selected → CTA
|
||||
// disabled. User toggles by clicking; no checkmark icon — we lean on
|
||||
// the border + ring pattern already used by `compact-runtime-row.tsx`
|
||||
// for visual selection state.
|
||||
const [selected, setSelected] = useState<Set<StarterCardId>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const submitInFlightRef = useRef(false);
|
||||
// After Promise.all(createIssue × N) resolves we DON'T navigate
|
||||
// immediately. We park the first issue id here and switch the Modal to
|
||||
// a success state ("you're all set, agent is on it, here's how to
|
||||
// check in via Inbox / chat") — gives the user a moment to register
|
||||
// what just happened + surfaces two features they're likely to miss.
|
||||
// Got it on the success view is what finally dismisses + navigates.
|
||||
const [successIssueId, setSuccessIssueId] = useState<string | null>(null);
|
||||
|
||||
// Resolve the role / use_case enum slugs to human-readable labels in
|
||||
// the user's current locale, then build the markdown block that gets
|
||||
// appended to every starter issue description. Memoized on t +
|
||||
// i18n.language so a language switch refreshes everything in one
|
||||
// re-render; bundle is rebuilt whenever the questionnaire row changes.
|
||||
const userContextLabels: UserContextLabels = useMemo(() => {
|
||||
const lang = pickContentLang(i18n.language);
|
||||
return {
|
||||
heading: t(($) => $.welcome_after_onboarding.user_context_heading),
|
||||
roleLabel: t(($) => $.welcome_after_onboarding.user_context_role_label),
|
||||
useCaseLabel: t(
|
||||
($) => $.welcome_after_onboarding.user_context_use_case_label,
|
||||
),
|
||||
listSeparator: lang === "zh" ? "、" : ", ",
|
||||
role: {
|
||||
engineer: t(($) => $.questions.role.engineer),
|
||||
product: t(($) => $.questions.role.product),
|
||||
designer: t(($) => $.questions.role.designer),
|
||||
founder: t(($) => $.questions.role.founder),
|
||||
marketing: t(($) => $.questions.role.marketing),
|
||||
writer: t(($) => $.questions.role.writer),
|
||||
research: t(($) => $.questions.role.research),
|
||||
ops: t(($) => $.questions.role.ops),
|
||||
student: t(($) => $.questions.role.student),
|
||||
other: t(($) => $.questions.role.other),
|
||||
},
|
||||
useCase: {
|
||||
ship_code: t(($) => $.questions.use_case.ship_code),
|
||||
manage_team: t(($) => $.questions.use_case.manage_team),
|
||||
personal_tasks: t(($) => $.questions.use_case.personal_tasks),
|
||||
plan_research: t(($) => $.questions.use_case.plan_research),
|
||||
write_publish: t(($) => $.questions.use_case.write_publish),
|
||||
automate_ops: t(($) => $.questions.use_case.automate_ops),
|
||||
evaluate: t(($) => $.questions.use_case.evaluate),
|
||||
other: t(($) => $.questions.use_case.other),
|
||||
},
|
||||
};
|
||||
}, [t, i18n.language]);
|
||||
const toggle = (id: StarterCardId) => {
|
||||
if (submitting) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Find-or-create Helper agent. Dedupe lives in `findOrCreateHelper`
|
||||
// (module-level promise cache) so StrictMode double-mounts share one
|
||||
// in-flight request. `attemptKey` increments on manual Retry to force
|
||||
// the effect to re-run.
|
||||
useEffect(() => {
|
||||
if (agent) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const a = await findOrCreateHelper(workspaceId, runtimeId, i18n.language);
|
||||
if (!cancelled) {
|
||||
setAgent(a);
|
||||
qc.invalidateQueries({
|
||||
queryKey: workspaceKeys.agents(workspaceId),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPrepError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent, attemptKey, i18n.language, qc, runtimeId, workspaceId]);
|
||||
|
||||
// Phase 1: error surface for the prepare step.
|
||||
if (prepError) {
|
||||
return (
|
||||
<FullScreenError
|
||||
title={t(($) => $.welcome_after_onboarding.error_title)}
|
||||
message={
|
||||
prepError.message ||
|
||||
t(($) => $.welcome_after_onboarding.error_generic)
|
||||
}
|
||||
retryLabel={t(($) => $.welcome_after_onboarding.retry)}
|
||||
closeLabel={t(($) => $.welcome_after_onboarding.error_close)}
|
||||
onRetry={() => {
|
||||
setPrepError(null);
|
||||
setAttemptKey((n) => n + 1);
|
||||
}}
|
||||
onClose={onAbandon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 1: loading veil while we set up the agent.
|
||||
if (!agent) {
|
||||
return (
|
||||
<FullScreenLoading
|
||||
label={t(($) => $.welcome_after_onboarding.loading_helper)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2: blocking modal with starter cards.
|
||||
const lang = pickContentLang(i18n.language);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (submitInFlightRef.current || selected.size === 0) return;
|
||||
submitInFlightRef.current = true;
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Build the per-task "about me" block once and append it to every
|
||||
// starter issue. The block is empty markdown ("") when the user
|
||||
// skipped both questions, so unconditional concat is safe — Helper
|
||||
// sees the original prompt verbatim with no trailing context.
|
||||
const userContext = buildUserContextSection(
|
||||
me?.onboarding_questionnaire,
|
||||
userContextLabels,
|
||||
);
|
||||
// Create issues in declared order (STARTER_CARD_IDS), so the user
|
||||
// lands on the most foundational task first when there's a tie. We
|
||||
// run them in parallel — Helper's max_concurrent_tasks lets it pick
|
||||
// them up immediately, and one failure of N shouldn't gate the
|
||||
// others.
|
||||
const orderedIds = STARTER_CARD_IDS.filter((id) => selected.has(id));
|
||||
const issues = await Promise.all(
|
||||
orderedIds.map((id) => {
|
||||
const card = HELPER_STARTER_PROMPTS[id];
|
||||
return api.createIssue({
|
||||
title: card.title[lang],
|
||||
description: card.prompt[lang] + userContext,
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assignee_type: "agent",
|
||||
assignee_id: agent.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(workspaceId) }),
|
||||
qc.invalidateQueries({
|
||||
queryKey: workspaceKeys.agents(workspaceId),
|
||||
}),
|
||||
]);
|
||||
// Issues created — switch the Modal to its success state. We
|
||||
// navigate only when the user explicitly clicks Got it (handled in
|
||||
// handleGotIt below) so they see the inbox / chat hint.
|
||||
submitInFlightRef.current = false;
|
||||
setSubmitting(false);
|
||||
setSuccessIssueId(issues[0]!.id);
|
||||
} catch (err) {
|
||||
submitInFlightRef.current = false;
|
||||
setSubmitting(false);
|
||||
setSubmitError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.welcome_after_onboarding.error_generic),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGotIt = async () => {
|
||||
if (!successIssueId) return;
|
||||
onComplete();
|
||||
const slug = await resolveWorkspaceSlug(qc, workspaceId);
|
||||
navigation.push(paths.workspace(slug).issueDetail(successIssueId));
|
||||
};
|
||||
|
||||
if (successIssueId) {
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
modal={true}
|
||||
disablePointerDismissal={true}
|
||||
onOpenChange={() => {
|
||||
/* blocking until Got it */
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-md sm:max-w-md"
|
||||
aria-describedby="welcome-after-onboarding-runtime-success-subtitle"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 pt-4">
|
||||
<div
|
||||
className="text-4xl animate-welcome-emoji-pop"
|
||||
aria-hidden
|
||||
>
|
||||
🎉
|
||||
</div>
|
||||
<DialogTitle className="text-center text-2xl font-semibold">
|
||||
{t(($) => $.welcome_after_onboarding.runtime.success.title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
id="welcome-after-onboarding-runtime-success-subtitle"
|
||||
className="text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
($) => $.welcome_after_onboarding.runtime.success.subtitle,
|
||||
{ agentName: agent.name },
|
||||
)}
|
||||
</DialogDescription>
|
||||
<div className="mt-1 flex flex-col gap-1.5 max-w-sm">
|
||||
<p className="text-center text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{t(
|
||||
($) => $.welcome_after_onboarding.runtime.success.tip_inbox,
|
||||
)}
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{t(
|
||||
($) => $.welcome_after_onboarding.runtime.success.tip_chat,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button size="lg" onClick={handleGotIt}>
|
||||
{t(($) => $.welcome_after_onboarding.runtime.success.got_it)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
modal={true}
|
||||
disablePointerDismissal={true}
|
||||
onOpenChange={() => {
|
||||
/* runtime path is blocking by design */
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-xl sm:max-w-xl"
|
||||
aria-describedby="welcome-after-onboarding-runtime-subtitle"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 pt-4 animate-onboarding-enter">
|
||||
<img
|
||||
src={agent.avatar_url || HELPER_AVATAR_URL}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="h-14 w-14 rounded-xl ring-1 ring-foreground/10"
|
||||
/>
|
||||
<DialogTitle className="text-center text-xl font-semibold">
|
||||
{t(($) => $.welcome_after_onboarding.runtime.greeting)}
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
id="welcome-after-onboarding-runtime-subtitle"
|
||||
className="text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{t(($) => $.welcome_after_onboarding.runtime.subtitle)}
|
||||
</DialogDescription>
|
||||
<p className="text-center text-sm text-muted-foreground max-w-md leading-relaxed">
|
||||
{t(($) => $.welcome_after_onboarding.runtime.capabilities)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<p className="mb-3 text-sm font-medium text-foreground">
|
||||
{t(($) => $.welcome_after_onboarding.runtime.section_label)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{STARTER_CARD_IDS.map((id) => {
|
||||
const isSelected = selected.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => toggle(id)}
|
||||
disabled={submitting}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border bg-card px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-primary ring-1 ring-primary"
|
||||
: "hover:border-foreground/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
{HELPER_STARTER_PROMPTS[id].title[lang]}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground leading-snug">
|
||||
{t(
|
||||
($) =>
|
||||
$.welcome_after_onboarding.runtime.cards[id]
|
||||
.subtitle,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
disabled={selected.size === 0 || submitting}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
{submitting && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{selected.size === 0
|
||||
? t(($) => $.welcome_after_onboarding.runtime.assign_empty)
|
||||
: t(($) => $.welcome_after_onboarding.runtime.assign_count, {
|
||||
count: selected.size,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<p>{submitError}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-6 px-2 text-xs"
|
||||
onClick={() => setSubmitError(null)}
|
||||
>
|
||||
{t(($) => $.welcome_after_onboarding.dismiss_error)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skip sub-template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkipBundle {
|
||||
installIssueId: string;
|
||||
agentGuideId: string;
|
||||
}
|
||||
|
||||
interface SkipWelcomeProps {
|
||||
workspaceId: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip-path welcome. v3 product decision (see /Users/qingnaiyuan/.claude/plans):
|
||||
*
|
||||
* 1. Full-screen loading veil while we provision EVERYTHING in a fixed
|
||||
* sequence — create-agent-guide issue → install-runtime issue →
|
||||
* follow-up comment on the install-runtime issue linking to the
|
||||
* create-agent-guide identifier.
|
||||
* 2. Only after the whole chain succeeds do we mount the celebration
|
||||
* Modal. Cards inside the Modal are pure display — no interactive
|
||||
* per-card state, no spinners, no error boundaries.
|
||||
* 3. If ANY step fails we silently dismiss; the user lands on the
|
||||
* issues list and finds whatever subset of issues did get created.
|
||||
* Onboarded_at is already set by Step 3, so this isn't blocking.
|
||||
* 4. The Modal has a single primary action [Got it] which dismisses and
|
||||
* navigates to the install-runtime issue. Closing via X or outside
|
||||
* click dismisses but doesn't navigate.
|
||||
*
|
||||
* Why provision-then-show instead of show-with-loading: the previous
|
||||
* design had cards with their own spinners and retry buttons, which made
|
||||
* the Modal feel like a busy task list rather than a celebration of
|
||||
* finishing onboarding. The product brief asked for emotional value.
|
||||
*/
|
||||
function SkipWelcome({ workspaceId, onDismiss }: SkipWelcomeProps) {
|
||||
// i18n.language is the LIVE runtime locale (browser fallback + saved
|
||||
// me.language). Reading me.language directly would miss new users
|
||||
// whose preference field is still null but who are browsing in Chinese.
|
||||
const { t, i18n } = useT("onboarding");
|
||||
const navigation = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
const me = useAuthStore((s) => s.user);
|
||||
|
||||
const [bundle, setBundle] = useState<SkipBundle | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
// Provision sequence — runs once on mount. StrictMode double-mount is
|
||||
// safe because each step uses module-level dedup (seedIssueDeduped /
|
||||
// postCommentDeduped) keyed on `${workspaceId}:<purpose>`.
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
if (bundle || failed) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const lang = pickContentLang(i18n.language);
|
||||
// Order matters — each step's input depends on the previous step's
|
||||
// output (issue identifier / uuid for cross-reference mention
|
||||
// chips). Module-level dedupes (seedIssueDeduped /
|
||||
// postCommentDeduped) make StrictMode double-mounts share the same
|
||||
// in-flight promise per step.
|
||||
//
|
||||
// 1. install-runtime first (body is a static const, no deps).
|
||||
// Step 1 of the bundle, in_progress so the user sees it's the
|
||||
// active next step.
|
||||
const installRuntime = await seedIssueDeduped(
|
||||
`${workspaceId}:install-runtime`,
|
||||
{
|
||||
title: INSTALL_RUNTIME_ISSUE_TITLE[lang],
|
||||
description: INSTALL_RUNTIME_ISSUE_BODY[lang],
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assignee_type: "member",
|
||||
assignee_id: me.id,
|
||||
},
|
||||
);
|
||||
// 2. agent-guide. Body is composed at call-time so it can embed
|
||||
// a mention chip pointing back at install-runtime — the user
|
||||
// reads agent-guide and can jump to its prerequisite with one
|
||||
// click.
|
||||
const agentGuide = await seedIssueDeduped(
|
||||
`${workspaceId}:create-agent-guide`,
|
||||
{
|
||||
title: CREATE_AGENT_GUIDE_ISSUE_TITLE[lang],
|
||||
description: getCreateAgentGuideBody({
|
||||
lang,
|
||||
installRuntimeIdentifier: installRuntime.identifier,
|
||||
installRuntimeId: installRuntime.id,
|
||||
}),
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee_type: "member",
|
||||
assignee_id: me.id,
|
||||
},
|
||||
);
|
||||
// 3. follow-up comment on install-runtime pointing at agent-guide
|
||||
// via the same mention-chip protocol (renders as the styled
|
||||
// IssueChip pill). Prefix is a TS const — anything that gets
|
||||
// persisted to DB must not depend on the i18n bundle (stale
|
||||
// bundle would write raw key text into comment.content
|
||||
// permanently).
|
||||
const prefix = FOLLOWUP_COMMENT_PREFIX[lang];
|
||||
const commentText = `${prefix} [${agentGuide.identifier}](mention://issue/${agentGuide.id})`;
|
||||
await postCommentDeduped(
|
||||
`${workspaceId}:install-runtime-followup`,
|
||||
installRuntime.id,
|
||||
commentText,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(workspaceId) });
|
||||
if (!cancelled) {
|
||||
setBundle({
|
||||
installIssueId: installRuntime.id,
|
||||
agentGuideId: agentGuide.id,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bundle, failed, i18n.language, me, qc, t, workspaceId]);
|
||||
|
||||
// Failure path: dismiss the welcome signal so the next render returns
|
||||
// null. We don't surface an error UI — the user is already onboarded;
|
||||
// anything that did get created is visible in the issues list.
|
||||
useEffect(() => {
|
||||
if (failed) onDismiss();
|
||||
}, [failed, onDismiss]);
|
||||
|
||||
if (failed || !me) return null;
|
||||
|
||||
if (!bundle) {
|
||||
return (
|
||||
<FullScreenLoading
|
||||
label={t(($) => $.welcome_after_onboarding.skip.loading)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGotIt = async () => {
|
||||
// Dismiss BEFORE navigating so the destination doesn't re-render
|
||||
// the Modal from the (still-set) welcome-store signal.
|
||||
onDismiss();
|
||||
const slug = await resolveWorkspaceSlug(qc, workspaceId);
|
||||
navigation.push(paths.workspace(slug).issueDetail(bundle.installIssueId));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
modal={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onDismiss();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-xl sm:max-w-xl"
|
||||
aria-describedby="welcome-after-onboarding-skip-subtitle"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 pt-6">
|
||||
<div className="text-6xl animate-welcome-emoji-pop" aria-hidden>
|
||||
🎉
|
||||
</div>
|
||||
<DialogTitle className="text-center text-2xl font-semibold">
|
||||
{t(($) => $.welcome_after_onboarding.skip.title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
id="welcome-after-onboarding-skip-subtitle"
|
||||
className="text-center text-sm text-muted-foreground max-w-md"
|
||||
>
|
||||
{t(($) => $.welcome_after_onboarding.skip.subtitle)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<SkipPreviewCard
|
||||
cardKey="install_runtime"
|
||||
statusLabel={t(
|
||||
($) => $.welcome_after_onboarding.skip.status_in_progress,
|
||||
)}
|
||||
statusTone="active"
|
||||
/>
|
||||
<SkipPreviewCard
|
||||
cardKey="create_agent"
|
||||
statusLabel={t(($) => $.welcome_after_onboarding.skip.status_todo)}
|
||||
statusTone="todo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button size="lg" onClick={handleGotIt}>
|
||||
{t(($) => $.welcome_after_onboarding.skip.got_it)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure display card for the celebration Modal. No interaction — the
|
||||
* single CTA at the bottom of the Modal is the only way to advance.
|
||||
*/
|
||||
function SkipPreviewCard({
|
||||
cardKey,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
}: {
|
||||
cardKey: "install_runtime" | "create_agent";
|
||||
statusLabel: string;
|
||||
statusTone: "active" | "todo";
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-background px-3 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
{t(($) => $.welcome_after_onboarding.skip.cards[cardKey].title)}
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
statusTone === "active"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-snug">
|
||||
{t(($) => $.welcome_after_onboarding.skip.cards[cardKey].subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FullScreenLoading({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullScreenError({
|
||||
title,
|
||||
message,
|
||||
retryLabel,
|
||||
closeLabel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
retryLabel: string;
|
||||
closeLabel: string;
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex max-w-md flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-md">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<p className="text-center text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground">{message}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{closeLabel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve a workspace slug from the cache (the workspace list query is
|
||||
// always pre-warmed by the layout). Falls back to a network fetch when
|
||||
// the cache is cold.
|
||||
async function resolveWorkspaceSlug(
|
||||
qc: ReturnType<typeof useQueryClient>,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const cached = qc
|
||||
.getQueriesData<{ id: string; slug: string }[] | undefined>({
|
||||
queryKey: workspaceKeys.list(),
|
||||
})
|
||||
.map(([, data]) => data)
|
||||
.find(Boolean);
|
||||
const hit = cached?.find((w) => w.id === workspaceId);
|
||||
if (hit) return hit.slug;
|
||||
const ws = await api.getWorkspace(workspaceId);
|
||||
return ws.slug;
|
||||
}
|
||||
@@ -308,9 +308,15 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Patch("/api/me", h.UpdateMe)
|
||||
r.Patch("/api/me/onboarding", h.PatchOnboarding)
|
||||
r.Post("/api/me/onboarding/complete", h.CompleteOnboarding)
|
||||
r.Post("/api/me/onboarding/cloud-waitlist", h.JoinCloudWaitlist)
|
||||
// DEPRECATED — shim routes for desktop < v3 during the rollout
|
||||
// window. v3 frontend creates the Helper agent + starter issue
|
||||
// via generic CreateAgent / CreateIssue and only calls /complete
|
||||
// here. Remove once X-Client-Version telemetry confirms zero
|
||||
// pre-v3 desktops are still calling these. Handlers live in
|
||||
// server/internal/handler/onboarding_shim.go.
|
||||
r.Post("/api/me/onboarding/runtime-bootstrap", h.BootstrapOnboardingRuntime)
|
||||
r.Post("/api/me/onboarding/no-runtime-bootstrap", h.BootstrapOnboardingNoRuntime)
|
||||
r.Post("/api/me/onboarding/cloud-waitlist", h.JoinCloudWaitlist)
|
||||
r.Post("/api/cli-token", h.IssueCliToken)
|
||||
r.Post("/api/upload-file", h.UploadFile)
|
||||
r.Post("/api/feedback", h.CreateFeedback)
|
||||
|
||||
@@ -393,16 +393,29 @@ func TeamInviteAccepted(inviteeID, workspaceID string, daysSinceInvite int64) Ev
|
||||
// The handler drives this transition — we emit from PatchOnboarding so
|
||||
// the single emission site stays honest even if the frontend retries.
|
||||
//
|
||||
// `source` and `useCase` are multi-select (users can pick several);
|
||||
// `role` stays single-select. Empty slice = no answer (skip is
|
||||
// captured separately via the *Skipped booleans).
|
||||
//
|
||||
// The three answers are also mirrored into person properties via $set
|
||||
// so cohorting by source / role / use_case works across every event
|
||||
// on the same user without re-joining back to the DB.
|
||||
// on the same user without re-joining back to the DB. PostHog accepts
|
||||
// array property values; breakdowns on a multi-value property treat
|
||||
// each element as a separate group.
|
||||
//
|
||||
// `*Skipped` booleans capture per-question skip intent (the new v2
|
||||
// signal). `*HasOther` are presence booleans for the free-text "other"
|
||||
// override; the free-text content is kept in the DB for product
|
||||
// research but not broadcast via analytics (PII risk + low cardinality
|
||||
// ask).
|
||||
func OnboardingQuestionnaireSubmitted(userID, source, role, useCase string, sourceSkipped, roleSkipped, useCaseSkipped, sourceHasOther, roleHasOther, useCaseHasOther bool) Event {
|
||||
// `*Skipped` booleans capture per-question skip intent. `*HasOther`
|
||||
// are presence booleans for the free-text "other" override; the
|
||||
// free-text content is kept in the DB for product research but not
|
||||
// broadcast via analytics (PII risk + low cardinality ask).
|
||||
func OnboardingQuestionnaireSubmitted(userID string, source []string, role string, useCase []string, sourceSkipped, roleSkipped, useCaseSkipped, sourceHasOther, roleHasOther, useCaseHasOther bool) Event {
|
||||
// Normalize nil slices to [] so PostHog property values are stable
|
||||
// (avoids null vs [] mixing in property type inference).
|
||||
if source == nil {
|
||||
source = []string{}
|
||||
}
|
||||
if useCase == nil {
|
||||
useCase = []string{}
|
||||
}
|
||||
return Event{
|
||||
Name: EventOnboardingQuestionnaireSubmit,
|
||||
DistinctID: userID,
|
||||
|
||||
@@ -426,35 +426,22 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Accepting an invite is the physical event that "completes" onboarding for an
|
||||
// invitee — atomic with CreateMember so the invariant
|
||||
// "member row exists ↔ onboarded_at != null" cannot be violated.
|
||||
// COALESCE in MarkUserOnboarded keeps this idempotent for users joining
|
||||
// Accepting an invite marks the invitee as onboarded. The web /
|
||||
// desktop workspace layout has a hard onboarded_at gate; without
|
||||
// this mark, an invitee landing on their first workspace would be
|
||||
// redirected back to /onboarding to fill out a questionnaire for a
|
||||
// workspace someone else already set up. Atomic with CreateMember so
|
||||
// `member` and `onboarded_at` can never disagree. COALESCE in
|
||||
// MarkUserOnboarded keeps the call idempotent for users joining
|
||||
// additional workspaces after their first.
|
||||
firstOnboardingCompletion := !user.OnboardedAt.Valid
|
||||
onboardedUser, err := qtx.MarkUserOnboarded(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
slog.Warn("accept invitation: mark user onboarded failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(accepted.WorkspaceID))...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark user onboarded")
|
||||
return
|
||||
}
|
||||
|
||||
// Seed an install-runtime issue if the workspace has no runtime yet, so
|
||||
// the invitee lands on a concrete next step rather than an empty list.
|
||||
// claimStarterContentStateIfUnset keeps older desktop builds from showing
|
||||
// the legacy import dialog (rendered when this column is NULL).
|
||||
seededIssue, seededIssueCreated, err := ensureNoRuntimeOnboardingIssue(
|
||||
r.Context(), qtx, accepted.WorkspaceID, user.ID, onboardedUser.Language,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("accept invitation: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(accepted.WorkspaceID))...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue")
|
||||
return
|
||||
}
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, user.ID, onboardedUser.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
|
||||
return
|
||||
@@ -478,21 +465,6 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
"member": memberResp,
|
||||
})
|
||||
|
||||
if seededIssueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), seededIssue.WorkspaceID)
|
||||
issueResp := issueToResponse(seededIssue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, wsID, "member", userID, map[string]any{"issue": issueResp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
wsID,
|
||||
uuidToString(seededIssue.ID),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
|
||||
// days_since_invite rounds down to whole days so the funnel segments
|
||||
// "accepted same day" cleanly from "accepted later". inv.CreatedAt is
|
||||
// the invitation row's insertion time so this is safe to compute here.
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/issueguard"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// noRuntimeIssueTitle and noRuntimeIssueDescription are the canonical
|
||||
// "install your first runtime" issue. The text lives here so every mark-
|
||||
// onboarded entry point (BootstrapOnboardingNoRuntime, CompleteOnboarding,
|
||||
// CreateWorkspace, AcceptInvitation) seeds the same body.
|
||||
const noRuntimeIssueTitle = "Connect a runtime to start using agents"
|
||||
|
||||
func noRuntimeIssueDescription(language pgtype.Text) string {
|
||||
if language.Valid && strings.HasPrefix(language.String, "zh") {
|
||||
return zhNoRuntimeIssueDescription()
|
||||
}
|
||||
return enNoRuntimeIssueDescription()
|
||||
}
|
||||
|
||||
func enNoRuntimeIssueDescription() string {
|
||||
return strings.Join([]string{
|
||||
"Welcome to Multica.",
|
||||
"",
|
||||
"Agents need a runtime before they can execute work. You can still use Multica as a lightweight project-management workspace while you install one.",
|
||||
"",
|
||||
"## Try Multica first",
|
||||
"",
|
||||
"Before the runtime is ready, you can:",
|
||||
"",
|
||||
"1. Create a project for your current work.",
|
||||
"2. Create a few issues and move them across backlog, todo, in_progress, and done.",
|
||||
"3. Add priorities, labels, comments, and subscriptions.",
|
||||
"4. Use Inbox to track assignments and mentions.",
|
||||
"",
|
||||
"That gives you the project-management layer first. Once a runtime is connected, agents can start working from the same issues.",
|
||||
"",
|
||||
"## Install your first agent runtime",
|
||||
"",
|
||||
"Full guide: https://multica.ai/docs/install-agent-runtime",
|
||||
"",
|
||||
"For English users, the fastest first path is Codex:",
|
||||
"",
|
||||
"1. Make sure Node.js is installed.",
|
||||
"2. Install Codex:",
|
||||
" npm i -g @openai/codex",
|
||||
"3. Sign in:",
|
||||
" codex",
|
||||
"4. Confirm your terminal can find it:",
|
||||
" which codex",
|
||||
" codex --version",
|
||||
"5. Restart the Multica daemon:",
|
||||
" multica daemon restart",
|
||||
" If you use the desktop app, restarting the app is enough.",
|
||||
"6. Return to Runtimes and refresh. You should see a Codex runtime online.",
|
||||
"7. Create your first agent from that runtime, then assign an issue to the agent and set status to todo.",
|
||||
"",
|
||||
"Codex reference: https://developers.openai.com/codex/cli",
|
||||
"",
|
||||
"When the runtime is connected, you can create Multica Helper for a guided first run.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func zhNoRuntimeIssueDescription() string {
|
||||
return strings.Join([]string{
|
||||
"欢迎来到 Multica。",
|
||||
"",
|
||||
"智能体需要先连上运行时才能执行工作。运行时还没准备好时,你也可以先把 Multica 当作轻量项目管理工具体验起来。",
|
||||
"",
|
||||
"## 先体验项目管理功能",
|
||||
"",
|
||||
"运行时安装前,你可以先做这些事:",
|
||||
"",
|
||||
"1. 为当前工作创建一个项目。",
|
||||
"2. 新建几个 issue,并在 backlog、todo、in_progress、done 之间流转。",
|
||||
"3. 给 issue 加优先级、标签、评论和订阅。",
|
||||
"4. 用收件箱追踪分配给你的事项和 @mention。",
|
||||
"",
|
||||
"这样你先熟悉项目管理层。连上运行时后,智能体会直接在这些 issue 上开始工作。",
|
||||
"",
|
||||
"## 安装第一个 Agent 运行时",
|
||||
"",
|
||||
"完整文档:https://multica.ai/docs/install-agent-runtime",
|
||||
"",
|
||||
"中文用户建议先装 Kimi CLI:",
|
||||
"",
|
||||
"1. 在 macOS / Linux 终端安装 Kimi CLI:",
|
||||
" curl -LsSf https://code.kimi.com/install.sh | bash",
|
||||
" Windows PowerShell:",
|
||||
" Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
|
||||
"2. 确认终端能找到 Kimi:",
|
||||
" kimi --version",
|
||||
"3. 在你想让 Kimi 工作的项目目录里启动一次:",
|
||||
" kimi",
|
||||
"4. 首次启动后输入 /login,按提示完成 Kimi Code 或 API key 配置。",
|
||||
"5. 重启 Multica 守护进程:",
|
||||
" multica daemon restart",
|
||||
" 如果你用桌面端,重启 app 即可。",
|
||||
"6. 回到 Runtimes 页面刷新。你应该能看到一个在线的 Kimi 运行时。",
|
||||
"7. 用这个运行时创建第一个智能体,再把一个 issue 分配给它,并把状态切到 todo。",
|
||||
"",
|
||||
"Kimi CLI 官方文档:https://moonshotai.github.io/kimi-cli/zh/guides/getting-started.html",
|
||||
"",
|
||||
"运行时连上后,你就可以创建 Multica Helper,开始一次有智能体参与的上手引导。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// seedInstallRuntimeIssue creates the install-runtime issue, deduping against
|
||||
// existing active issues with the same title via pg_advisory_xact_lock so
|
||||
// concurrent callers can't produce two copies. Must run inside a transaction.
|
||||
func seedInstallRuntimeIssue(
|
||||
ctx context.Context,
|
||||
q *db.Queries,
|
||||
workspaceID pgtype.UUID,
|
||||
userID pgtype.UUID,
|
||||
language pgtype.Text,
|
||||
) (db.Issue, bool, error) {
|
||||
var emptyUUID pgtype.UUID
|
||||
existing, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
ctx, q, workspaceID, emptyUUID, emptyUUID, noRuntimeIssueTitle, false,
|
||||
)
|
||||
if err != nil {
|
||||
return db.Issue{}, false, err
|
||||
}
|
||||
if foundIssue {
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
issueNumber, err := q.IncrementIssueCounter(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return db.Issue{}, false, err
|
||||
}
|
||||
issue, err := q.CreateIssue(ctx, db.CreateIssueParams{
|
||||
WorkspaceID: workspaceID,
|
||||
Title: noRuntimeIssueTitle,
|
||||
Description: strOrNullText(noRuntimeIssueDescription(language)),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "member", Valid: true},
|
||||
AssigneeID: userID,
|
||||
CreatorType: "member",
|
||||
CreatorID: userID,
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return db.Issue{}, false, err
|
||||
}
|
||||
return issue, true, nil
|
||||
}
|
||||
|
||||
// ensureNoRuntimeOnboardingIssue is the side-door wrapper used by
|
||||
// CompleteOnboarding / CreateWorkspace / AcceptInvitation: it only seeds the
|
||||
// install-runtime issue when the workspace has no agent_runtime yet.
|
||||
// BootstrapOnboardingNoRuntime is the explicit "I skipped the runtime step"
|
||||
// signal and bypasses this gate via seedInstallRuntimeIssue directly.
|
||||
func ensureNoRuntimeOnboardingIssue(
|
||||
ctx context.Context,
|
||||
q *db.Queries,
|
||||
workspaceID pgtype.UUID,
|
||||
userID pgtype.UUID,
|
||||
language pgtype.Text,
|
||||
) (db.Issue, bool, error) {
|
||||
runtimes, err := q.ListAgentRuntimes(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return db.Issue{}, false, err
|
||||
}
|
||||
if len(runtimes) > 0 {
|
||||
return db.Issue{}, false, nil
|
||||
}
|
||||
return seedInstallRuntimeIssue(ctx, q, workspaceID, userID, language)
|
||||
}
|
||||
|
||||
// claimStarterContentStateIfUnset transitions starter_content_state from NULL
|
||||
// to 'imported'. Kept after the starter-kit removal so older desktop builds —
|
||||
// which still render the legacy import dialog when this column is NULL — skip
|
||||
// the dialog on accounts created after the removal.
|
||||
func claimStarterContentStateIfUnset(
|
||||
ctx context.Context,
|
||||
q *db.Queries,
|
||||
userID pgtype.UUID,
|
||||
current pgtype.Text,
|
||||
) error {
|
||||
if current.Valid {
|
||||
return nil
|
||||
}
|
||||
_, err := q.SetStarterContentState(ctx, db.SetStarterContentStateParams{
|
||||
ID: userID,
|
||||
StarterContentState: pgtype.Text{String: "imported", Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/issueguard"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// Upper bound on free-text fields. `cloudWaitlistReasonMaxLen` is a
|
||||
@@ -28,50 +26,18 @@ const (
|
||||
// minimum that keeps the door open for future fields without
|
||||
// letting a malicious user stuff the JSONB column.
|
||||
patchOnboardingBodyLimit = 16 * 1024
|
||||
|
||||
// Runtime bootstrap is just workspace_id + runtime_id, but keep a
|
||||
// separate small cap so this endpoint cannot be used as bulk storage.
|
||||
runtimeBootstrapBodyLimit = 8 * 1024
|
||||
)
|
||||
|
||||
const (
|
||||
onboardingAssistantName = "Multica Helper"
|
||||
onboardingIssueTitle = "Start here: learn Multica with Multica Helper"
|
||||
onboardingAgentTemplate = "multica_helper"
|
||||
)
|
||||
|
||||
const onboardingAssistantDescription = "Default guide for your first Multica workspace."
|
||||
|
||||
const onboardingAssistantAvatarURL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E"
|
||||
|
||||
const onboardingAssistantInstructions = `You are Multica Helper, the user's first Multica teammate. Your job is to onboard them inside the first issue.
|
||||
|
||||
When the onboarding issue starts, leave a concise first comment that:
|
||||
1. Explains that issues are where work happens in Multica.
|
||||
2. Tells the user they can reply in the thread or @mention you to continue.
|
||||
3. Asks for one concrete task they want help with.
|
||||
4. Mentions that they can create more agents and connect more runtimes later.
|
||||
|
||||
Keep the tone practical. Do not create extra issues or projects unless the user asks.`
|
||||
|
||||
const onboardingIssueDescription = `Welcome to Multica.
|
||||
|
||||
This is your guided first run. Multica Helper is assigned to this issue and will help you try the core workflow:
|
||||
|
||||
1. Read Multica Helper's first comment.
|
||||
2. Reply with something you want to build, fix, write, or plan.
|
||||
3. @mention Multica Helper when you want it to continue.
|
||||
4. Open Agents and Runtimes later when you want to customize the teammate or the computer it runs on.
|
||||
|
||||
You can close this issue when the workflow makes sense.`
|
||||
|
||||
// completeOnboardingRequest carries the client's view of which exit the
|
||||
// user took from the flow. The client is the only place that knows
|
||||
// whether Step 3's runtime connect was skipped, whether the cloud
|
||||
// waitlist form was submitted, or whether Welcome's "I've done this
|
||||
// before" path was used. Unknown/missing → OnboardingPathUnknown so
|
||||
// legacy clients still complete the flow cleanly, just without a
|
||||
// funnel-ready label.
|
||||
// user took from the flow. Used purely as an analytics dimension — server
|
||||
// state (onboarded_at) flips the same way regardless. Unknown / missing
|
||||
// → OnboardingPathUnknown so legacy clients still complete cleanly, just
|
||||
// without a funnel-ready label.
|
||||
//
|
||||
// `workspace_id` is retained for analytics enrichment; the v2 code path
|
||||
// used it to seed an install-runtime issue inside the same transaction,
|
||||
// but in v3 every workspace-content seeding lives in the frontend
|
||||
// welcome hook (see packages/views/workspace/welcome-after-onboarding.tsx).
|
||||
type completeOnboardingRequest struct {
|
||||
CompletionPath string `json:"completion_path,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
@@ -94,9 +60,10 @@ var validCompletionPaths = map[string]struct{}{
|
||||
// 200 OK (for client-side retries) but skip the event so the funnel
|
||||
// counts honest first-completion.
|
||||
//
|
||||
// When the client supplies workspace_id and the workspace has no runtime
|
||||
// yet, this also seeds the "install a runtime" issue (idempotent), so the
|
||||
// "I've done this before" / Skip exits land on a concrete next step.
|
||||
// V3 has no in-handler seeding side effect: workspace content (Helper
|
||||
// agent, starter issues, install-runtime guides) is created by the
|
||||
// frontend welcome hook via the generic CreateAgent / CreateIssue
|
||||
// endpoints. This handler does one thing: flip the field.
|
||||
func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
@@ -112,85 +79,31 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve workspace_id (if any) up front so a malformed value short-
|
||||
// circuits with 400 before we touch the DB.
|
||||
var wsUUID pgtype.UUID
|
||||
hasWorkspace := false
|
||||
// Validate workspace_id if supplied; we don't write with it, but a
|
||||
// malformed value should fail fast rather than silently land in
|
||||
// PostHog as a junk dimension.
|
||||
if req.WorkspaceID != "" {
|
||||
parsed, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsUUID = parsed
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
hasWorkspace = true
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
before, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
// Read the prior state so we can detect "was this call the one that
|
||||
// actually completed onboarding?" — MarkUserOnboarded uses COALESCE
|
||||
// and returns the preserved timestamp on repeat calls, which is not
|
||||
// the signal we need for the funnel.
|
||||
before, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !before.OnboardedAt.Valid
|
||||
|
||||
user, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
user, err := h.Queries.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
|
||||
var seededIssue db.Issue
|
||||
seeded := false
|
||||
if hasWorkspace {
|
||||
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
}); err == nil {
|
||||
seededIssue, seeded, err = ensureNoRuntimeOnboardingIssue(r.Context(), qtx, wsUUID, parseUUID(userID), before.Language)
|
||||
if err != nil {
|
||||
slog.Warn("complete onboarding: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue")
|
||||
return
|
||||
}
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), user.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
slog.Warn("complete onboarding: mark user onboarded failed", append(logger.RequestAttrs(r), "error", err)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if seeded {
|
||||
prefix := h.getIssuePrefix(r.Context(), seededIssue.WorkspaceID)
|
||||
resp := issueToResponse(seededIssue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(seededIssue.ID),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
|
||||
if firstCompletion {
|
||||
path := req.CompletionPath
|
||||
if _, ok := validCompletionPaths[path]; !ok {
|
||||
@@ -212,392 +125,66 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
// BootstrapOnboardingRuntime is the runtime-connected onboarding exit:
|
||||
// create or reuse one default helper agent, create or reuse one onboarding
|
||||
// issue assigned to it, and mark onboarding complete. The flow is
|
||||
// deliberately one issue, not a seeded project with many tasks.
|
||||
func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingRuntimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
|
||||
member, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
runtime, err := qtx.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: runtimeUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
||||
return
|
||||
}
|
||||
|
||||
agents, err := qtx.ListAgents(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
}
|
||||
isFirstAgent := len(agents) == 0
|
||||
|
||||
var assistant db.Agent
|
||||
assistantCreated := false
|
||||
// Only reuse helpers this flow could have created: name match AND
|
||||
// workspace-visible. Skipping private agents is the access-control
|
||||
// gate — a private "Multica Helper" owned by another member must not
|
||||
// be auto-assigned to the bootstrap issue, which would bypass
|
||||
// canAccessPrivateAgent and trigger a task as that private agent.
|
||||
for _, existing := range agents {
|
||||
if existing.Name == onboardingAssistantName && existing.Visibility == "workspace" {
|
||||
assistant = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assistant.ID.Valid {
|
||||
assistant, err = qtx.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: onboardingAssistantName,
|
||||
Description: onboardingAssistantDescription,
|
||||
AvatarUrl: pgtype.Text{String: onboardingAssistantAvatarURL, Valid: true},
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: []byte("{}"),
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: "workspace",
|
||||
MaxConcurrentTasks: 6,
|
||||
OwnerID: parseUUID(userID),
|
||||
Instructions: onboardingAssistantInstructions,
|
||||
CustomEnv: []byte("{}"),
|
||||
CustomArgs: []byte("[]"),
|
||||
McpConfig: nil,
|
||||
Model: pgtype.Text{},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: create assistant failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding assistant")
|
||||
return
|
||||
}
|
||||
assistantCreated = true
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(),
|
||||
qtx,
|
||||
wsUUID,
|
||||
emptyUUID,
|
||||
emptyUUID,
|
||||
onboardingIssueTitle,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: duplicate issue check failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated := false
|
||||
if !foundIssue {
|
||||
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
|
||||
return
|
||||
}
|
||||
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: onboardingIssueTitle,
|
||||
Description: strOrNullText(onboardingIssueDescription),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: assistant.ID,
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(userID),
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated = true
|
||||
}
|
||||
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if assistantCreated {
|
||||
resp := agentToResponse(assistant)
|
||||
h.publish(protocol.EventAgentCreated, req.WorkspaceID, "member", userID, map[string]any{"agent": resp})
|
||||
h.Analytics.Capture(analytics.AgentCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(assistant.ID),
|
||||
runtime.Provider,
|
||||
runtime.RuntimeMode,
|
||||
onboardingAgentTemplate,
|
||||
isFirstAgent,
|
||||
))
|
||||
}
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(issue.ID),
|
||||
uuidToString(assistant.ID),
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
analytics.OnboardingPathFull,
|
||||
onboardedAt,
|
||||
updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
AgentID: uuidToString(assistant.ID),
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// BootstrapOnboardingNoRuntime is the runtime-skipped onboarding exit:
|
||||
// create or reuse one self-serve onboarding issue and mark onboarding
|
||||
// complete. This keeps the no-runtime path focused on the single real
|
||||
// blocker instead of seeding a project full of follow-up tasks.
|
||||
func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingNoRuntimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
|
||||
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
// The user explicitly skipped the runtime step, so seed the install-
|
||||
// runtime issue regardless of any pre-existing runtime on the workspace
|
||||
// — the user's intent was "I have nothing to connect right now".
|
||||
issue, issueCreated, err := seedInstallRuntimeIssue(
|
||||
r.Context(), qtx, wsUUID, parseUUID(userID), userBefore.Language,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap no-runtime onboarding: seed install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(issue.ID),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
analytics.OnboardingPathRuntimeSkipped,
|
||||
onboardedAt,
|
||||
updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingNoRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
type patchOnboardingRequest struct {
|
||||
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
|
||||
}
|
||||
|
||||
// questionnaireAnswers mirrors the frontend's v2 `QuestionnaireAnswers`
|
||||
// shape. Each of source / role / use_case has a value, an optional
|
||||
// free-text "other" override, and a skip marker. The questionnaire is
|
||||
// "resolved" once every slot has either an answer or a skip marker;
|
||||
// the funnel event fires on the transition into that state.
|
||||
// questionnaireAnswers mirrors the frontend's `QuestionnaireAnswers`
|
||||
// shape. Source and use_case are multi-select arrays (Step 1 + Step 3
|
||||
// allow picking several); role stays single-select.
|
||||
//
|
||||
// stringOrSlice tolerates pre-multi-select rows that wrote a bare
|
||||
// string into the JSONB column — `json.Unmarshal` would otherwise
|
||||
// fail on type mismatch when reading those back.
|
||||
type stringOrSlice []string
|
||||
|
||||
func (s *stringOrSlice) UnmarshalJSON(data []byte) error {
|
||||
// Empty / null both decode to nil slice.
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
// Try array first (current shape).
|
||||
var arr []string
|
||||
if err := json.Unmarshal(data, &arr); err == nil {
|
||||
*s = arr
|
||||
return nil
|
||||
}
|
||||
// Fall back to single string (legacy shape from before this column
|
||||
// went multi-select). Empty string means "unanswered" — keep nil.
|
||||
var single string
|
||||
if err := json.Unmarshal(data, &single); err != nil {
|
||||
return err
|
||||
}
|
||||
if single == "" {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
*s = []string{single}
|
||||
return nil
|
||||
}
|
||||
|
||||
type questionnaireAnswers struct {
|
||||
Source string `json:"source"`
|
||||
SourceOther string `json:"source_other"`
|
||||
SourceSkipped bool `json:"source_skipped"`
|
||||
Role string `json:"role"`
|
||||
RoleOther string `json:"role_other"`
|
||||
RoleSkipped bool `json:"role_skipped"`
|
||||
UseCase string `json:"use_case"`
|
||||
UseCaseOther string `json:"use_case_other"`
|
||||
UseCaseSkipped bool `json:"use_case_skipped"`
|
||||
Version int `json:"version"`
|
||||
Source stringOrSlice `json:"source"`
|
||||
SourceOther string `json:"source_other"`
|
||||
SourceSkipped bool `json:"source_skipped"`
|
||||
Role string `json:"role"`
|
||||
RoleOther string `json:"role_other"`
|
||||
RoleSkipped bool `json:"role_skipped"`
|
||||
UseCase stringOrSlice `json:"use_case"`
|
||||
UseCaseOther string `json:"use_case_other"`
|
||||
UseCaseSkipped bool `json:"use_case_skipped"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (q questionnaireAnswers) sourceResolved() bool {
|
||||
return q.Source != "" || q.SourceSkipped
|
||||
return len(q.Source) > 0 || q.SourceSkipped
|
||||
}
|
||||
func (q questionnaireAnswers) roleResolved() bool {
|
||||
return q.Role != "" || q.RoleSkipped
|
||||
}
|
||||
func (q questionnaireAnswers) useCaseResolved() bool {
|
||||
return q.UseCase != "" || q.UseCaseSkipped
|
||||
return len(q.UseCase) > 0 || q.UseCaseSkipped
|
||||
}
|
||||
|
||||
// questionnaireSchemaVersion is the schema this handler understands.
|
||||
@@ -651,6 +238,7 @@ func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := h.Queries.PatchUserOnboarding(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("patch onboarding failed", append(logger.RequestAttrs(r), "error", err)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update onboarding")
|
||||
return
|
||||
}
|
||||
@@ -660,9 +248,9 @@ func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
if after.complete() && !before.complete() {
|
||||
h.Analytics.Capture(analytics.OnboardingQuestionnaireSubmitted(
|
||||
userID,
|
||||
after.Source,
|
||||
[]string(after.Source),
|
||||
after.Role,
|
||||
after.UseCase,
|
||||
[]string(after.UseCase),
|
||||
after.SourceSkipped,
|
||||
after.RoleSkipped,
|
||||
after.UseCaseSkipped,
|
||||
@@ -736,12 +324,3 @@ func (h *Handler) JoinCloudWaitlist(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
// strOrNullText converts an empty-meaning-absent string into a
|
||||
// nullable pgtype.Text. Empty -> SQL NULL; non-empty -> Valid.
|
||||
func strOrNullText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
// onboarding_shim.go — DEPRECATED endpoints kept alive for desktop < v3.
|
||||
//
|
||||
// Background: v3 moved Helper-agent creation and starter-issue seeding to
|
||||
// the frontend welcome hook (packages/views/workspace/welcome-after-onboarding.tsx),
|
||||
// which calls generic CreateAgent / CreateIssue. Pre-v3 desktop builds
|
||||
// however still call BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime
|
||||
// during their onboarding flow. Server-side removal would break those
|
||||
// users during the rollout window where v3 server is live but their
|
||||
// desktop hasn't auto-updated yet.
|
||||
//
|
||||
// These handlers are intentionally minimal copies of the pre-v3
|
||||
// implementation, condensed to inline DB calls (no OnboardingService /
|
||||
// WorkspaceContentService layer — that abstraction died with v3). They
|
||||
// remain valid until telemetry on the X-Client-Version header confirms
|
||||
// every active desktop install is on a v3+ build, at which point this
|
||||
// entire file + the two router entries + the 5 tests should be deleted.
|
||||
//
|
||||
// DO NOT add features here. DO NOT change behavior. The contract is "what
|
||||
// pre-v3 desktop expects".
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/issueguard"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// Runtime bootstrap is just workspace_id + runtime_id, but keep a separate
|
||||
// small cap so this endpoint cannot be used as bulk storage.
|
||||
const runtimeBootstrapBodyLimit = 8 * 1024
|
||||
|
||||
// maxStarterPromptLen caps the user-supplied StarterPrompt on
|
||||
// bootstrapOnboardingRuntimeRequest. The prompt becomes the seeded
|
||||
// onboarding issue's description, so it needs room for a real paragraph
|
||||
// or two without inviting bulk payload. 2 KiB matches pre-v3 cap.
|
||||
const maxStarterPromptLen = 2 * 1024
|
||||
|
||||
const (
|
||||
onboardingAssistantName = "Multica Helper"
|
||||
onboardingIssueTitle = "Start here: learn Multica with Multica Helper"
|
||||
onboardingAgentTemplate = "multica_helper"
|
||||
|
||||
// noRuntimeIssueTitle MUST match the pre-v3 service constant so
|
||||
// LockAndFindActiveDuplicate dedupes correctly across desktop versions.
|
||||
noRuntimeIssueTitle = "Connect a runtime to start using agents"
|
||||
)
|
||||
|
||||
const onboardingAssistantDescription = "Built-in workspace assistant. Answers Multica questions and runs CLI operations."
|
||||
|
||||
const onboardingAssistantAvatarURL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='t' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%2323242C'/%3E%3Cstop offset='100%25' stop-color='%2313141A'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='28' fill='url(%23t)'/%3E%3Cg stroke='%23FFFFFF' stroke-width='13' stroke-linecap='round'%3E%3Cline x1='64' y1='32' x2='64' y2='96'/%3E%3Cline x1='32' y1='64' x2='96' y2='64'/%3E%3Cline x1='41.4' y1='41.4' x2='86.6' y2='86.6'/%3E%3Cline x1='86.6' y1='41.4' x2='41.4' y2='86.6'/%3E%3C/g%3E%3C/svg%3E"
|
||||
|
||||
// onboardingAssistantInstructions is the system prompt persisted on every
|
||||
// Multica Helper agent created by this shim. Pre-v3 desktop submits a
|
||||
// starter prompt from the workspace OnboardingHelperModal; that prompt
|
||||
// becomes the issue body, while this constant becomes the agent's identity
|
||||
// block in CLAUDE.md / AGENTS.md / GEMINI.md. v3 frontend has its own
|
||||
// in-views copy of this string (`packages/views/onboarding/templates/
|
||||
// helper-instructions.ts`) — these two must stay in sync until the shim
|
||||
// is removed.
|
||||
const onboardingAssistantInstructions = `You are Multica Helper, the built-in AI assistant for this Multica workspace. Your role is to help any member use Multica better — answer questions, give advice, and execute workspace operations on their behalf.
|
||||
|
||||
## What Multica is
|
||||
|
||||
Multica is an open-source, AI-native team workspace (source: https://github.com/multica-ai/multica). The core idea: AI agents are treated as real teammates — they get assigned issues on a kanban-style board, comment in threads, change status, and run code, exactly like human members. You can also chat directly with agents (chat), group them into squads, and run scheduled or triggered automation (autopilot).
|
||||
|
||||
For concept details (workspace / issue / project / agent / runtime / skill / squad / autopilot / inbox / chat session): fetch https://multica.ai/docs via WebFetch — that's authoritative. For the "why" or implementation, fetch the GitHub repo above. Never paraphrase concepts from memory.
|
||||
|
||||
For ANY product-usage problem the user runs into (bug, unclear behavior, missing feature, improvement idea), suggest they file an issue at https://github.com/multica-ai/multica/issues — that's the official feedback channel.
|
||||
|
||||
## What you can do
|
||||
|
||||
Your toolbox is the ` + "`multica`" + ` CLI. It's already on your PATH and authenticated as the workspace owner.
|
||||
|
||||
Your full capability surface = whatever ` + "`multica --help`" + ` shows. Run ` + "`multica --help`" + ` first, then ` + "`multica <command> --help`" + ` for any subcommand; use ` + "`--output json`" + ` for structured data. The CLI is your manifest — never invent commands or flags.
|
||||
|
||||
A few things you can actually do (non-exhaustive — ` + "`--help`" + ` is the source of truth):
|
||||
- Create issues, post comments
|
||||
- Create or iterate on agents
|
||||
- Manage projects, squads, autopilots, skills, runtimes, etc.
|
||||
|
||||
## Tone
|
||||
|
||||
Be concise and direct, like a colleague. Respond in the user's language (Chinese in, Chinese out). When pointing at a UI location, name the exact path ("Settings → Agents → New"); when pointing at a doc, link to the specific page, not the homepage. Never fabricate URLs, flags, or file paths.`
|
||||
|
||||
const onboardingIssueDescription = `Welcome to Multica.
|
||||
|
||||
This is your guided first run. Multica Helper is assigned to this issue and will help you try the core workflow:
|
||||
|
||||
1. Read Multica Helper's first comment.
|
||||
2. Reply with something you want to build, fix, write, or plan.
|
||||
3. @mention Multica Helper when you want it to continue.
|
||||
4. Open Agents and Runtimes later when you want to customize the teammate or the computer it runs on.
|
||||
|
||||
You can close this issue when the workflow makes sense.`
|
||||
|
||||
type bootstrapOnboardingRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
StarterPrompt string `json:"starter_prompt,omitempty"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
// BootstrapOnboardingRuntime — DEPRECATED, kept for desktop < v3.
|
||||
//
|
||||
// Creates or reuses one "Multica Helper" agent on the supplied runtime,
|
||||
// creates or reuses one onboarding issue assigned to it, then marks the
|
||||
// user onboarded. Single transaction.
|
||||
func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingRuntimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
||||
return
|
||||
}
|
||||
req.StarterPrompt = strings.TrimSpace(req.StarterPrompt)
|
||||
if utf8.RuneCountInString(req.StarterPrompt) > maxStarterPromptLen {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("starter_prompt exceeds %d characters", maxStarterPromptLen))
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
member, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
runtime, err := qtx.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: runtimeUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
||||
return
|
||||
}
|
||||
|
||||
agents, err := qtx.ListAgents(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
}
|
||||
isFirstAgent := len(agents) == 0
|
||||
|
||||
var assistant db.Agent
|
||||
assistantCreated := false
|
||||
for _, existing := range agents {
|
||||
if existing.Name == onboardingAssistantName && existing.Visibility == "workspace" {
|
||||
assistant = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assistant.ID.Valid {
|
||||
assistant, err = qtx.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: onboardingAssistantName,
|
||||
Description: onboardingAssistantDescription,
|
||||
AvatarUrl: pgtype.Text{String: onboardingAssistantAvatarURL, Valid: true},
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: []byte("{}"),
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: "workspace",
|
||||
MaxConcurrentTasks: 6,
|
||||
OwnerID: parseUUID(userID),
|
||||
Instructions: onboardingAssistantInstructions,
|
||||
CustomEnv: []byte("{}"),
|
||||
CustomArgs: []byte("[]"),
|
||||
McpConfig: nil,
|
||||
Model: pgtype.Text{},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding (shim): create assistant failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding assistant")
|
||||
return
|
||||
}
|
||||
assistantCreated = true
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(), qtx, wsUUID, emptyUUID, emptyUUID, onboardingIssueTitle, false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding (shim): duplicate issue check failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated := false
|
||||
if !foundIssue {
|
||||
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
|
||||
return
|
||||
}
|
||||
description := onboardingIssueDescription
|
||||
if req.StarterPrompt != "" {
|
||||
description = req.StarterPrompt
|
||||
}
|
||||
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: onboardingIssueTitle,
|
||||
Description: strOrNullText(description),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: assistant.ID,
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(userID),
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding (shim): create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated = true
|
||||
}
|
||||
|
||||
// Mark onboarded. COALESCE in MarkUserOnboarded preserves the original
|
||||
// timestamp on re-entries, so this is idempotent.
|
||||
before, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !before.OnboardedAt.Valid
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
// Claim starter_content_state so pre-v3 desktop builds (which still
|
||||
// gate the legacy "starter content" import dialog on NULL) don't pop
|
||||
// it for users created by this shim path.
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), before.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if assistantCreated {
|
||||
resp := agentToResponse(assistant)
|
||||
h.publish(protocol.EventAgentCreated, req.WorkspaceID, "member", userID, map[string]any{"agent": resp})
|
||||
h.Analytics.Capture(analytics.AgentCreated(
|
||||
userID, req.WorkspaceID, uuidToString(assistant.ID),
|
||||
runtime.Provider, runtime.RuntimeMode, onboardingAgentTemplate, isFirstAgent,
|
||||
))
|
||||
}
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID, req.WorkspaceID, uuidToString(issue.ID),
|
||||
uuidToString(assistant.ID), "", "", analytics.SourceOnboarding,
|
||||
))
|
||||
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID, req.WorkspaceID, analytics.OnboardingPathFull,
|
||||
onboardedAt, updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
AgentID: uuidToString(assistant.ID),
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// BootstrapOnboardingNoRuntime — DEPRECATED, kept for desktop < v3.
|
||||
//
|
||||
// Creates or reuses one "install a runtime" guide issue (assigned to the
|
||||
// member themselves) and marks onboarding complete. The user explicitly
|
||||
// skipped the runtime step, so we unconditionally seed regardless of any
|
||||
// pre-existing runtime on the workspace.
|
||||
func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingNoRuntimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
existing, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(), qtx, wsUUID, emptyUUID, emptyUUID, noRuntimeIssueTitle, false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap no-runtime onboarding (shim): duplicate issue check failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
|
||||
var issue db.Issue
|
||||
issueCreated := false
|
||||
if foundIssue {
|
||||
issue = existing
|
||||
} else {
|
||||
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
|
||||
return
|
||||
}
|
||||
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: noRuntimeIssueTitle,
|
||||
Description: strOrNullText(noRuntimeIssueDescription(userBefore.Language)),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "member", Valid: true},
|
||||
AssigneeID: parseUUID(userID),
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(userID),
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap no-runtime onboarding (shim): create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated = true
|
||||
}
|
||||
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), userBefore.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID, req.WorkspaceID, uuidToString(issue.ID),
|
||||
"", "", "", analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID, req.WorkspaceID, analytics.OnboardingPathRuntimeSkipped,
|
||||
onboardedAt, updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingNoRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// noRuntimeIssueDescription picks the EN or ZH copy based on the user's
|
||||
// language preference. ZH selected on any "zh*" prefix (zh, zh-CN, zh-Hans).
|
||||
// Matches pre-v3 service.workspace_content.go behavior 1:1.
|
||||
func noRuntimeIssueDescription(language pgtype.Text) string {
|
||||
if language.Valid && strings.HasPrefix(language.String, "zh") {
|
||||
return zhNoRuntimeIssueDescription()
|
||||
}
|
||||
return enNoRuntimeIssueDescription()
|
||||
}
|
||||
|
||||
func enNoRuntimeIssueDescription() string {
|
||||
return strings.Join([]string{
|
||||
"Welcome to Multica.",
|
||||
"",
|
||||
"Agents need a runtime before they can execute work. You can still use Multica as a lightweight project-management workspace while you install one.",
|
||||
"",
|
||||
"## Try Multica first",
|
||||
"",
|
||||
"Before the runtime is ready, you can:",
|
||||
"",
|
||||
"1. Create a project for your current work.",
|
||||
"2. Create a few issues and move them across backlog, todo, in_progress, and done.",
|
||||
"3. Add priorities, labels, comments, and subscriptions.",
|
||||
"4. Use Inbox to track assignments and mentions.",
|
||||
"",
|
||||
"That gives you the project-management layer first. Once a runtime is connected, agents can start working from the same issues.",
|
||||
"",
|
||||
"## Install your first agent runtime",
|
||||
"",
|
||||
"Full guide: https://multica.ai/docs/install-agent-runtime",
|
||||
"",
|
||||
"For English users, the fastest first path is Codex:",
|
||||
"",
|
||||
"1. Make sure Node.js is installed.",
|
||||
"2. Install Codex:",
|
||||
" npm i -g @openai/codex",
|
||||
"3. Sign in:",
|
||||
" codex",
|
||||
"4. Confirm your terminal can find it:",
|
||||
" which codex",
|
||||
" codex --version",
|
||||
"5. Restart the Multica daemon:",
|
||||
" multica daemon restart",
|
||||
" If you use the desktop app, restarting the app is enough.",
|
||||
"6. Return to Runtimes and refresh. You should see a Codex runtime online.",
|
||||
"7. Create your first agent from that runtime, then assign an issue to the agent and set status to todo.",
|
||||
"",
|
||||
"Codex reference: https://developers.openai.com/codex/cli",
|
||||
"",
|
||||
"When the runtime is connected, you can create Multica Helper for a guided first run.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func zhNoRuntimeIssueDescription() string {
|
||||
return strings.Join([]string{
|
||||
"欢迎来到 Multica。",
|
||||
"",
|
||||
"智能体需要先连上运行时才能执行工作。运行时还没准备好时,你也可以先把 Multica 当作轻量项目管理工具体验起来。",
|
||||
"",
|
||||
"## 先体验项目管理功能",
|
||||
"",
|
||||
"运行时安装前,你可以先做这些事:",
|
||||
"",
|
||||
"1. 为当前工作创建一个项目。",
|
||||
"2. 新建几个 issue,并在 backlog、todo、in_progress、done 之间流转。",
|
||||
"3. 给 issue 加优先级、标签、评论和订阅。",
|
||||
"4. 用收件箱追踪分配给你的事项和 @mention。",
|
||||
"",
|
||||
"这样你先熟悉项目管理层。连上运行时后,智能体会直接在这些 issue 上开始工作。",
|
||||
"",
|
||||
"## 安装第一个 Agent 运行时",
|
||||
"",
|
||||
"完整文档:https://multica.ai/docs/install-agent-runtime",
|
||||
"",
|
||||
"中文用户建议先装 Kimi CLI:",
|
||||
"",
|
||||
"1. 在 macOS / Linux 终端安装 Kimi CLI:",
|
||||
" curl -LsSf https://code.kimi.com/install.sh | bash",
|
||||
" Windows PowerShell:",
|
||||
" Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
|
||||
"2. 确认终端能找到 Kimi:",
|
||||
" kimi --version",
|
||||
"3. 在你想让 Kimi 工作的项目目录里启动一次:",
|
||||
" kimi",
|
||||
"4. 首次启动后输入 /login,按提示完成 Kimi Code 或 API key 配置。",
|
||||
"5. 重启 Multica 守护进程:",
|
||||
" multica daemon restart",
|
||||
" 如果你用桌面端,重启 app 即可。",
|
||||
"6. 回到 Runtimes 页面刷新。你应该能看到一个在线的 Kimi 运行时。",
|
||||
"7. 用这个运行时创建第一个智能体,再把一个 issue 分配给它,并把状态切到 todo。",
|
||||
"",
|
||||
"Kimi CLI 官方文档:https://moonshotai.github.io/kimi-cli/zh/guides/getting-started.html",
|
||||
"",
|
||||
"运行时连上后,你就可以创建 Multica Helper,开始一次有智能体参与的上手引导。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// strOrNullText converts an empty-meaning-absent string into a nullable
|
||||
// pgtype.Text. Local helper used only by this shim file.
|
||||
func strOrNullText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// claimStarterContentStateIfUnset transitions starter_content_state from NULL
|
||||
// to 'imported'. Older desktop builds render a "starter content" import
|
||||
// dialog when this column is NULL — claiming it here suppresses the dialog
|
||||
// for users who completed onboarding through this shim path.
|
||||
//
|
||||
// Only the shim cares about this column. v3 frontend doesn't read it, and
|
||||
// the v3 CompleteOnboarding handler intentionally leaves it alone.
|
||||
func claimStarterContentStateIfUnset(
|
||||
ctx context.Context,
|
||||
q *db.Queries,
|
||||
userID pgtype.UUID,
|
||||
current pgtype.Text,
|
||||
) error {
|
||||
if current.Valid {
|
||||
return nil
|
||||
}
|
||||
_, err := q.SetStarterContentState(ctx, db.SetStarterContentStateParams{
|
||||
ID: userID,
|
||||
StarterContentState: pgtype.Text{String: "imported", Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -190,6 +190,13 @@ func TestJoinCloudWaitlistSecondCallOverwrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shim endpoint tests — guard the BootstrapOnboarding* handlers that were
|
||||
// restored for desktop < v3 compatibility. Once telemetry confirms no
|
||||
// pre-v3 desktops remain, delete both these tests AND the handlers in
|
||||
// onboarding_shim.go in the same commit.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
@@ -267,8 +274,8 @@ func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if agentRuntime != testRuntimeID {
|
||||
t.Fatalf("agent runtime = %q, want %q", agentRuntime, testRuntimeID)
|
||||
}
|
||||
if !strings.Contains(instructions, "onboard them inside the first issue") {
|
||||
t.Fatalf("assistant instructions were not seeded: %q", instructions)
|
||||
if !strings.Contains(instructions, "built-in AI assistant") {
|
||||
t.Fatalf("assistant instructions were not seeded with the new identity: %q", instructions)
|
||||
}
|
||||
if avatarURL == nil || *avatarURL != onboardingAssistantAvatarURL {
|
||||
t.Fatalf("agent avatar_url = %v, want seeded Multica Helper avatar", avatarURL)
|
||||
@@ -312,8 +319,6 @@ func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if onboardedAt == nil {
|
||||
t.Fatal("expected onboarded_at to be set")
|
||||
}
|
||||
// starter_content_state is claimed defensively so older desktop builds
|
||||
// (which still render the legacy import dialog on NULL) don't surface it.
|
||||
if starterContentState == nil || *starterContentState != "imported" {
|
||||
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
|
||||
}
|
||||
@@ -344,6 +349,138 @@ func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingRuntime_WithStarterPrompt(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `
|
||||
DELETE FROM agent_task_queue
|
||||
WHERE agent_id IN (
|
||||
SELECT id FROM agent
|
||||
WHERE workspace_id = $1 AND name = $2
|
||||
)
|
||||
`, testWorkspaceID, onboardingAssistantName)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
const wantPrompt = "Introduce Multica to me, please."
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
"runtime_id": testRuntimeID,
|
||||
"starter_prompt": wantPrompt,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp bootstrapOnboardingRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
var description *string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT description FROM issue WHERE id = $1
|
||||
`, resp.IssueID).Scan(&description); err != nil {
|
||||
t.Fatalf("lookup issue description: %v", err)
|
||||
}
|
||||
if description == nil || *description != wantPrompt {
|
||||
t.Fatalf("issue description = %v, want %q", description, wantPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingRuntime_NoStarterPrompt(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `
|
||||
DELETE FROM agent_task_queue
|
||||
WHERE agent_id IN (
|
||||
SELECT id FROM agent
|
||||
WHERE workspace_id = $1 AND name = $2
|
||||
)
|
||||
`, testWorkspaceID, onboardingAssistantName)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
"runtime_id": testRuntimeID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp bootstrapOnboardingRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
var description *string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT description FROM issue WHERE id = $1
|
||||
`, resp.IssueID).Scan(&description); err != nil {
|
||||
t.Fatalf("lookup issue description: %v", err)
|
||||
}
|
||||
if description == nil || *description != onboardingIssueDescription {
|
||||
t.Fatalf("issue description = %v, want fallback onboardingIssueDescription", description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingNoRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
|
||||
@@ -201,32 +201,13 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Becoming a workspace member is the physical event that "completes" onboarding —
|
||||
// keep this atomic with CreateMember so `member` and `onboarded_at`
|
||||
// can never disagree. COALESCE in MarkUserOnboarded keeps it idempotent.
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark user onboarded")
|
||||
return
|
||||
}
|
||||
|
||||
// Brand-new workspaces never have a runtime yet, so seed the
|
||||
// "install a runtime" issue so the user lands on a concrete next step.
|
||||
// claimStarterContentStateIfUnset suppresses the legacy starter-content
|
||||
// dialog on older desktop builds that still render it when the column
|
||||
// is NULL.
|
||||
seededIssue, seededIssueCreated, err := ensureNoRuntimeOnboardingIssue(
|
||||
r.Context(), qtx, ws.ID, parseUUID(userID), updatedUser.Language,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("create workspace: ensure install-runtime issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(ws.ID))...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to seed onboarding issue")
|
||||
return
|
||||
}
|
||||
if err := claimStarterContentStateIfUnset(r.Context(), qtx, parseUUID(userID), updatedUser.StarterContentState); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
// NOTE: CreateWorkspace deliberately does NOT mark the user as
|
||||
// onboarded. The `onboarded_at` flag is owned by CompleteOnboarding
|
||||
// (Step 3 of the flow) and by AcceptInvitation (invitee joining an
|
||||
// existing workspace). This decouples "the user has a workspace"
|
||||
// from "the user has finished setup"; the workspace-layer route
|
||||
// gate (web layout / desktop App.tsx overlay) redirects un-onboarded
|
||||
// users back to /onboarding instead.
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create workspace")
|
||||
@@ -241,21 +222,6 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
// a schema change, and the event stream answers the question exactly.
|
||||
h.Analytics.Capture(analytics.WorkspaceCreated(userID, wsID))
|
||||
|
||||
if seededIssueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), seededIssue.WorkspaceID)
|
||||
issueResp := issueToResponse(seededIssue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, wsID, "member", userID, map[string]any{"issue": issueResp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
wsID,
|
||||
uuidToString(seededIssue.ID),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
|
||||
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", wsID, "name", ws.Name, "slug", ws.Slug)...)
|
||||
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,51 @@ func TestCreateWorkspace_RejectsReservedSlug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateWorkspace_DoesNotMarkOnboarded guards the onboarding
|
||||
// contract: creating a workspace MUST leave user.onboarded_at NULL so
|
||||
// the route guard in apps/web/app/[workspaceSlug]/layout.tsx (and the
|
||||
// desktop App.tsx overlay decision) can redirect the un-onboarded user
|
||||
// back to /onboarding to finish Step 3. The previous behavior atomically
|
||||
// set onboarded_at inside CreateWorkspace; this test makes the new
|
||||
// invariant explicit and regression-protected.
|
||||
//
|
||||
// CompleteOnboarding (Step 3 exit) and AcceptInvitation are the only
|
||||
// remaining handlers that flip onboarded_at.
|
||||
func TestCreateWorkspace_DoesNotMarkOnboarded(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
const slug = "handler-tests-onboarded-null"
|
||||
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
||||
// Ensure the test user starts un-onboarded so the assertion is meaningful.
|
||||
_, _ = testPool.Exec(ctx, `UPDATE "user" SET onboarded_at = NULL WHERE id = $1`, testUserID)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE slug = $1`, slug)
|
||||
_, _ = testPool.Exec(context.Background(), `UPDATE "user" SET onboarded_at = NULL WHERE id = $1`, testUserID)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/workspaces", map[string]any{
|
||||
"name": "Onboarding Invariant Probe",
|
||||
"slug": slug,
|
||||
})
|
||||
testHandler.CreateWorkspace(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateWorkspace: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var onboardedAt *string
|
||||
if err := testPool.QueryRow(ctx, `SELECT onboarded_at FROM "user" WHERE id = $1`, testUserID).Scan(&onboardedAt); err != nil {
|
||||
t.Fatalf("lookup user: %v", err)
|
||||
}
|
||||
if onboardedAt != nil {
|
||||
t.Fatalf("CreateWorkspace marked user as onboarded; expected NULL, got %q. The workspace layout hard gate relies on this staying NULL until Step 3 CompleteOnboarding fires.", *onboardedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteWorkspace_RequiresOwner exercises the in-handler authorization
|
||||
// added to DeleteWorkspace by calling the handler directly (bypassing the
|
||||
// router-level RequireWorkspaceRoleFromURL middleware). Without the handler
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Intentionally empty. The v3 design (frontend Zustand welcome-store) replaced
|
||||
-- the v2 persisted runtime choice columns; rolling this migration back has no
|
||||
-- meaningful target state. Re-introducing the columns would also re-introduce
|
||||
-- the deleted CHECK constraint, OnboardingService.MarkComplete plumbing, and
|
||||
-- PATCH /api/me/onboarding runtime field handling — none of which exist in
|
||||
-- the current code.
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v3 reverts the v2 attempt at persisting Step 3 runtime choice on the user
|
||||
-- row. The two columns were added in an earlier draft of this migration
|
||||
-- (wip/onboarding-v2, never shipped to production) and the design moved to
|
||||
-- a frontend Zustand transient store instead — onboarding state collapses
|
||||
-- back to the single `onboarded_at` field.
|
||||
--
|
||||
-- IF EXISTS guards make this safe whether the original v2 migration ran
|
||||
-- locally or not.
|
||||
ALTER TABLE "user"
|
||||
DROP CONSTRAINT IF EXISTS user_onboarding_runtime_choice_check;
|
||||
|
||||
ALTER TABLE "user"
|
||||
DROP COLUMN IF EXISTS onboarding_runtime_skipped;
|
||||
|
||||
ALTER TABLE "user"
|
||||
DROP COLUMN IF EXISTS onboarding_runtime_id;
|
||||
@@ -182,6 +182,10 @@ type PatchUserOnboardingParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
}
|
||||
|
||||
// Partial update of the user's onboarding decision fields. Currently only the
|
||||
// questionnaire JSONB is patchable — the v2 attempt at persisting Step 3
|
||||
// runtime choice on the user row was reverted; that state now lives in a
|
||||
// frontend Zustand transient store.
|
||||
func (q *Queries) PatchUserOnboarding(ctx context.Context, arg PatchUserOnboardingParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, patchUserOnboarding, arg.Questionnaire, arg.ID)
|
||||
var i User
|
||||
|
||||
@@ -46,6 +46,10 @@ WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: PatchUserOnboarding :one
|
||||
-- Partial update of the user's onboarding decision fields. Currently only the
|
||||
-- questionnaire JSONB is patchable — the v2 attempt at persisting Step 3
|
||||
-- runtime choice on the user row was reverted; that state now lives in a
|
||||
-- frontend Zustand transient store.
|
||||
UPDATE "user" SET
|
||||
onboarding_questionnaire = COALESCE(sqlc.narg('questionnaire'), onboarding_questionnaire),
|
||||
updated_at = now()
|
||||
|
||||
Reference in New Issue
Block a user