Files
multica/packages/core/onboarding/needs-backfill.ts
Jiayuan Zhang a61a8ecfed feat(onboarding): merge About-you step, collect source after agents deliver value (#5786)
Flow drops from five steps to three: role + use_case merge into a
single About-you screen (one Skip covers both; Continue stamps skip
markers on whichever group was left unanswered), and the source
question leaves onboarding entirely.

Source is now collected only by the workspace source-backfill prompt,
which additionally waits until agents/squads have completed at least
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace —
attribution is asked after Multica has visibly delivered value, not
before. The count rides a limit:1 issues query keyed under
issueKeys.all so realtime invalidations keep it fresh, enabled only
for users who still owe an answer.

Server: questionnaire complete() narrows to role + use_case so the
funnel step doesn't stall on the now-deferred source; a new
metrics-only onboarding_source_submitted event (+ Prometheus counter)
tracks the backfill prompt's answer/decline transition once per user.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:57:44 +08:00

75 lines
3.0 KiB
TypeScript

import type { User } from "../types";
import type { QuestionnaireAnswers } from "./types";
/**
* Maximum number of times the user can close the backfill prompt with
* the X / ESC / outside-click before we treat it as a permanent
* dismissal. After that the prompt stops appearing.
*
* Submit and explicit Skip are always terminal (they write to the
* server). The count exists only for the "I'll think about it later"
* close path — without a cap, a user who never decides would see the
* prompt every login forever.
*/
export const SOURCE_BACKFILL_MAX_DISMISSALS = 3;
/**
* Minimum number of issues completed by an AI assignee (agent or
* squad) in the current workspace before the source prompt may open.
*
* Source is not asked during onboarding at all — attribution is a
* zero-payoff question for the user, so we wait until Multica has
* demonstrably delivered value (agents finished real work) before
* spending goodwill on it. Answer rates for "how did you hear about
* us" prompts are also materially better after an activation moment
* than at signup. 3 ≈ one Helper starter-task batch, so an engaged
* new user typically crosses it within the first session.
*
* The count itself comes from `agentCompletedIssueCountOptions` in
* `./queries.ts`; the modal combines it with `needsSourceBackfill`.
*/
export const SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES = 3;
/**
* Should we ask this already-onboarded user where they heard about
* Multica?
*
* Returns true for users who:
* - have completed onboarding (`onboarded_at` set), and
* - have not recorded any source (empty array or absent), and
* - did not previously decline the source question (skip marker), and
* - have not closed this backfill prompt enough times to dismiss it.
*
* This is the user-level half of the gate. The workspace-level half —
* "have agents completed at least SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES
* issues here?" — needs a server query, so it lives in the modal
* (`source-backfill-modal.tsx`), which also uses this predicate to
* decide whether that query is worth running at all.
*
* Pure function — `dismissCount` is passed in so this stays callable
* from core (no localStorage / StorageAdapter dependency).
*/
export function needsSourceBackfill(
user: User | null | undefined,
dismissCount: number,
): boolean {
if (!user) return false;
if (!user.onboarded_at) return false;
if (dismissCount >= SOURCE_BACKFILL_MAX_DISMISSALS) return false;
const q = user.onboarding_questionnaire as
| Partial<QuestionnaireAnswers>
| null
| undefined;
if (!q) return true;
if (q.source_skipped === true) return false;
// Pre-multi-select rows wrote `source` as a bare string. Treat a
// non-empty string the same as a one-element array — the user did
// answer. Mirrors `OnboardingFlow.mergeQuestionnaire` (views) and
// `stringOrSlice.UnmarshalJSON` (server).
const raw: unknown = q.source;
if (Array.isArray(raw)) return raw.length === 0;
if (typeof raw === "string") return raw.length === 0;
return true;
}