Replace the static conversation with a scripted, looping flow that plays the actual delegation loop: compose a new issue and pick Claude Code from the Assignee dropdown -> create (assigned) -> the issue page with the agent working (reads/edits + spinner) -> the agent posts its result as a comment and the status moves to In Review. Adds newhome-fade / newhome-pop scene+popover entrances.
Co-authored-by: multica-agent <github@multica.ai>
Build the remaining three value cards, each with its own focused auto-playing demo (same approach as value #1): #2 Delegation — a delegation conversation (person @mentions an agent, it works, then replies with a PR); #3 Accountability — a streaming run transcript (thinking/reads/edits/tests); #4 Leverage — a skills library with a highlight cycling across agents. Cards alternate sides (text/demo -> demo/text -> ...) via a ValueCard reverse prop. The content-light demos share a scale frame (ValueDemoFrame) at the same DEMO_ZOOM and are sized to fit the demo half without bleeding.
Co-authored-by: multica-agent <github@multica.ai>
Radius is role-based, not one global value. Restore an 8px control radius (the design system's --radius-md) on buttons, nav links, the GitHub chip, and the demo browser tabs; keep surfaces (cards, demo windows, panel rows) at the restrained 6px. Flattening buttons to the container radius made them read boxy and off-brand versus the real product, whose Button uses rounded-lg.
Co-authored-by: multica-agent <github@multica.ai>
The value card's 24px corner was too large. Unify all landing-authored chrome (value card, demo window frames, buttons, nav links, chips, browser tabs, panel rows) to a single small 6px radius. The embedded real-product component radii (board columns/cards) are left as-is so the live demo still matches the actual product.
Co-authored-by: multica-agent <github@multica.ai>
Drop the big section heading and the hero->features divider/background tint; each value is now a rounded, tinted, bordered card that supplies its own framing. The card's overflow-hidden border is the boundary that clips the demo, so it bleeds to the card edge instead of past the browser edge. Keep the value title on one line (lg:whitespace-nowrap).
Co-authored-by: multica-agent <github@multica.ai>
Replace the stacked text-above-board value layout with a two-column row: a compact claim on the left (vertically centered) and the live demo on the right at its real shared-zoom size, bleeding off the right page edge (section uses overflow-x-clip so there's no horizontal scrollbar). Matches the requested reference layout.
Co-authored-by: multica-agent <github@multica.ai>
Sandbox landing at /newhome (production / untouched): hero with an embedded, interactive product demo (real board/issue-detail/create-issue/transcripts over mock data) and a Values section whose first pillar is an auto-playing board built from the real product components. All embedded demos render at one shared DEMO_ZOOM, and popups portal into the scaled box so they share the zoom.
Co-authored-by: multica-agent <github@multica.ai>
* feat(onboarding): backfill prompt for users missing source attribution
Adds a one-shot popup shown after login to already-onboarded users
whose `onboarding_questionnaire.source` was never recorded — either
they completed onboarding before the source step shipped, or they
clicked Skip on it. Reuses the existing 12-option StepSource UI and
the existing `PATCH /api/me/onboarding` endpoint, so no schema or
backend changes.
Web renders it as a route at /onboarding/source (sibling of the
reserved /onboarding); desktop dispatches it as a WindowOverlay per
the Route categories rule. Submit and explicit Skip are terminal;
the close X bumps a per-user localStorage counter and stops appearing
after 3 dismissals.
Emits source_backfill_shown / submitted / skipped / dismissed PostHog
events so the funnel can be tracked separately from first-time
onboarding.
For MUL-2796.
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): preserve role/use_case and respect dismiss cap in source backfill
Round-2 fixes from Emacs's review of #3550:
1. PATCH wipe: `PATCH /api/me/onboarding` replaces the JSONB column
wholesale (server/internal/handler/onboarding.go), so sending only
the source slots was wiping role/use_case/version for exactly the
historical users this targets. Read user.onboarding_questionnaire,
overlay the source fields client-side via mergedQuestionnairePatch,
and send the full shape. 7 unit cases cover the merge semantics.
2. Legacy single-string source: pre-multi-select rows wrote
`source: "search"` as a bare string. needsSourceBackfill now treats
that as already answered, matching mergeQuestionnaire (views) and
stringOrSlice.UnmarshalJSON (server). Flipped the existing test and
added empty-string + null coverage.
3. Dismiss cap honored in callback: the web auth callback was passing
dismissCount=0, which would force-route capped users through
/onboarding/source on every login (the route page would bounce them
onward, but only after a blank detour and a re-fired
`source_backfill_shown` event). Added readSourceBackfillDismissCount
so the callback reads the same per-user localStorage bucket the
prompt writes to. Test asserts a count of 3 bypasses the detour.
Co-authored-by: multica-agent <github@multica.ai>
* test(onboarding): clear source-backfill dismiss counter in callback test beforeEach
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): footer hint text matches the Submit button on the backfill prompt
The Source step's hint reads "Hit Continue when you're ready" because
its commit button is "Continue". The backfill view ships a "Submit"
button instead, so the inherited hint was misleading. Add a dedicated
`source_backfill.hint_ready` key across en / zh / ko and use it here.
Caught during browser E2E in the round-2 verification stack.
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): magic-code login also detours through source backfill
The round-2 fix in PR #3550 only wired the source-backfill detour
into the OAuth `/auth/callback` post-success path. Magic-code login
goes through `/login` → `handleSuccess()` which calls
`resolveLoggedInDestination()` and pushes directly to the workspace,
so those users never reach `/onboarding/source`. Caught during the
local-env demo for Jiayuan.
Add `maybeSourceBackfillDetour` to the login page and apply it in
both the already-authenticated useEffect and the post-verify-code
handler. Predicate consults the same per-user localStorage bucket
the prompt writes to, so a user who hit the close-X cap on this
browser flows straight through.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(onboarding): source backfill is a workspace-mounted modal, not a route detour
Per UAT, the prompt should overlay the workspace as a Dialog with the
workspace visible behind a dimmed backdrop — the original brief and
reference screenshot both showed a modal. PR #3550 shipped a full-window
takeover (web /onboarding/source + desktop WindowOverlay) which Jiayuan
rejected.
This commit replaces the full-window view with a Dialog-based
`<SourceBackfillModal />` mounted once inside the shared `DashboardLayout`
(packages/views/layout). The modal self-mounts: it reads
`needsSourceBackfill(user, dismissCount)` and opens itself when the
predicate flips to true; X / ESC / outside-click all bump the per-user
localStorage cap and close.
Removed:
- apps/web/app/(auth)/onboarding/source/page.tsx (route)
- paths.sourceBackfill (no longer needed)
- callback page detour
- login page maybeSourceBackfillDetour
- desktop WindowOverlay type "source-backfill"
- desktop navigation interception of /onboarding/source
- desktop App.tsx dispatch effect
- pageview-tracker case
- views/onboarding `SourceBackfillView` + `readSourceBackfillDismissCount` exports
Preserved (semantics unchanged):
- `needsSourceBackfill` predicate (incl. legacy single-string source coercion)
- `mergedQuestionnairePatch` so role / use_case survive Submit / Skip
- PostHog events: source_backfill_shown / submitted / skipped / dismissed
- Per-user dismiss-count cap (3) in localStorage
- en / zh / ko i18n strings
Tests:
- 7 new tests for the modal in packages/views/onboarding/source-backfill-modal.test.tsx
- Adjusted apps/web/app/auth/callback/page.test.tsx: detour tests dropped,
one assertion remains that onboarded users with missing source land in
the workspace (the modal handles the rest)
- Full suite: 965 tests pass, typecheck + lint clean
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): mount source-backfill modal on the desktop workspace too
Desktop's WorkspaceRouteLayout never wraps DashboardLayout, so the
previous commit's modal mount only fired for web. Regression: desktop
users were not seeing the prompt at all.
Wire the same `<SourceBackfillModal />` next to `<WelcomeAfterOnboarding />`
inside `workspace-route-layout.tsx`, with the matching
`!overlayActive` suppression so the Dialog doesn't portal-jump above
an active pre-workspace WindowOverlay (onboarding / accept-invite /
new-workspace). Same component on both platforms — single source of
truth lives in packages/views/onboarding/source-backfill-modal.tsx.
Also drop the now-stale `source-backfill detour` comment in the web
callback test fixture (Emacs nit, non-blocking).
Co-authored-by: multica-agent <github@multica.ai>
* test(desktop): assert workspace-route-layout mounts source-backfill modal
Two structural tests pinning the round-4 fix:
- `mounts SourceBackfillModal when no WindowOverlay is active` —
guards against the regression Emacs caught (modal silently absent
on desktop because the previous round only wired DashboardLayout).
- `suppresses SourceBackfillModal while a WindowOverlay is active` —
mirrors the existing `!overlayActive` rule that WelcomeAfterOnboarding
already relies on so a portal-rendered Dialog can't visually outrank
an active pre-workspace overlay.
Mocks the SourceBackfillModal with a marker component so the test
asserts mount/unmount without depending on the modal's own predicate
gate.
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): backfill modal Other toggles off; entrance settles after 700ms
UAT round-3 follow-ups from Jiayuan:
1. **Other can't be deselected**: the modal kept a parallel
`pendingOther` flag set to true on every Other click, and
`IconOtherOptionCard`'s row click was guarded with
`if (!selected) onSelect()` — so a second click neither flipped
pendingOther nor reached the parent toggle. Drop `pendingOther`
(the `source.includes("other")` derivation is already authoritative)
AND add an opt-in `allowToggleOff` prop to `IconOtherOptionCard`
that lets the row toggle when already selected. The text input
stops click propagation so typing never deselects.
2. **Rebase + absorb GitHub channel**: rebased onto origin/main which
added `social_github` (PR #3612). Modal's option list now mirrors
StepSource — GitHub slotted between YouTube and Other social,
reusing the existing `GitHubIcon`.
3. **Soft entrance**: defer the dialog open by 700ms after the user
lands on a workspace so the underlying view paints first and the
modal feels like an inviting prompt rather than a hard block.
Honour `prefers-reduced-motion: reduce` (open immediately for
users who have opted out of incidental motion).
Tests:
- New `Other toggles off on the second click instead of getting stuck`
- New `renders the GitHub channel rebased from origin/main`
- New `defers the entrance by ~700ms when the user has not opted into
reduced motion`
- Existing tests stamp `prefers-reduced-motion: reduce` in beforeEach
so the dialog opens synchronously and they don't need to drive
fake timers.
Full suite passes (969 tests).
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): backfill modal opens reliably + Other deselects via icon area
Three follow-up fixes after live UAT:
1. Strict-mode regression on entrance delay: the gate ref was being
stamped when the effect *scheduled* the timer, so React Strict
Mode's double-invoke cleared the first timer and then bailed on
the second pass because the ref was already set, leaving the
dialog forever closed. Stamp the ref only inside the timer
callback (or synchronously when reduced-motion is on) so the
second strict pass starts a fresh timer.
2. Other deselect: dropping `pendingOther` wasn't enough — the input
that replaces the label when Other is selected was previously
stopping click propagation, so a re-click on the row never
reached the toggle. Remove `e.stopPropagation()` and instead let
the row's onClick ignore clicks whose target IS the input
(typing / focusing the input still doesn't deselect; clicks on
the icon, padding, or border do).
3. Tests: drive the Other re-click via Playwright `click({position:
{x:24,y:24}})` so the click lands on the icon area instead of the
center of the input, matching real-user behaviour.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(onboarding): source picker is single-select primary source
Per Jiayuan's call after the survey of HDYHAU UX in PLG SaaS (Linear /
Vercel / Loom / Notion / Webflow / Stripe / Figma / Cursor / PostHog
mostly skip the question entirely; where it's asked the documented
default — Fairing / Recast / HockeyStack / Ruler Analytics — is to
capture the primary source so channel weights sum to 100% and ROI
math is defensible).
Modal + StepSource both pivot from multi-select to single-select
radio. Server schema is intentionally untouched: `source` stays
`string[]` for back-compat with v2 multi-select rows; the client
always sends a one-element array. Zero migration, zero data loss.
Frontend:
- `source-backfill-modal.tsx`: state pivots from a multi-element
`source: Source[]` to a single `pickedSlug` derived from
`source[0]`; click handler replaces the array instead of toggling.
Cards switch to `mode="radio"`, the fieldset gets `role="radiogroup"`,
the now-redundant `pendingOther` and `allowToggleOff` opt-in go
away — radio mode means no toggle-off, so the original UAT bug
("Other can't be deselected") is structurally impossible.
- `step-source.tsx`: drop the `multiSelect` prop so it routes
through `step-question.tsx`'s existing radio path (same one
StepRole already uses). Picking a second option replaces the
first; switching away from Other clears `source_other` so a stale
value can't leak.
- `icon-option-card.tsx`: revert the `allowToggleOff` plumbing.
Tests:
- `source-backfill-modal.test.tsx`: drop the multi-select toggle-off
assertion; add "picking a second option replaces the first" with
explicit radio-role queries.
- `step-source.test.tsx`: rewrite multi-select tests as single-select
(no more "stacks several picks" / "toggle off" cases); add
"switching away from Other clears source_other".
Full suite (970 tests) green, typecheck + lint clean.
Co-authored-by: multica-agent <github@multica.ai>
* docs(onboarding): refresh stale multi-select comments around source
Comment-only follow-up to the single-select refactor in d14f9d09f.
Five docblocks still described `source` as multi-select; they now
correctly say single-select and explain the array shape is kept
purely for v2 back-compat with the JSONB column.
- packages/core/onboarding/types.ts — QuestionnaireAnswers docblock
- packages/core/onboarding/store.ts — PostHog mirror comment
- packages/views/onboarding/steps/step-question.tsx — header docblock,
canContinue branch, and footer-hint comment (Source moves from the
multi-select side to the single-select side; Use case stays as the
remaining multi-select consumer)
- server/internal/handler/onboarding.go — questionnaireAnswers docblock
and the stringOrSlice fall-back comment (the column "going multi-
select" is no longer the current state; rename to "pre-array shape")
- server/internal/analytics/events.go — OnboardingQuestionnaireSubmitted
docblock
No behaviour changes. Tests + Go build still green.
Co-authored-by: multica-agent <github@multica.ai>
* i18n(onboarding): add ja translations for source-backfill keys
The Japanese locale landed on main (PR #3538) after this branch
started, so my source-backfill round-2 keys (`common.close`,
`source_backfill.eyebrow / lede / submit / hint_ready`) never made
it into ja and the parity test fails in CI. Add them now with
translations that match the en/zh-Hans/ko wording and tone.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* i18n: add japanese locale
* fix: spacing issues
* refactor
* fix(desktop): set <html lang> before paint to avoid JA Kanji font flash
Switch the documentElement.lang sync from useEffect to useLayoutEffect so
lang is committed before the first paint. Otherwise Japanese desktop users
saw one frame of Kanji rendered with the Chinese-first fallback stack before
the html[lang|="ja"] CJK override applied. Also fix the stale selector in the
HTML_LANG comment (html[lang^="ja"] -> html[lang|="ja"]).
Addresses review nits on MUL-2893.
Co-authored-by: multica-agent <github@multica.ai>
* fix(docs): tokenize the ideographic iteration mark in JA search
Add U+3005 (々) to the Japanese search tokenizer character class. It sits just
below the kana blocks, so words like 様々 / 日々 / 個々 previously dropped the
mark and split awkwardly, hurting recall.
Addresses a review nit on MUL-2893.
Co-authored-by: multica-agent <github@multica.ai>
* fix(i18n): restore ja locale parity after merging main
Merging main brought new EN strings into agents/chat/onboarding/settings/
squads that the ja bundle (authored against an older snapshot) lacked, breaking
the locales parity test. Add the Japanese translations for the new keys
(workspace logo upload, agents runtime filter, chat session-history stop
dialog, onboarding social_github, squad archived status) and drop the two
renamed chat window keys (active_group / archived_group) that EN removed in
favour of history_group.
Fixes the failing @multica/views parity.test.ts on the FE CI for MUL-2893.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Adds OpenCode model variant discovery for thinking controls, passes saved thinking_level through opencode run --variant, and hardens verbose model parsing with fallback coverage.
Adds avatar_url column to workspace, threads it through the API +
WorkspaceAvatar component, and adds a click-to-upload editor in the
workspace settings tab. Mirrors the squad avatar pattern (migration 086);
UI strings use "logo" while the schema/code uses avatar_url for codebase
consistency with user.avatar_url and squad.avatar_url.
- migration 093: ALTER TABLE workspace ADD COLUMN avatar_url TEXT
- UpdateWorkspace SQL + handler accept avatar_url (auth gated to
owner/admin at the router via RequireWorkspaceRoleFromURL)
- WorkspaceAvatar renders <img> when avatar_url is set, falls back to
the initial-letter span otherwise
- workspace-tab.tsx adds a 16x16 click-to-upload logo editor at the
top of the general settings card, using useFileUpload + accept=
image/png,image/jpeg,image/webp (server stores under workspaces/{id}/)
- en + zh-Hans settings i18n strings added
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
* feat(agents): add runtime machine filter to Agents tab (MUL-2846)
Add a dropdown filter to the Agents tab toolbar that lets the user
narrow the list to agents bound to a specific runtime machine. The
filter reuses `buildRuntimeMachines` from the runtimes package so the
machine grouping (Local / Remote / Cloud) matches the Runtimes page
sidebar, and the per-machine agent counts respect the current scope
(Mine/All) so the numbers reflect what the user would see if they
clicked the row.
Only rendered in the Active view; the Archived view's toolbar is
unchanged. If the selected machine is GC'd while the user is on the
page (daemon stopped, runtime deleted), the filter auto-resets to
'All runtimes' instead of leaving the list empty. The no-matches state
now surfaces 'No agents on <machine>' when the machine filter is the
reason for zero results.
Adds new `runtime_filter` and `no_matches.runtime_filtered` /
`no_matches.search_runtime_filtered` i18n keys in en, zh-Hans, and
ko. 7 new unit tests in
`runtime-machine-filter-dropdown.test.tsx`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): address code review on runtime machine filter
- Plumb localDaemonId / localMachineName / hasLocalMachine / currentUserId
through AgentsPage → buildRuntimeMachines so the Local section and
device-name consolidation match the Runtimes page on both web and
Desktop. Adds a DesktopAgentsPage wrapper that bridges daemonAPI the
same way DesktopRuntimesPage does.
- Make the 'All runtimes' badge use the in-scope total instead of
summing per-machine counts, so an agent bound to a GC'd runtime
doesn't silently vanish from the count.
- Move Date.now() out of the machines useMemo into a useState lazy
init so the snapshot stays stable per mount.
- Drop unused i18n keys (all_description / this_machine / reset) from
runtime_filter in en / zh-Hans / ko.
- Add a regression test for the All-runtimes badge divergence.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): machine-scoped availability counts + Base UI menu items
Follow-up to the previous code-review round (Emacs review at 1144b6023).
#1 (medium) — Availability counts now respect the selected machine.
Introduce an inScopeOnMachine memo (inScope narrowed by the selected
runtime machine, but NOT by availability chip or search) and use it as
the base for both availabilityCounts and the AvailabilityFilterRow's
totalCount, so the chips reflect 'agents on this machine' once a
machine is selected. filteredAgents is now derived from inScopeOnMachine
so the availability chip and search further refine within the machine
scope. The dropdown's 'All runtimes' badge still uses inScope.length —
it's the count the user would see if they cleared the filter, so it
should stay unfiltered.
#2 (low) — Dropdown rows now use DropdownMenuItem instead of raw <button>.
Replaces the bare <button> in RuntimeMachineFilterItem with the
shared DropdownMenuItem wrapper (Base UI Menu.Item). The rows are now
registered as proper menu items: keyboard navigation (arrow keys, Enter,
Space), typeahead, ARIA role='menuitem' semantics, and auto-close on
selection (closeOnClick: true) all work. Active styling is preserved
via data-active, and a data-highlighted variant on the inactive style
matches Base UI's keyboard-focus appearance.
Tests updated to use role-based queries (getByRole('menuitem')) and
add a regression that verifies the menu is properly registered with
Base UI.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: MiniMax M3 <M3@multica.local>
* feat(billing): test page consuming /api/cloud-billing/*
Stuffs every cloud-billing endpoint onto a single dev page so we can
verify the proxy + Stripe end-to-end flow without a designed UI.
Reachable at /<workspaceSlug>/billing — the page is account-level
data but lives under the workspace dashboard layout because that's
where the authenticated shell sits. No sidebar entry on purpose;
this is test-quality and meant to be deleted when the real billing
UI ships.
What's there:
* Balance card (GET /balance)
* Stripe-success polling banner — visible only when ?session_id=
is in the URL (Stripe substitutes it into checkout_success_url
on its way back). React Query refetchInterval polls every 2s
until the topup status reaches credited / failed / canceled,
then a 'Clear from URL' button calls navigation.replace(pathname).
* Buy section: server-authoritative price tier buttons (GET
/price-tiers) → POST /checkout-sessions → window.location to the
Stripe URL. We do NOT hard-code amounts on the frontend; tier
config lives in cloud's billing.price_tiers.
* Stripe Billing Portal button (POST /portal-sessions). Opens in a
new tab so the originating page stays put for easy verification.
Documented behaviour: 400 is expected for users with no Stripe
customer record yet.
* Three lists: transactions / batches / topups.
Plumbing:
* packages/core/types/billing.ts — interfaces mirroring the cloud
response shapes. Status / source / tx_type fields are typed
'string' rather than enum unions to match the schemas' z.string()
parsing (same convention as CloudRuntimeNode); the canonical
enum values are exported as separate type aliases for callers
that want to switch on them.
* packages/core/api/schemas.ts — 9 zod schemas + 7 EMPTY_ fallbacks,
all .loose() so a non-breaking cloud-side field addition doesn't
crash the parser.
* packages/core/api/client.ts — 8 methods using parseWithFallback,
matching the existing cloud-runtime shape.
* packages/core/billing/{queries,mutations,index}.ts — React Query
queryOptions + mutations. Notable choices: balance / lists are
NOT keyed on workspace (account-level data), and the
checkout-session polling stops automatically when status is
terminal so we don't poll forever after a user closes the tab.
* packages/core/package.json + packages/views/package.json — exports
map updated for @multica/core/billing and @multica/views/billing.
Verification:
* pnpm --filter @multica/core typecheck clean
* pnpm --filter @multica/views typecheck — only pre-existing
hast-util-to-html error in editor code (exists on main)
* pnpm --filter @multica/core test — 412 passing
* pnpm --filter @multica/views test — 877 passing, 1 failure
(editor/readonly-content) is also pre-existing on main, not
caused by this change
Out of scope: real production-quality billing UI; sidebar entry; i18n
strings; mobile app. This is a single test page; it gets replaced
when the real UI ships.
* fix(billing): refetch balance/lists when checkout polling reaches terminal
Closes the second-half of the Stripe-return race the previous commit
left dangling.
Symptom:
After Stripe redirects back with ?session_id=..., the banner polls
/checkout-sessions/{id} every 2s and the rest of the page (balance,
transactions, batches, topups) is fetched once on mount. The
webhook race means those four queries usually see pre-credit state
— but the banner is the only thing that keeps polling, so once it
reads 'credited' nothing else on the page knows. The user would
see 'Final status: credited' next to a stale balance card until
they manually refresh.
Fix:
Add useInvalidateBillingDataAfterCredit() in @multica/core/billing —
a hook returning a callback that flushes balance / transactions /
batches / topups (NOT the checkout-session itself; its
refetchInterval already terminated, refetching would just confirm
the same value). The Stripe-success banner runs this callback in
a useEffect keyed on terminal-status transition, so it fires
exactly once when the polling lands.
Strict scope is documented in the hook's JSDoc:
- balance/transactions/batches: only change at the 'credited'
transition (cloud writes ledger + batch + wallet in one DB tx)
- topups: changes on every terminal transition
- For 'failed' / 'canceled' we technically over-fetch the first
three; three cheap round-trips, simplifies the call site, fine
on a test page.
Effect dep is . terminal flips false→true at most once
per session id (the polling stops when terminal is true so the
data won't change again). If the user lands here with a session
that is already terminal (re-opened tab on a credited URL), the
effect still fires on first data load and we still re-fetch —
correct, the cached snapshot is just as stale in that case.
go build / pnpm typecheck / pnpm test clean (core 412 passing; only
pre-existing hast-util-to-html error in unrelated editor code on
views, same as on main).
* feat(agent): add Antigravity runtime backend
Adds Google's Antigravity CLI (`agy`) as the 12th supported coding-tool
runtime, alongside Claude / Codex / Cursor / Copilot / Gemini / Hermes /
Kimi / Kiro / OpenCode / OpenClaw / Pi.
The CLI emits plain assistant text on stdout (no structured event
stream), so the backend streams stdout line-by-line as `MessageText`
events and accumulates the same text as the final `Result.Output`.
Session resumption uses `--conversation <id>`; because the conversation
UUID is not echoed on stdout, the daemon routes `--log-file` to a temp
file and recovers the id from the glog-formatted log lines.
MUL-2767
Co-authored-by: multica-agent <github@multica.ai>
* fix(agent): correct Antigravity capability contract from Elon review
- ModelSelectionSupported now returns false for antigravity. `agy` has no
--model flag and antigravityBackend deliberately drops opts.Model, so
the UI must render a disabled "Managed by runtime" picker instead of
an empty dropdown plus a silently-ignored manual-entry field. Also
stop seeding AgentEntry.Model from MULTICA_ANTIGRAVITY_MODEL — the
backend would silently ignore it.
- Antigravity skills now write to {workDir}/.agents/skills/, the CLI's
native workspace path (inherits Gemini CLI's layout per
https://antigravity.google/docs/gcli-migration). Previously they went
to the .agent_context/skills/ fallback that the CLI doesn't scan.
Runtime brief moves antigravity into the native-discovery branch and
local_skills.go points the user-level skill root at
~/.gemini/antigravity-cli/skills for Runtime → local skill import.
- Doc + UI comment sync: providers matrix / install-agent-runtime /
cloud-quickstart / agents-create / tasks (session-resume support) /
skills / README all now list Antigravity in the right buckets, and
the model-picker / model-dropdown comments cite antigravity (not the
stale hermes reference) as the supported=false example.
New tests: TestAntigravityModelSelectionUnsupported,
TestInjectRuntimeConfigAntigravity (native discovery wording),
TestWriteContextFilesAntigravityNativeSkills (.agents/skills/ landing,
.agent_context/skills/ NOT written).
Co-authored-by: multica-agent <github@multica.ai>
* feat(provider-logo): swap inline placeholder for real Antigravity PNG
Replaces the hand-drawn planet+arc placeholder with the official asset
shipped from Downloads. Stored next to the component; bundlers
(Next.js / electron-vite) resolve the PNG import to a URL string at
build time. Added a small assets.d.ts so packages/views' tsc accepts
PNG / SVG module imports — there was no prior asset usage in this
package to register the declaration.
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(deps): add eslint phantom dep detection + fix existing violations (MUL-2654)
Introduce eslint-plugin-import-x/no-extraneous-dependencies rule to
prevent phantom deps from causing production build splits when pnpm
creates peer-dep variants. Fix all existing phantom deps across the
monorepo, unify catalog references, and enable desktop smoke CI on PRs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* revert(ci): remove desktop smoke PR trigger per user feedback
The existing smoke workflow only verifies packaging completes — it does
not actually start the app or check rendering. This means it wouldn't
have caught the white-screen bug (which was a runtime issue, not a build
failure). Adding it to PRs would slow CI without providing meaningful
protection. The ESLint no-extraneous-dependencies rule is the actual
prevention mechanism.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(deps): sync pnpm-lock.yaml for rehype-sanitize dep classification
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): move rehype-sanitize to deps + declare eslint-config (MUL-2654)
- Move rehype-sanitize from devDependencies to dependencies (used in
production Markdown.tsx)
- Add @multica/eslint-config to devDependencies (imported by
eslint.config.mjs but previously undeclared)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)
The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.
Why
- `multica agent list --output json` historically returned plaintext
`custom_env` for owner/admin callers (the redaction gate gave only
members the masked map). Any agent token running on the workspace
inherits its owner's role and could read every other agent's
secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
symmetric leaks via mutation responses, WS events, the "reveal"
path itself (no actor-aware auth), and a `****` overwrite footgun
on UpdateAgent.
What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
`has_custom_env` + `custom_env_key_count`. Strip env handling from
UpdateAgent (silently ignored if sent). Keep CreateAgent's
custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
`internal/handler/agent_env.go`:
- resolveActor → 403 for agent actors (closes the lateral-movement
path).
- Owner/admin role gate via existing helper.
- PUT honours value == "****" as "preserve existing value".
- Both write to `activity_log` with `agent_env_revealed` /
`agent_env_updated` actions. Audit details record key names only,
never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
flags removed from `multica agent update`; the no-fields error
now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
`getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
to show "N variables configured" + explicit "Reveal & edit"
button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
metadata fields.
Tests
- Handler tests pinned the new invariants: no env in list/get
responses, owner reveal happy-path + audit row, agent-actor 403,
`****` sentinel preserves real values, UpdateAgent silently
ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
expose the env flags; `agent env set` MUST expose
--custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
pass cleanly.
This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)
Addresses Elon's review of #3209:
* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
workspace, owner). Daemon injects it into the agent process in place
of its own credential. Auth middleware authoritatively rebuilds
X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
X-Actor-Source=task_token; that header is server-set only — incoming
values are stripped before any auth branch runs. resolveActor honors
the header so an agent that strips X-Agent-ID / X-Task-ID still
resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
failures: GET refuses to return plaintext, PUT persists inside the
same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
instead of silently dropping it — directs callers to the audited env
endpoint.
* Agent actors never see mcp_config, even when the underlying member
is owner/admin; mutation broadcasts go through a redaction shim so
WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
whitespace) and frontend test that assumed a unique "Test User"
match.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)
Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.
Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.
mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.
FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.
Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").
Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
case asserting client-supplied slug/id/query cannot override the
bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
response redaction contract per actor type.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)
`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.
The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): add use-cases content pipeline with welcome page (MUL-2349)
Wire fumadocs-mdx into apps/web with an independent collection rooted at
content/use-cases/. Add the first page at /use-cases/welcome (header + H1 +
prose + screenshot + footer) using the about-page visual shell.
- source.config.ts + lib/use-cases-source.ts (separate from apps/docs)
- features/landing/components/mdx/screenshot.tsx wraps next/image
- public/use-cases/welcome/screenshot-1.png placeholder (55KB)
- next.config.ts wraps NextConfig with createMDX()
- .gitignore + eslint ignore .source/
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): bilingual db-boy use case with cookie locale (MUL-2349)
Extends the use-cases pipeline into the first real article.
- ZH + EN MDX (auto-data-analysis.{zh,en}.mdx) sharing three real
screenshots; sensitive fields on db-boy-profile.png (RDS host, DB
name, password) are blurred in-place.
- Cookie-based locale: /use-cases/<slug> reads multica-locale
server-side via lib/use-cases-i18n.ts (mirrors LandingLayout's
cookie + Accept-Language fallback). Same URL serves either language;
no [lang] segment so all other landing routes stay unchanged.
- Frontmatter schema (source.config.ts): z.looseObject with declared
hero_image / updated_at (required) / category (optional); a
preprocess converts YAML-auto-parsed Date back to a YYYY-MM-DD string.
- MDX components factory createMdxComponents(locale) routes the
secondary CTA to /docs/zh (ZH) or /docs (EN); internal MDX links
use <Link> for SPA nav; full-width and half-width colons both
trigger [CTA: ...] / [占位图: ...] markers; 副 and Secondary
both work as the secondary CTA prefix.
- Index page localizes hero / subtitle / card CTA / metadata; sort
fallback uses an epoch placeholder so undefined-order disappears.
- Landing header + footer surface use-cases entry in both locales.
- Detail route: sticky header, right-rail TOC with anchor jumps,
scroll-mt-[100px] on H2/H3 so anchor jumps don't slip under the
sticky header.
- Drop welcome demo page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): resolve code review blockers on use-cases PR
- Add `use-cases` to reserved_slugs.json + regenerate TS (P1: prevent
future workspace slug collision)
- Fix dead links in both MDX files: /features/* → /docs/* (P2)
- Remove duplicate brand suffix in page title metadata (nit)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(web): align usecases locale routing
* chore: refresh web mdx lockfile
* fix(web): type mdx next config adapter
* fix(web): wrap settings route page
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After submit, the tall form collapses into the much shorter success card;
the browser keeps the scroll offset so the user lands on the footer and
has to scroll up to see the confirmation. Scroll the page back to the
success card on success.
Also shorten the awkward "Back to multica.ai" / "返回 multica.ai" CTA to
"Back to home" / "返回首页".
MUL-2493
Co-authored-by: multica-agent <github@multica.ai>
Per design feedback, the Contact Sales entry now sits next to "Start
free trial" / "Download Desktop" in the hero as a text-only "Talk to
sales →" link (no background, no border) and is removed from the
landing header.
MUL-2493
Co-authored-by: multica-agent <github@multica.ai>
The form posted to a relative `/api/contact-sales`, which on the
Vercel-hosted web app gets handled by the `/api/*` rewrite using
the server-only `REMOTE_API_URL`. On `multica-app.copilothub.ai`
that env points at a privately-resolvable host, so the rewrite
returns 404 (`DNS_HOSTNAME_RESOLVED_PRIVATE`) even though every
other API call works — the rest of the app uses
`NEXT_PUBLIC_API_URL` and hits the API origin directly.
Switch the form to do the same: `${NEXT_PUBLIC_API_URL}/api/contact-sales`,
falling back to a relative URL for local dev / self-hosted setups
where same-origin still works.
MUL-2493
Co-authored-by: multica-agent <github@multica.ai>
* feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493)
Adds a public `/contact-sales` marketing page with a needs-discovery form
modelled on the design reference attached to MUL-2493 — first/last name,
business email (with free-provider rejection), company name + size,
country/region, intended use case, and a free-text goals field, plus the
two consent checkboxes from the reference.
Submissions hit a new public `POST /api/contact-sales` endpoint with
per-IP rate limiting (Redis-backed via the existing RateLimit middleware,
configurable through `RATE_LIMIT_CONTACT_SALES`) and a per-email hourly
cap so a single business address can't be used as a flood channel after
one valid pass. The inquiry is stored in a new `contact_sales_inquiry`
table; analytics fires a `contact_sales_submitted` PostHog event with
only the closed-enum dimensions (size, country, use case) — the free-text
goals stay in the DB and are never broadcast.
The page is linked from the landing header (md+) and the footer's Company
column, in both English and Simplified Chinese. The reserved-slug list is
updated so a workspace named `contact-sales` can't shadow the route.
Co-authored-by: multica-agent <github@multica.ai>
* fix(landing): canonicalize business email and tighten contact-sales form (MUL-2493)
- Parse the submitted email with net/mail and run the free-email
block-list against the canonical addr.Address, so a display-name
form like `Ada <ada@gmail.com>` can no longer slip past the gate
(the raw string had domain `gmail.com>`, which wasn't blocked).
Adds regression tests covering the display-name bypass and the
canonicalization helper.
- Drop noValidate from the contact-sales form so the browser's
native required / email / select checks fire before submit;
the JS-side free-email warning still runs as a UX guard.
- Update success copy ("respond within three business days") in
EN and ZH plus the page metadata.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(timezone): add scheduling/viewing timezone architecture RFC
* feat(db): replace daily rollups with task_usage_hourly, add user.timezone
Migrations 100-104: add "user".timezone (Viewing tz), build the UTC
hourly task_usage_hourly rollup with its pipeline, drop the legacy
task_usage_daily / task_usage_dashboard_daily pipelines, and drop the
agent_runtime.timezone column. Report queries now slice day boundaries
at read time by the caller-supplied @tz instead of materialising in a
fixed tz. Regenerate sqlc.
* feat(server): add task_usage_hourly backfill command
Replace the two legacy backfill commands (daily / dashboard_daily) with
a single backfill_task_usage_hourly that loads historical task_usage
into the new UTC hourly rollup, sliced per workspace.
* refactor(server): resolve viewing timezone in report handlers
Report handlers resolve the Viewing tz per request (?tz query param,
then user.timezone, then UTC) and pass it to the hourly-rollup queries.
Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup
dual paths, remove the /api/usage endpoints, and stop the daemon from
reporting and the runtime handler from accepting host timezone.
* refactor(core): switch report queries to viewing timezone
API client and dashboard/runtime queries send ?tz with each report
request, the user schema/types carry the new timezone field, and the
runtime timezone field/mutation is removed.
* feat(views): add viewing timezone preference and UI
Add the useViewingTimezone hook and a Timezone setting in Preferences;
report charts and the dashboard week boundary follow the viewer tz.
Remove the runtime detail timezone editor and its locale strings.
* fix(test): update fixtures and stabilize tests for timezone refactor
The timezone architecture refactor changed several types without
updating dependent test code:
- RuntimeDevice no longer has a timezone field — drop it from the
create-agent-dialog runtime fixture.
- User now requires a timezone field — add it to the apps/web mockUser
fixture.
- The PreferencesTab timezone tests asserted on the async save handler
(PATCH then store update) with a bare expect, racing the mutation's
settle callback, and timed out querying the Select's ~600-option IANA
list on a loaded CI runner. Wrap the assertions in waitFor and extend
the timeout for those three tests.
* docs(timezone): document self-host migration order and trigger invariant
Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package
comment: applying migrations 100-104 in a single migrate-up drops the
legacy daily rollups before the hourly backfill runs, leaving dashboards
empty until cron catches up.
Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id
must be added to the trigger's OF list if it ever becomes mutable,
otherwise dirty buckets for the old agent_id are silently missed.
* style(runtimes): drop trailing blank line in runtime-detail
## Summary
Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a):
- **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage.
- **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility.
- **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans).
- **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu.
- **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused).
Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost.
## Test plan
- [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/`
- [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty`
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass)
- [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox
- [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
* refactor(onboarding): remove starter-content kit, unify install-runtime issue across mark-onboarded paths (MUL-2438)
Drops the post-onboarding ImportStarterContent / DismissStarterContent
flow (handler + routes + StarterContentPrompt + templates + locale
strings + analytics event). The bug — web onboarding seeding 6+ starter
issues without a runtime — only existed through that path; with it gone
the source disappears.
The "install a runtime" issue from BootstrapOnboardingNoRuntime is now
the canonical no-runtime onboarding seed. The title/description and a
LockAndFindActiveDuplicate-deduped seeder move to
handler/no_runtime_issue.go, and CompleteOnboarding / CreateWorkspace /
AcceptInvitation seed it whenever the workspace has no runtime yet, so
every mark-onboarded entry point lands the user on a concrete next
step.
starter_content_state column is kept and continues to be claimed as
'imported' in all five entry points so older desktop builds (which
still render the legacy dialog on NULL) don't surface it to accounts
created after this change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): backfill starter_content_state for in-window NULL users (MUL-2438)
054 only covered pre-feature users. Anyone onboarded between then and the
starter-content kit removal could still sit at NULL, and old desktop
clients gate the legacy StarterContentPrompt on `starter_content_state
IS NULL`. The import/dismiss routes are gone, so leaving these rows NULL
would surface a dialog whose buttons 404. Mark them 'imported' to match
the new helper's claim semantics.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(onboarding): simplify runtime bootstrap
* fix(onboarding): close private-helper reuse hole and guide-issue nav race
- server: when bootstrap looks for an existing Multica Helper, require
Visibility="workspace" so a private helper owned by another member
can't be auto-assigned to the onboarding issue (and trigger a task as
that private agent), which would have bypassed canAccessPrivateAgent.
- web onboarding page: refreshMe() inside bootstrap flips hasOnboarded
before onComplete fires, letting the guard's router.replace overtake
onComplete's router.push to the new guide issue. Mark the page as
"completing" right before navigating so the guard stays silent during
the in-flight transition.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): escape daemon command literals to satisfy i18next/no-literal-string
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
* fix(editor): bump hast-util-to-html to v9 so lowlight output actually serializes
Source view of fenced ```html (and any other code block falling through to
the lowlight branch in ReadonlyContent) silently rendered as un-highlighted
escaped text. Root cause was a stale dep pin: `hast-util-to-html: ^4.0.1`
predates the package's ESM/named-export rewrite — v4 only exports a CJS
default function, so the `import { toHtml } from "hast-util-to-html"` in
code-block-static.tsx:19 and readonly-content.tsx:32 resolved to
`undefined` at runtime. The try/catch in both call sites caught the
"toHtml is not a function" throw and fell through to escapeHtml plain
text, so no `.hljs-*` spans ever made it to the DOM and the syntax-color
CSS added in #2808 had nothing to attach to.
Bumping to ^9.0.5 (matches the v9 line that lowlight@3 / remark / rehype
ship in the rest of the tree) makes the named `toHtml` export available
and source-view highlighting works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): open HTML attachment in new tab + full-page preview route
Adds a third toolbar button to HtmlAttachmentPreview between Maximize and
Download: open the attachment in a new app tab (desktop) or browser tab
(web). The full-screen modal stays — they serve different scenarios:
modal for a quick "see it bigger" without leaving the issue context,
new-tab when the user wants to keep the rendered HTML around while
working on something else.
Components:
- New workspace path: `/{slug}/attachments/{id}/preview?name={filename}`.
Lives outside the (dashboard) group on web so the iframe gets the full
viewport — sidebar would defeat the point. Desktop registers the route
inside `WorkspaceRouteLayout` so workspace context resolution still
runs (no slug → no path is built).
- `packages/views/attachments/attachment-preview-page.tsx`: shared full-
page view that reuses `useAttachmentHtmlText` for the iframe srcDoc.
Sandbox stays `allow-scripts` (no allow-same-origin) — same security
posture as the inline preview.
- `HtmlAttachmentPreview`: adds Open-in-new-tab button. Routes through
`useNavigation().openInNewTab` when available (desktop), falls back to
`window.open(getShareableUrl(path))` on web. Button is hidden when no
workspace slug is in scope (shouldn't happen in practice, but the
shared component must not throw outside a workspace route).
Tests cover: desktop openInNewTab call args, web window.open fallback,
and that the failure-mode toolbar still surfaces all three actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): drop now-stale @ts-expect-error on hast-util-to-html imports
v9 ships bundled type declarations, so the directives added for v4 trigger
TS2578 ("Unused '@ts-expect-error' directive") on CI typecheck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal navigation on web feels laggy because clicking a sidebar link blocks
0.2–0.6s with zero visual feedback — no prefetch, no Suspense fallback in the
dashboard segment, and no React transition to mark the route commit as pending.
This change adds the three pieces App Router needs to make the click→commit
window feel instant, scoped to the (dashboard) segment so auth/landing keep
their existing chrome:
- NavigationAdapter gains an optional prefetch(path). The web adapter wires
it to router.prefetch; desktop leaves it undefined (react-router has no
equivalent and doesn't need one). AppLink prefetches on hover/focus and
preserves caller-supplied onMouseEnter/onFocus/onClick.
- NavigationProvider wraps push/replace in useTransition and exposes the
pending flag via useIsNavigating(). Every useNavigation().push caller —
sidebar AppLink, command palette, post-create modal jumps — picks this up
automatically.
- New apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx renders a minimal
skeleton during cold transitions inside the dashboard segment only.
- DashboardLayout renders a 1px top progress bar driven by useIsNavigating.
packages/views remains free of next/* imports; desktop is unaffected by
construction (no prefetch, transition flips quickly, no loading.tsx).
Co-authored-by: multica-agent <github@multica.ai>