Compare commits

..

138 Commits

Author SHA1 Message Date
Lambda
f9f30f15c4 fix(chat): prevent chatbox jump when sending first message
The ChatInput wrapper toggled between pb-8 (empty state) and pb-4
(has messages), causing a 16px vertical jump the moment hasMessages
flipped. EmptyState already centers itself inside flex-1, so the
extra padding wasn't needed — collapse to a single pb-4.
2026-04-24 01:55:37 +08:00
Jiayuan Zhang
35aca57939 feat(chat): Chat V2 — sidebar entry + main-area page (#1580)
* feat(chat): Chat V2 — sidebar entry + main-area page

Replace the floating drawer + FAB with a first-class workspace route
`/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an
unread dot; session history lives inside the Chat tab via a popover
rather than leaking into the global sidebar (keeps Multica's "nouns in
the nav" semantic — Inbox / Issues / Projects are work objects, Chat is
a tool).

- Add `paths.workspace(slug).chat()` + update link-handler route set.
- New `ChatPage` view with PageHeader, history popover, centered
  messages/composer column, and empty-state starter prompts.
- Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone
  `ChatSessionHistory` (history now embedded in the popover).
- Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore`
  — the page is a route now, not an overlay.
- Wire the new `/chat` route on web (App Router) and desktop
  (react-router + tab-store icon mapping).

Addresses MUL-1322.

* fix(chat): align composer width with message column

The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making
the composer 32px narrower than the messages column. Drop the outer
px-4 so both share the same max-w-3xl outer + px-5 inner padding
provided by ChatMessageList / ChatInput.

* fix(chat): taller default composer (~3 lines visible, 8 max)

min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously
showed only 1 text row after pb-9 for the action bar; raise the
floor so there's visible writing room and lift the ceiling so a
longer draft can grow before scrolling kicks in.

* fix(chat): restore anchor + in-flight indicator + cold-start session restore

Three issues surfaced by review:

1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate
   only matches issue/project/inbox pathnames, so moving chat to its own route
   dropped 'bring the page I was on into the conversation'. Track the last
   anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar
   + lastAnchorLocation on useChatStore) and substitute it when on /chat.

2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the
   sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a
   spinner while any chat task is in flight.

3. ChatPage restore effect latched didRestoreRef before the sessions query
   resolved, so cold-start direct nav to /chat landed on the empty state even
   when the server had an active session. Wait for isSuccess before locking
   the ref.

* fix(chat): clear lastAnchorLocation on workspace rehydration

The pathname captured in workspace A would otherwise be reused against
workspace B's wsId, triggering a cross-workspace issue/project fetch
and silently leaking anchor context into chat messages.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 01:46:37 +08:00
Bohan Jiang
e0e91fc792 feat(daemon): harden agent mention-loop instructions (#1581)
* feat(daemon): harden agent mention-loop instructions

Two agents that mention each other via `mention://agent/<id>` can fall into
an infinite reply loop — each says "I'm done" in prose but keeps
`@mentioning` the other, which re-enqueues their run. Adding hard caps on
agent-to-agent turns conflicts with Multica's design principle of giving
agents the same authorship freedom as humans, so this change hardens the
instructions that the harness injects instead.

- Replace the terse "mentions are actions" blurb with a full Mentions
  protocol: `side-effecting` warning, explicit "when NOT to mention"
  (replying to another agent, sign-offs, thanks) and "when a mention IS
  appropriate" (human escalation, first-time delegation, user asked).
- Add a pre-workflow decision step for comment-triggered runs: decide
  whether a reply is warranted at all, decide whether to include any
  `@mention`, and clarify that the post-a-comment rule is mandatory *if*
  you reply — silence is a valid exit for agent-to-agent threads.
- Thread the triggering comment's author kind + display name
  (`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint
  through the daemon task type, per-turn prompt, and CLAUDE.md workflow.
  When the author is another agent, both surfaces now name that agent
  and warn against sign-off mentions.
- Soften the old closing line that told agents to `always` use the
  mention format — the word generalized to member/agent mentions and
  encouraged the very behavior that causes loops.

Refs GH#1576, MUL-1323.

* fix(daemon): remove MUST-respond conflict and sanitize trigger author name

Addresses two blocking points on PR #1581:

1. buildCommentPrompt told the agent "You MUST respond to THIS comment"
   and unconditionally appended the reply command — directly conflicting
   with the new agent-to-agent silence-as-valid-exit workflow. Models
   were likely to keep following the older must-reply rule and fall back
   into the loop this PR is trying to close.

   Rewrite the header as "Focus on THIS comment — do not confuse it
   with previous ones" (keeps the anti-stale-comment signal) and change
   BuildCommentReplyInstructions to open with "If you decide to reply,
   post it by running exactly this command" so the reply command is
   available but conditional across both prompt surfaces.

2. Raw agent/user display names were being embedded directly into the
   high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and
   member names are only validated as non-empty at write time, so a
   name containing newlines, backticks, or fake mention markup would
   turn the field into a cross-agent prompt-injection surface.

   Add execenv.SanitizePromptField — strip control runes, collapse
   whitespace, drop markdown structural characters (backtick, asterisk,
   brackets, pipe, angle brackets, hash, backslash), truncate to 64
   runes — and apply it at both embed sites (per-turn prompt and
   CLAUDE.md). Defense-in-depth at the consumption layer so this works
   for already-stored names without a migration.

Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName
plants an attack payload in TriggerAuthorName and checks the rendered prompt
does not leak the newline-anchored injection or the fake mention markup.
TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the
conditional reply-command framing.

* refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer

Per PR #1581 feedback:

1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in
   runtime_config.go. It duplicated what workflow steps 4 and 5 already
   say ("Decide whether a reply is warranted", "Never @mention the
   agent you are replying to as a thank-you or sign-off"), so the
   signal lands the same without the extra ~7 lines of CLAUDE.md. The
   per-turn prompt preamble in prompt.go stays — that surface has no
   numbered workflow below it and would otherwise lose the
   silence-as-exit signal.

2. Delete execenv.SanitizePromptField + its test. Workspace agents are
   created by trusted team members, so the cross-agent name-injection
   surface it defended isn't realistic in the current trust model.

3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop
   populating them in daemon.go — they're no longer read by the
   execenv package. The same fields on daemon.Task stay because
   prompt.go still needs them to label the triggering author in the
   per-turn prompt.

Tests simplified to match the leaner shape: CLAUDE.md regression
guards now assert that the anti-loop phrases live in the numbered
workflow, and the sanitizer-specific tests are removed.
2026-04-24 01:39:12 +08:00
Jiayuan Zhang
977b0c0558 feat(agents): show profile card on agent avatar hover (#1577)
* feat(agents): show profile card on agent avatar hover

Hovering an agent avatar now opens a preview card with name, status,
runtime mode + connectivity, model, skills, and owner. Wired through
the shared ActorAvatar wrapper so every render site gets it; opt-out
via disableHoverCard in pickers and the agent's own detail header
where the card would be redundant or interfere with click selection.

* fix(agents): keyboard-focusable hover card + opt out on settings avatar

- Make the agent profile-card hover trigger focusable (tabIndex=0 with
  visible focus ring), so keyboard users can open the card. Drops
  cursor-default so the trigger inherits the parent control's cursor
  instead of fighting it.
- Disable the hover card on the agent settings avatar — it's a
  click-to-upload target on the agent's own settings page, where the
  card would be redundant and the trigger conflicted with the upload
  affordance.

* fix(agents): scope hover-card tab stop to standalone avatars only

Detect a focusable ancestor (link/button/role=button/tabindex>=0) at
mount and only flip the agent profile-card trigger to tabIndex=0 when
none exists. Avatars rendered inside an existing focusable parent (issue
list rows wrapped in AppLink, button-style cards, etc.) keep the trigger
unfocusable so they don't add redundant nested tab stops or bloat
keyboard navigation. Standalone avatars (e.g. comment author, issue
detail meta) remain keyboard-accessible with a focus-visible ring.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:53:55 +08:00
Black
17136742b9 fix(runtimes): fix dark mode chart visibility and invalid CSS color syntax (#1573)
All chart components used `hsl(var(--chart-X))` but `--chart-X` holds a
full oklch value, not bare HSL components — making the expression invalid
CSS. Browsers silently fell back to black, so bars/areas/heatmap cells were
invisible against the dark background.

- Replace `hsl(var(--chart-X))` with `var(--color-chart-X)` across all
  runtime chart components and the landing feature section
- Fix heatmap opacity using `color-mix(in oklch, ...)` instead of the
  invalid `hsl(var(--chart-3) / 0.3)` syntax; switch to foreground color
  so cells blend with the neutral theme in both light and dark mode
- Raise dark-mode chart-2 through chart-5 lightness values so they
  contrast clearly against the dark background

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 00:47:41 +08:00
Jiayuan Zhang
5e51f5b356 feat(desktop): add right-click context menu with clipboard actions (#1575)
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:11:16 +08:00
Jiayuan Zhang
13daede63e docs: remove Star History chart from README (#1574)
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:09:09 +08:00
Bohan Jiang
6107211a6e docs(selfhost): correct WebSocket guidance for LAN access (#1567)
The previous note claimed the frontend's auto-derived WebSocket URL
worked on LAN without extra configuration. It does not: Next.js
`rewrites()` only proxy HTTP requests, so the `Upgrade` handshake
required for WebSocket never reaches the Go backend, and real-time
features (chat streaming, live issue updates, notifications) silently
fail when accessing the app via a non-localhost host.

Replace the incorrect sentence with a dedicated subsection that points
users at the reverse-proxy recipe (already documented above, includes
the correct /ws Upgrade headers) and, for setups without a proxy,
documents the build-time NEXT_PUBLIC_WS_URL + selfhost.build.yml
override path.

Refs: GH #1522
2026-04-23 18:25:02 +08:00
Naiyuan Qing
044d1443b5 fix(issues): keep reply editor expand icon muted on focus (#1565)
The expand button relied on the parent row's inherited color, which
flipped to text-foreground via group-focus-within while the editor was
focused. The attach and submit buttons set text-muted-foreground on
themselves and stayed muted regardless of focus, so expand was the only
one changing color — inconsistent with the "default muted" convention
the other icon-buttons in this editor follow.

Give expand its own text-muted-foreground and drop the now-unused color
classes from the button row container.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:05:36 +08:00
Bohan Jiang
8f10741a4d feat(daemon/gc): tighten GC defaults + flex duration suffix (#1559)
* feat(daemon/gc): tighten GC defaults + flex duration suffix

Driven by user feedback in #1539 (40 GB VPS filling within 24h of heavy
AI-coding usage): the existing TTLs were sized for desktop/laptop
deployments and are too lenient for small-disk, long-running daemons.

- GCTTL: 5d → 24h. Done/canceled issues almost never need a multi-day
  grace period in AI-coding workflows.
- GCOrphanTTL: 30d → 72h. Covers crash-leftover and pre-GC directories
  without a month-long wait.
- Issue-deleted orphans (API returns 404) are now cleaned on the next GC
  cycle regardless of mtime. The issue row is gone; there is nothing
  left to protect.
- parseFlexDuration: accept a `d` (day) suffix in addition to the stdlib
  time.ParseDuration syntax. MULTICA_GC_TTL=5d now works; previously only
  120h was accepted.

* fix(daemon/gc): address review — 404 safety + decimal/overflow in duration parser

Two issues flagged in PR review:

1. 404-immediate-clean is unsafe. The /gc-check endpoint returns 404 for
   both "issue deleted" AND "daemon token has no access to the workspace"
   (anti-enumeration, see requireDaemonWorkspaceAccess). Clean-on-404
   would let a scoped-down daemon token wipe taskDirs whose issues are
   still live. Restore the mtime gate against GCOrphanTTL. With the new
   72h default we still shrink the original 30d window dramatically
   without the cross-workspace hazard. Lock the behavior in with a new
   test that asserts a recent 404 is skipped.

2. parseFlexDuration mishandled decimals and swallowed Atoi errors:
   "0.5d" → 7m12s (regex matched only the "5d"), "1.5d" → 1h7m12s,
   and 20+ digit day values Atoi-errored silently to 0. Match the full
   decimal number with `\d*\.\d+|\d+` and parse with ParseFloat so
   fractional days and oversized inputs both go through
   time.ParseDuration correctly — fractions as sub-hour durations,
   overflow as a returned error.
2026-04-23 17:40:09 +08:00
Bohan Jiang
cbe0cbef56 fix(daemon): retry local-skill reports on transient server errors (#1561)
Review follow-up on PR #1557: the server-side change started returning
500 when the store write failed, but the daemon's handleLocalSkillList /
handleLocalSkillImport were discarding the ReportLocalSkill*Result error
return. Net effect was a silent drop — the daemon moved on, the request
stayed in "running" on the server, and the user saw the same "daemon did
not respond within 30 seconds" timeout the store refactor was supposed
to kill.

Fix: route both report calls through reportLocalSkillResultWithRetry,
which retries on 5xx + network errors with 0 / 0.5s / 2s / 4s backoff
(total ~6.5s, well inside the 60s server-side running timeout), stops
on 4xx (request expired / cross-workspace rejection — retry won't help),
bails on context cancel, and logs Error on exhaustion so ops has a
footprint to grep for.

Tests (server/internal/daemon/local_skill_report_test.go, 6 new cases):
- 500 twice then success -> 3 attempts, second retry lands
- 404 -> exactly 1 attempt (permanent, no retry)
- import 502 then success -> 2 attempts
- All-500 -> burns through all backoff slots then gives up with ERROR log
- Context cancel mid-backoff -> exactly 1 attempt, cancellation logged
- Smoke: report paths hit /api/daemon/runtimes/<rt>/local-skills{,import}/<req>/result

localSkillReportBackoffs is var-assignable so tests can swap in zero-delay
schedules without paying real sleep latency.
2026-04-23 17:39:20 +08:00
Naiyuan Qing
502add4bd1 fix(issues): restore compact single-line reply editor, keep expand overlap fix (#1562)
#1558 fixed the expand button covering trailing text, but also collapsed
the reply editor's "empty = 1 line, has content = 2 lines" behavior by
making the button row a permanent flex sibling below the editor.

Restore the original absolute-positioned button row on both editors:

- comment-input: back to `pb-8` container + `absolute bottom-1 right-1.5`
  buttons (pre-#1558 layout; never had the overlap bug).
- reply-input: absolute buttons + `pb-7` gated on `!isEmpty || isExpanded`.
  Empty → single-line compact; any content → two-row layout with buttons
  below text (no overlap by construction).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:37:36 +08:00
affe (Yufei Zhang)
5ef957ca1b fix(skills): resolve aliased skills.sh imports (#1432)
* fix(skills): resolve aliased skills.sh imports

* fix(skills): harden alias fallback scan
2026-04-23 17:33:30 +08:00
Kagura
6d9ca9de93 fix(daemon): suppress agent terminal windows on Windows (#1474)
* fix(daemon): suppress agent terminal windows on Windows (#1471)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add hideAgentWindow to detectCLIVersion and avoid SysProcAttr overwrite

- Add missing hideAgentWindow(cmd) call in detectCLIVersion (claude.go:554)
  so --version checks don't flash console windows on Windows.
- Refactor hideAgentWindow to preserve existing SysProcAttr fields
  instead of overwriting the entire struct.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 17:23:00 +08:00
Naiyuan Qing
e994d77982 feat(help): mark external links with arrow, move Feedback last (#1560)
Add an ArrowUpRight glyph next to Docs and Change log to signal they
open externally, and reorder so Feedback (internal modal) sits at the
bottom.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:18:18 +08:00
Bohan Jiang
ad803b86ec fix(skills): shared-state runtime local-skill stores (MUL-1288) (#1557)
* fix(skills): shared-state runtime local-skill stores (MUL-1288)

Fixes the bug Bohan surfaced on MUL-1288: behind prod's multi-node API the
runtime-local-skill list/import flow would intermittently time out or 404.
Root cause: LocalSkillListStore and LocalSkillImportStore were per-process
sync.Mutex+map, so when the frontend POST, the daemon heartbeat and the
frontend GET landed on different API instances, each saw a different
pending set. Confirmed against production daemon logs — the failed
request_id never showed up in the daemon's "runtime local skills
requested" log, even though other requests around the same window worked.

Per Yushen's guidance (server must stay stateless; state lives in
storage), migrate both stores to Redis so every node agrees on the same
pending set.

What changed
- LocalSkillListStore / LocalSkillImportStore are now interfaces. Methods
  take context.Context and return error.
- InMemoryLocalSkill{List,Import}Store — renamed from the existing types,
  kept as the default for single-node dev and the in-process test suite.
- RedisLocalSkill{List,Import}Store — new. Keyed on
  mul:local_skill:{list,import}:<id> (JSON record, TTL = retention), with
  a per-runtime ZSET mul:local_skill:{list,import}:pending:<runtime_id>
  (score = created_at UnixNano) providing cross-node ordering. PopPending
  wins the claim via ZREM == 1, so concurrent pops from different nodes
  never return the same request twice.
- NewRouter gets an optional *redis.Client; when non-nil it swaps in the
  Redis-backed stores. main.go hoists the existing Redis client (already
  used by the realtime relay) so both subsystems share one client.
- Handler fields flip to interface types; handler.New still constructs
  in-memory stores by default.
- Daemon heartbeat's PopPending call sites thread r.Context() through so
  Redis operations inherit request cancellation. Errors warn instead of
  poisoning the heartbeat response.

Tests
- Existing in-memory tests updated for the new signatures (ctx + error).
- New runtime_local_skills_redis_store_test.go covers:
  - Create/Get/Complete round trip preserves skills payload
  - PopPending across two *store instances sharing one rdb (the exact
    regression: node A creates, node B pops)
  - N concurrent PopPending on one record => exactly one winner
  - Pending-timeout threshold transitions the record and removes the zset
    member so a later PopPending doesn't return a timed-out request
  - Import store round-trips CreatorID (which is json:"-" on the public
    struct — needs a Redis envelope so ReportLocalSkillImportResult can
    still attribute the created Skill)
  - Per-runtime isolation — a PopPending for runtime B does not disturb
    A's pending zset
- Tests skip gracefully if REDIS_TEST_URL is unset; CI now spins up a
  redis:7-alpine service and exports the URL so the suite actually runs
  there.

Out of scope
PingStore / UpdateStore / ModelListStore have the same shape and the
same latent bug (they just fire rarely enough to have gone unnoticed).
Migrating them to Redis is a follow-up — MUL-1288 is specifically the
local-skills break Bohan is blocked on.

* fix(skills): atomic Redis claim + surface store write failures (PR #1557 review)

Two real gaps GPT-Boy flagged:

1. RedisLocalSkill{List,Import}Store.PopPending was doing ZREM then SET as
   two separate round-trips. If the SET failed for any reason — transient
   Redis error, context cancellation, pod getting SIGKILL'd mid-call — the
   request was already gone from the pending zset but the stored record
   still said "pending", and no subsequent PopPending would re-dispatch
   it. Exactly the "request disappears" class of bug this PR is supposed
   to kill.

   Fix: push the claim into a Lua script so Redis runs ZREM + SET as one
   atomic unit. If ZREM returns 0 (another node won the race), SET is
   skipped and the caller retries.

2. ReportLocalSkill{List,Import}Result handlers were logging Complete/Fail
   store failures at Warn and still returning 200 OK. That made the
   daemon think the report landed when it hadn't, leaving the request
   stuck in "running" until the server-side timeout and — worse for the
   import flow — leaving the just-created Skill row orphaned in Postgres
   so every retry collided with the unique-name constraint.

   Fix: escalate to Error + return 500 so the daemon (and monitoring) can
   see the write failed. For the import flow, Complete failure after the
   Skill row is already committed also triggers a best-effort DeleteSkill
   so a daemon retry lands on a clean slate instead of hitting
   "a skill with this name already exists" forever.

Tests
- New TestRedisLocalSkillListStore_PopPendingAtomicClaim asserts the
  happy-path invariant: after one PopPending the record is "running"
  AND a second PopPending returns nothing. Deliberately does NOT poke
  Redis internals directly so the test survives any future key-layout
  refactor.
- Existing cross-instance / concurrent / timeout / per-runtime tests
  continue to pass against the Lua-based claim path (verified locally
  against a scratch redis-server; 8/8 Redis tests green).
2026-04-23 17:07:34 +08:00
Bohan Jiang
b51d1c4dc3 fix(cli): make browser-login work from a machine that isn't the server (#1556)
* fix(cli): make browser-login work from a machine that isn't the server

The #923 callback host fix only worked when the CLI and the self-hosted
server ran on the same box. In a cross-machine setup — `multica login`
from a laptop against a self-hosted server on a NAS — the flow silently
wedged on two issues:

1. The callback host was derived from `--app-url`, so the `cli_callback`
   URL pointed at the server's IP and the browser could never reach
   the CLI's local listener on the laptop. The OAuth token never came
   back and subsequent `/api/workspaces` calls 401'd on stale state.

2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket.
   Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and
   get "connection refused" even when the URL is otherwise correct.

Changes:

- Derive the callback host from the CLI's own outbound interface by
  dialing the server (UDP, no packets sent — just asks the kernel which
  source IP it would use). Falls back to loopback for public app URLs
  and to the app IP for offline detection.
- Add `--callback-host` flag on `login` and `setup self-host` so
  reverse-proxy / FQDN users can override auto-detection — this is the
  follow-up @hassaanz asked for on #923.
- Pin the callback listener to `tcp4` so macOS never lands on an
  IPv6-only socket.
- `multica setup self-host`: when the user explicitly passes a remote
  `--server-url` but omits `--app-url`, infer app URL from the server
  host and warn instead of silently defaulting to `localhost:3000`.

Unit tests cover the binding-decision matrix (public, localhost, same-
machine LAN, cross-machine LAN, outbound-detect failure, flag override)
and the new setup helpers.

Reported by @RafeRoberts in #1494 with very clear repro details.

* fix(cli): prompt for app_url instead of guessing on remote server_url

Per GPT-Boy's review on MUL-1260: deriving app_url as
http://<server-host>:3000 breaks for the common api.example.com +
app.example.com split and for https-fronted deploys — the setup flow
would still open a broken login URL, just slightly later.

Replace the guess with an interactive prompt. If the user hits enter
(or stdin is unavailable), fail loudly with a clear usage hint instead
of proceeding with bad data.
2026-04-23 16:41:29 +08:00
Naiyuan Qing
efc08a1e37 fix(issues): stop expand button from covering text in comment/reply editors (MUL-1297) (#1558)
The comment and reply editors positioned their three trailing buttons
(expand, attach, submit) with `absolute` and relied on `pr-14` /
`pb-8` magic numbers to reserve space. The reserved 56px is smaller
than the actual 80px button row, so the leftmost button (expand)
visibly overlaps the trailing characters of a long line of text.

Restructure the button row as a normal flex sibling below the editor.
Text can no longer flow under the buttons, and the layout no longer
needs the `pr-14` hack, `pb-8` padding, or the ResizeObserver that
toggled `pb-7` when content overflowed.

Also align the expand button in comment-input with the reply-input
version (`h-6 w-6` + `h-3.5 w-3.5` icon) so the two entry points
match.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:38:04 +08:00
Bohan Jiang
6fd1255873 feat(runtimes): remove Test Connection / runtime ping feature (#1554)
* feat(runtimes): remove Test Connection / runtime ping feature

The Test Connection action invoked a real single-turn agent run to verify
runtime connectivity. In practice it was expensive (reuses none of the
normal task exec env, so it also gave misleading results) and low value —
daemon heartbeat + Online status already covers the "is the runtime
alive" question. Dropping the whole end-to-end probe path:

- deletes server handler and in-memory PingStore
- drops pending_ping from the heartbeat response and daemon poll loop
- removes daemon.handlePing, PendingPing, ReportPingResult
- removes the CLI `multica runtime ping` command
- removes the PingSection UI block and RuntimePing types / api methods

* docs: fix runtime CLI subcommand list in product-overview
2026-04-23 16:18:21 +08:00
Naiyuan Qing
6c72c71e3e feat(analytics): add onboarding_runtime_detected event on desktop Step 3 (#1553)
Answers "did the user have an AI CLI installed locally when they hit
Step 3" — currently unanswerable from the existing funnel because the
bundled daemon fails to register at all when zero CLIs are on PATH, so
`runtime_registered` is silent on that cohort. Splits the 40% of
`completion_path=runtime_skipped` into "had CLIs, skipped anyway" vs "no
CLIs available, had no choice" — the two cases need opposite product
fixes.

Fires once per Step 3 mount in `step-runtime-connect.tsx` (desktop
only), when the scanning phase resolves — either immediately on first
runtime registration or after the 5 s empty timeout. Reports
`runtime_count`, `online_count`, sorted `providers`, convenience
booleans (`has_claude` / `has_codex` / `has_cursor`), and `detect_ms`.
Also writes `has_any_cli` + `detected_cli_count` via `$set` as cohort
signals.

Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web users
don't run the bundled daemon, so their runtime list can reflect
daemons on other machines and would corrupt the
"CLI installed locally" signal.

Refs MUL-1250.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:56:55 +08:00
Jiayuan Zhang
83a3683d07 feat(landing): add sticky date navigation to changelog page (#1552)
* feat(landing): add sticky date navigation to changelog page

Adds a right-side "On this page" nav that lists every release date and
scroll-spies the active entry as the user reads through the changelog.
Dates are formatted per locale (e.g. "April 22" / "4月22日").

* feat(landing): move changelog date nav to left as timeline sidebar

Moves the date navigation from the right to the left and restyles it
as a grouped timeline:

- Releases are grouped under a month-year header ("April 2026").
- A vertical rail connects a dot per release; the active dot is filled
  with a soft halo ring, the row text goes full-opacity + semibold.
- Clicking a date smooth-scrolls to the release and pins the hash; a
  short nav lock suppresses scroll-spy flicker while the page animates.
- Sidebar is sticky up to viewport height, scrollable when there are
  many releases; on <lg the sidebar collapses and content falls back
  to the existing centered layout.
- Entry headers now render the full localized date for clarity.

Label changed from "On this page" / "本页目录" to "All releases" /
"历史版本" to match the new nav-style role.

* fix(landing): align changelog nav day/version columns

Reserve a fixed-width right-aligned slot for the day number so
single-digit days (e.g. "1", "9") don't shift the version column.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-23 15:54:06 +08:00
Bohan Jiang
fae3afee79 fix(agents): drop auto-loading Local Runtime Skills section from Skills tab (#1551)
* fix(agents): drop auto-loading Local Runtime Skills section from Skills tab

Every visit to an agent's Skills tab fired POST
/api/runtimes/<id>/local-skills + a polling GET, which:

- Created noise on every tab open (the section was rarely the user's
  reason for entering the tab — they came in for workspace skills).
- Currently 404s under the dev backend's multi-replica deploy because
  the runtime-local-skills request store is in-process; the polling
  GET frequently lands on a different replica than the POST. The
  protocol fix is tracked separately; this PR just stops the
  unsolicited polling.

Removes the entire `Local Runtime Skills` inline section, the
`runtimeLocalSkillsOptions` query, and the per-skill Import dialog
mount on this tab. Users who want to import a local skill go through
the Skills page's `+ Add Skill` → `From Runtime` tab — the same flow
that handles all other skill creation, only triggered explicitly.

Top blue callout stays — still accurate: local runtime skills are
auto-available to the agent, importing creates an editable workspace
copy.

* test(agents): replace stale Local Runtime Skills assertion with negative case

The previous test required the inline section + auto-loading runtime
local skills query, both removed in this PR. Replace it with a
regression test that asserts the section is gone, the per-row import
button is gone, and the top informational callout still renders so we
know the tab body actually mounted.

Drops the now-unused @multica/core/runtimes mock; if a future change
re-introduces that import, the missing mock would surface immediately.
2026-04-23 15:47:29 +08:00
LinYushen
91424752ac feat(realtime): phase 0 — extract Broadcaster interface + add metrics (MUL-1138) (#1429)
* feat(realtime): phase 0 — extract Broadcaster interface + add metrics

Phase 0 of the WebSocket horizontal-scaling plan tracked in MUL-1138.
This change is intentionally behavior-preserving: it sets up the seams
needed for later phases (subscribe/unsubscribe protocol, scope-level
fanout, Redis Streams relay) without altering any wire protocol or
producer call sites.

What changed
- New realtime.Broadcaster interface covering the three fanout methods
  producers already use on *Hub (BroadcastToWorkspace, SendToUser,
  Broadcast). *Hub continues to satisfy it; a future Redis-backed
  implementation can be dropped in without touching listeners.
- registerListeners now depends on realtime.Broadcaster instead of
  *realtime.Hub, isolating the bus → realtime fanout layer behind an
  interface.
- New realtime.Metrics singleton with atomic counters: connects,
  disconnects, active connections, slow-client evictions, total
  messages sent/dropped, and per-event-type send counters. Wired into
  Hub register/unregister/broadcast paths and into every listener.
- New GET /health/realtime endpoint returning a JSON snapshot of the
  metrics so we can observe baseline fanout pressure before phase 1.

Why phase 0 first
GPT-Boy's only-Redis plan and CC-Girl's review both call out the same
prerequisite: get a Broadcaster seam and visibility in place before
introducing scope-level subscriptions or a Redis relay. Doing this as
a standalone step keeps each later PR focused and trivially revertable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(realtime): only-Redis fanout — scopes, subscribe protocol, Redis Streams relay (MUL-1138)

Implements the final-version plan agreed in MUL-1138 on top of phase 0:

* Hub: 4 scope types (workspace/user/task/chat), per-client subscription
  set, subscribe/unsubscribe WS frames, ScopeAuthorizer hook for
  task/chat scope auth, first/last-subscriber callbacks for the relay,
  workspace+user auto-subscribe on connect.
* RedisRelay: Broadcaster impl that XADDs every event into
  ws:scope:{type}:{id}:stream and XREADGROUPs only the scopes for which
  this node has live subscribers. Per-node consumer group, heartbeat,
  stale-consumer sweeper, MAXLEN cap, lag/disconnect metrics.
* Listeners: route task:* events to ScopeTask, chat:* events to
  ScopeChat; workspace remains the default for everything else.
* events.Event: optional TaskID / ChatSessionID hints so the listener
  layer can pick the right scope without re-parsing payloads.
* Handler: publishTask / publishChat helpers; chat + task message
  publishers updated to use them.
* main.go: when REDIS_URL is set, wrap the hub with NewRedisRelay and
  pass the relay (instead of the hub) to registerListeners. A
  db-backed ScopeAuthorizer enforces that task/chat subscribes belong
  to the caller's workspace.
* Metrics: per-scope subscribe/deny counters, redis connect state, node
  id, lag/dropped counters surfaced via /health/realtime.

Behavior in single-node mode (REDIS_URL unset) is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(realtime): address PR #1429 review must-fix items (MUL-1138)

- listeners: keep task/chat events on workspace fanout until the WS
  client supports scope-subscribe + reconnect-replay. Routing them
  through BroadcastToScope today (without any client subscriber) would
  silently drop every chat / task message and break the live timeline,
  chat unread badges, and pending-task UI. The server-side scope infra
  (Hub subscribe/unsubscribe, ScopeAuthorizer, Redis Streams relay)
  stays in place so flipping the switch in the client follow-up PR is
  a one-line change.

- scope_authorizer: ScopeChat now enforces CreatorID == userID, mirroring
  the HTTP layer (handler/chat.go: GetChatSession / SendChatMessage /
  MarkChatSessionRead). Without this, any workspace member who learned a
  session_id could subscribe to chat:message / chat:done /
  chat:session_read for a peer's private chat. The same creator-only
  check is applied to ScopeTask when the task is a chat task
  (task.ChatSessionID set). Issue tasks remain workspace-scoped.

- Refactor scope authorizer to depend on a narrow scopeAuthQuerier
  interface so its decisions can be unit-tested without a live DB.

- Add tests:
  * listeners_scope_test.go pins the workspace-fanout fallback for
    task:message / task:progress / chat:message / chat:done /
    chat:session_read.
  * scope_authorizer_test.go covers chat creator-only access, chat-task
    creator-only access, and issue-task workspace-only access (creator
    allowed, peer denied, cross-workspace denied, missing session
    denied, empty userID denied).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
2026-04-23 13:36:55 +08:00
LinYushen
d97aec83d7 fix: pass model to Hermes ACP and add hermes to InjectRuntimeConfig (#1203)
* fix: pass model to Hermes ACP session/new and add hermes to InjectRuntimeConfig

- hermes.go: include opts.Model in session/new params so Hermes uses
  the configured model instead of its default (fixes local LLM failures)
- runtime_config.go: add "hermes" to the AGENTS.md provider list so
  Hermes receives the Multica runtime instructions and skill discovery

Fixes: https://github.com/multica-ai/multica/issues/1195

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hermes): drop false native-skill claim and add regression tests

The previous change added 'hermes' to the 'skills discovered automatically'
branch of buildMetaSkillContent, but resolveSkillsDir has no Hermes case so
skills still land in the .agent_context/skills/ fallback. AGENTS.md ended up
claiming native discovery while the files were somewhere else, which would
mislead Hermes (and future debuggers).

- Move 'hermes' to the fallback branch alongside 'gemini' so AGENTS.md points
  Hermes at .agent_context/skills/ — matching where writeContextFiles actually
  writes them.
- Extract buildHermesSessionParams so the session/new payload is unit-testable.
- Add regression tests covering:
  * buildHermesSessionParams includes/omits 'model' correctly
  * InjectRuntimeConfig('hermes') writes AGENTS.md with the fallback hint
  * writeContextFiles('hermes') writes skills to .agent_context/skills/

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
2026-04-23 12:43:30 +08:00
Naiyuan Qing
95bcffef8c fix(desktop): expose search params from root navigation adapter (#1547)
DesktopNavigationProvider stubbed `searchParams` to an empty
URLSearchParams, so any shell-level consumer of useNavigation() that
looked at query params read blanks. The miss surfaced in focus-mode:
on /inbox?issue=<id>, ChatWindow's useRouteAnchorCandidate couldn't
see the selection, so the Focus button stayed disabled.

Mirror the full location (pathname + search) from the active tab's
router — same subscription pattern TabNavigationProvider already uses
~30 lines below. InboxPage itself was fine because it's rendered
inside TabNavigationProvider; the bug only hit components mounted at
the shell root (ChatWindow, ChatFab, and any future sibling).

No test: the fix is an identical copy of a production-shipped pattern
in the same file, and the mock surface needed to exercise the adapter
(useActiveTabRouter + memory router + tab store) exceeds the fix
itself. Verified via pnpm typecheck across all packages.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:02:43 +08:00
Naiyuan Qing
d6e7824ff1 feat(feedback): in-app feedback flow + Help launcher (#1546)
* feat(feedback): add in-app feedback flow and Help launcher

Replaces the duplicated bottom-sidebar user popover and "What's new" links
with a single Help menu (Docs / Feedback / Change log) pinned to the
sidebar footer. Feedback opens a rich-text modal that POSTs to a new
/api/feedback endpoint; submissions land in a dedicated feedback table
with per-user hourly rate limiting (10/hr) to deter spam without adding
middleware infrastructure. User identity (avatar + name + email) moves
into the workspace dropdown header so the sidebar is no longer visually
redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(feedback): harden submit path and cap request body

- Read editor markdown via ref at submit time instead of debounced state,
  so ⌘+Enter immediately after typing doesn't drop the last keystrokes.
- Block submission while images are still uploading; toast prompts the
  user to wait instead of silently sending markdown with blob: URLs
  that get stripped.
- Cap /api/feedback request body at 64 KiB via MaxBytesReader so an
  authenticated client can't bloat the metadata JSONB column with an
  oversized url field.
- Add Go handler tests covering happy path, empty-message rejection,
  and the hourly rate limit boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analytics): instrument feedback funnel

Adds two events pairing frontend intent with backend conversion so we
can compute a completion rate for the in-app Feedback modal:

- `feedback_opened` (frontend) — fires once on FeedbackModal mount.
  Source is currently always "help_menu" but the type is a union so
  future entry points have to extend it explicitly. Workspace id is
  attached when present.
- `feedback_submitted` (backend) — fires from CreateFeedback after the
  DB insert succeeds and the hourly rate-limit check has passed.
  Message content itself is never sent to PostHog; the event carries
  a coarse length bucket (0-100 / 100-500 / 500-2000 / 2000+), an
  image-presence flag, and the client platform / version pulled from
  X-Client-* headers via middleware.ClientMetadataFromContext.

Affects no existing funnel; seeds a new Feedback funnel for product
triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:35:55 +08:00
Mack
f2ba087f74 fix(editor): preserve nested ordered lists through readonly render (#1512)
Default @tiptap/markdown serializer emitted nested list items with 2-space indent, but CommonMark (remark-gfm) requires ≥3 spaces under a `1.` marker — so ReadonlyContent (autopilot detail / issue / comment) flattened nested ordered lists, with third-level items glued onto their parent line. Configure Markdown extension with indentation.size = 3.

Closes #1510
2026-04-23 07:08:19 +08:00
Naiyuan Qing
059356cce7 docs(claude-md): trim implementation archaeology, keep rules (#1540)
CLAUDE.md is loaded into context every conversation; verbose race-condition
post-mortems and code-organization rationales rot fast and crowd out the
actionable rules they were meant to support. Strip the archaeology, keep the
load-bearing constraints.

- Workspace identity singleton + destructive ops (~22 -> 11 lines): keep the
  "must call setCurrentWorkspace(null, null) when leaving context" rule and
  the 4-step destructive order; drop the three-way race autopsy (already
  documented inline in workspace-tab.tsx where it belongs).
- Drag region (~27 -> 3 lines): keep "every full-window desktop view must
  mount <DragStrip /> as first flex child"; drop hit-testing rationale,
  canonical-file inventory, and useImmersiveMode escape-hatch trivia.
- UX vs platform chrome (~3 -> 0 lines): delete entirely. The rule
  duplicates "Cross-Platform Development Rules" above; the rest is purely
  why-we-organized-it-this-way narrative.

Common Zustand footguns kept as-is - both items are real rules (stable
selector references, hooks accepting wsId as parameter), not archaeology.

Net: -36 lines, no rule lost.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 06:57:10 +08:00
Bohan Jiang
7375bda9b5 fix(landing): scope landing route to always-light palette (MUL-1277) (#1537)
* fix(landing): scope landing route to always-light palette

The landing page sections use hardcoded light colors (bg-white / #0a0d12),
but shared components rendered inside — notably CloudWaitlistExpand on
/download — use semantic tokens that flip to dark values under next-themes'
`.dark` class, producing a mismatched dark card on an otherwise light page
when the user's OS is in dark mode.

Add a `.landing-light` class on the landing layout wrapper that re-declares
all color tokens to their light values for the subtree, so nested
token-driven components stay in lockstep with the hardcoded palette.

* test(agent): serialize fake-executable writes to avoid ETXTBSY on CI

TestKimiBackendInvokesACPSubcommand (and its Kimi/Codex siblings) write a
shell script to a per-test TempDir and then fork/exec it. With t.Parallel()
enabled across the package, a concurrent goroutine's fork can inherit the
still-open write fd to another test's new executable; Linux then rejects
the subsequent exec with ETXTBSY (seen as
  fork/exec /tmp/.../kimi: text file busy
on GitHub Actions).

Introduce writeTestExecutable, which holds syscall.ForkLock.RLock across
OpenFile→Write→Close. Fork (which takes ForkLock.Lock) cannot run while we
hold RLock, so no sibling fork inherits our write fd. Ran the three callers
with -count=10 under -p=1 and the full package with no failures.
2026-04-23 01:52:46 +08:00
Bohan Jiang
9dcc082920 docs(handler): note that GetConfig is public-only and what may be returned (#1538)
Adds a doc comment on GetConfig spelling out that the endpoint is mounted on
the unauthenticated route group (so the login page can fetch GoogleClientID /
AllowSignup before the user is signed in) and that only instance-level public
fields may be added. Prevents accidentally returning user- or tenant-scoped
data from this handler in the future.
2026-04-23 01:51:59 +08:00
Black
98edc6b9ff fix(auth): make /api/config publicly accessible (#1530) 2026-04-23 01:49:21 +08:00
Jiayuan Zhang
88b892f1ca fix(desktop): preserve last-opened workspace on app start (MUL-1269) (#1515)
The workspace query defaults `data` to `[]` before the first fetch, so the
bootstrap effect ran with an empty valid-slug set, wiped the persisted
`activeWorkspaceSlug`, then fell back to `workspaces[0]` once the real list
arrived — dropping the user on the default workspace on every launch.

Gate the effect on `workspaceListFetched` so validation runs only against
the real list, and re-read the store after `validateWorkspaceSlugs` to
avoid acting on a stale snapshot.

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-23 00:20:38 +08:00
Bohan Jiang
2cced51d64 docs(changelog): publish v0.2.14 + v0.2.15 release notes (#1517)
* docs(changelog): publish v0.2.14 + v0.2.15 release notes

Summarises the 25 commits shipped today across both releases for the public changelog page, in English and Chinese.

* docs(changelog): merge v0.2.14+v0.2.15 into one entry, trim, reclassify Gemini as fix

Per review: today's two releases read better as one set of notes; tightened
bullets; moved the Gemini 3 runtime-list update from Features to Fixes.

* docs(changelog): drop last 3 features from v0.2.15 entry per review
2026-04-22 20:02:42 +08:00
Bohan Jiang
6717db1fad feat(agents): surface task source on AgentTaskResponse + use it in Tasks tab (#1455)
Follow-up to #1453. That PR fixed the Tasks tab crash by filtering empty
issue_id out of the detail lookup and rendering a neutral "Task without
linked issue" label, but every issue-less task — chat-spawned or
autopilot-spawned — looked the same. The server already stores the
origin in `agent_task_queue.chat_session_id` / `autopilot_run_id`; only
the HTTP serializer was dropping them.

Server:
- `taskToResponse` now populates `ChatSessionID` and the new
  `AutopilotRunID` on `AgentTaskResponse`. Backward compatible: both
  omit when UUID is invalid, and existing clients ignore unknown
  fields.

Types:
- `AgentTask` (TS) gains `chat_session_id?` + `autopilot_run_id?` and a
  comment clarifying when `issue_id` is empty.

Tasks tab:
- Row label for issue-less tasks is picked from the populated source
  field: "Chat session" for chat tasks, "Autopilot run" for autopilot
  tasks, "Task without linked issue" as the neutral fallback. Rows stay
  inert (no anchor) in all three cases; existing issue-linked path is
  unchanged.

Tests:
- Two new regression tests assert the chat and autopilot labels render
  correctly and neither row becomes an anchor. Existing neutral-label
  test stays as the "neither source populated" case.
2026-04-22 19:26:57 +08:00
Dhruv-89
2a248b8548 fix(openclaw): raise agent discovery timeout to 30s (#1495)
'discoverOpenclawAgents' runs several 'openclaw' subprocesses under one
context; 5s was too short on cold starts or under load, causing empty
listings in the model picker. Increase the per-discovery cap to 30s.
2026-04-22 19:24:57 +08:00
Naiyuan Qing
f84d216794 fix(views): restore issue-mention class on <a> for mention card (#1516)
PR #1502's IssueChip extraction moved the `issue-mention` class from the
outer <a> into IssueChip's inner <span>, breaking three consumers that
select on `<a>.issue-mention` directly:

- `.rich-text-editor a.issue-mention` underline-exemption in
  content-editor.css (stopped matching -> mentions in editor gained a
  spurious underline).
- `link-hover-card.tsx` classList check that suppresses the URL preview
  on issue mentions (stopped matching -> hover card wrongly pops up
  over mention chips).
- Tailwind Typography prose (`prose a { text-decoration: underline }`)
  covers a separate path — markdown bubbles in chat. prose's specificity
  (0,1,1) beats `.no-underline` (0,1,0), so `not-prose` is the right
  escape hatch on the AppLink.

Put `issue-mention` back on the <a> in both wrappers (IssueMentionCard
and the editor's MentionView), and add `not-prose` only to the markdown
wrapper. IssueChip's BASE_CLASS keeps `issue-mention` too (inert on the
span; removing it is a separate scope that needs a full consumer audit).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:13:14 +08:00
Naiyuan Qing
101da19b02 feat(download): fall back to previous release within 1h freshness window (#1514)
New /download visitors were seeing grayed-out macOS buttons in the 20-ish
minutes after a tag push because CI only builds Linux/Windows — Mac is
still packaged manually and uploads tens of minutes later. Swap the
`/releases/latest` fetch for `/releases?per_page=2` and, when the latest
release is under an hour old, render the previous (fully-populated)
release instead. After the freshness window, page auto-switches to latest.

Frontend-only change — GitHub "latest" marker, electron-updater, and
homebrew paths are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:05:36 +08:00
Bohan Jiang
dc8096fb6e fix(agent): expose Gemini 3 + CLI aliases in Gemini runtime model list (#1508)
Gemini CLI has no `models list` subcommand, so Multica can't do real
dynamic discovery. Instead, swap the static catalog from fixed version
names (2.0/2.5 only) to the CLI's own aliases (`auto`, `pro`, `flash`,
`flash-lite`, `auto-gemini-2.5`) plus explicit pins for Gemini 3
preview and 2.5 variants. Aliases are resolved inside the Gemini CLI
per user entitlement + quota, so new model releases light up without
a Multica redeploy. Default is `auto`, matching Google's recommended
selection.

Fixes multica-ai/multica#1503.
2026-04-22 19:02:07 +08:00
LinYushen
2dae42f58a Tighten Vercel ignore rules (#1513) 2026-04-22 19:00:35 +08:00
Naiyuan Qing
f6dd47c944 fix(chat): disable focus button on pages without an anchor (#1509)
The focus toggle was only disabled when focusMode was already ON
*and* the current page had no anchor. Off-state on the same page
stayed clickable — clicking turned it on, and the button instantly
greyed out, making the missing fourth state visible.

Decouple "clickable" from focusMode: the button is disabled whenever
the current page has no anchor, regardless of the persisted on/off
preference. Both the chip render (context-anchor.tsx:173) and send
path (chat-window.tsx:176) already guard on candidate presence, so
leaving focusMode=true on an unanchorable page has no side effects —
the preference is preserved for the next anchorable page.

Tooltip now reads "Nothing to share with Multica on this page"
whenever the button is disabled, regardless of focusMode.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:57:44 +08:00
LinYushen
f98a67dd90 ci(release): build docker images natively per arch and merge manifests (#1507)
Multi-arch images were built on a single amd64 runner with QEMU
emulating arm64. The Next.js build (Dockerfile.web) under emulation
took 30+ minutes per release and was the long pole of the workflow.

Split each image build across two native runners (amd64 on
ubuntu-latest, arm64 on ubuntu-24.04-arm), push by digest, then
merge into a manifest list with docker buildx imagetools. QEMU is
no longer needed.

Backend and web each become a (matrix build + merge) pair, replacing
the previous single docker-images job. Per-platform GHA cache scopes
avoid cross-arch cache eviction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 18:28:54 +08:00
devv-eve
90ccd97469 fix: add .vercelignore for Vercel web deploys (#1505)
* fix: ignore non-web files in vercel deploy

* fix: keep docs app in vercel uploads

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-22 18:05:15 +08:00
Naiyuan Qing
180a534511 chore(docs): remove shipped plan and proposal docs (#1504)
All 15 files deleted here correspond to already-merged PRs —
their execution is done, their rationale lives in commit
messages and PR descriptions, and nothing in the tree references
them (grep across *.ts / *.tsx / *.go / *.mjs / *.json returns
zero hits).

Removed:
  docs/download-redesign.md               → PR #1500
  docs/download-positioning.md            → PR #1500
  docs/onboarding-redesign-proposal.md    → PR #1411
  docs/workspace-url-refactor-proposal.md → PR #1131 / #1138
  docs/plans/2026-04-07-tanstack-query-migration.md
  docs/plans/2026-04-08-board-dnd-rewrite.md
  docs/plans/2026-04-08-drag-upload-enhancement.md
  docs/plans/2026-04-08-image-view-enhancement.md
  docs/plans/2026-04-08-monorepo-extraction.md
  docs/plans/2026-04-09-desktop-app.md
  docs/plans/2026-04-09-monorepo-extraction.md
  docs/plans/2026-04-09-upload-attachment-fixes.md
  docs/plans/2026-04-15-workspace-slug-url-refactor.md
  docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
  docs/plans/2026-04-16-unify-workspace-identity-resolver.md

Empty `docs/plans/` directory goes with them (git drops empty
dirs automatically). Active, non-plan docs stay: analytics.md,
design.md, product-overview.md, codex-sandbox-troubleshooting.md.

Any future plan can live on a feature branch under
`.claude/plans/` (harness-scoped, not committed) or as a PR
description. No need to land them in-tree.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:49:07 +08:00
Naiyuan Qing
2d0916ee38 feat(chat): focus mode — share current page as context (#1502)
* refactor(views): extract IssueChip shared primitive from mention card

IssueMention (in editor NodeView) and IssueMentionCard shared 95% of their
markup — StatusIcon + identifier + title inside a bordered chip. They drifted
into two parallel implementations so changes had to be made in two places.

Extract the presentational chip into IssueChip. The navigable variants
(IssueMentionCard, the editor NodeView) become thin shells that layer
routing + cmd/shift behaviour onto the shared chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chat): add focus mode to share current page as context

Adds a Focus button next to the chat submit. When on, the chat auto-attaches
whatever the user is viewing (issue, project, or inbox-selected issue) as a
context prefix on outgoing messages, so the agent knows what "this" refers
to without the user pasting ids.

The attached object is derived from the route + react-query cache on every
render — no separate copy in state. Only the boolean focusMode is persisted
(global to the user, not per-workspace), matching the "my preference"
mental model.

The button has three visual states driven by two dimensions (focusMode +
whether the current route resolves to an anchorable object):
  - off:         ghost + muted, click turns on
  - on  + anchor: secondary (bright), click turns off
  - on  + none:  disabled (nothing to attach here)

The derived anchor renders above the input as a chip — IssueChip for issues,
a new ProjectChip for projects — wrapped in AppLink so the visual target
matches the clickable target (mirrors IssueMentionCard's hover + navigation).

Prefix format reuses the editor's mention markdown:
  Context: [MUL-1](mention://issue/<uuid>) — "Fix login bug"
  Context: Project "Authentication"

so the agent sees an identical token whether the user @-mentioned inline or
focus-mode attached. Backend is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:25:58 +08:00
Naiyuan Qing
5335edd50d feat(web): /download page + desktop promotion across landing, login, onboarding (#1500)
* docs(download): add redesign plan and copy positioning source of truth

Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): /download page with OS auto-detection

New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.

Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.

Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.

Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.

i18n copy sourced verbatim from docs/download-positioning.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA

Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.

Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.

cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web,auth): desktop promotion on login + solid landing hero download

LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.

Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.

Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web/download): anchor dark LandingHeader with relative wrapper

LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.

Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy

Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(onboarding): update platform-fork assertions for /download route

The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Merge origin/main into NevilleQingNY/download-redesign

Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.

Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.

typecheck passes across all 6 packages; all 166 views tests
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analytics): instrument download funnel across 5 surfaces + /download

Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.

Three new events, wired via `@multica/core/analytics`:

1. `download_intent_expressed` fires on any CTA pointing at
   /download. `source` splits the five surfaces cleanly; every
   authenticated emission also writes `platform_preference=desktop`
   on the person (same convention Step 3 already uses).

2. `download_page_viewed` fires once per /download mount after OS
   detect resolves. Carries `detected_os`, `detected_arch`,
   `detect_confident` (Chromium userAgentData vs UA fallback), and
   `version_available` so the Safari-on-Mac arm64-default cohort
   and GitHub-rate-limited degraded sessions are each isolable.
   Also $set_once's `first_detected_os/arch` on the person so every
   downstream event gains a platform dimension without re-emitting.

3. `download_initiated` fires on every installer click — Hero's
   primary CTA and each All Platforms matrix row. `primary_cta`
   splits hero-recommended from manual picks; `matched_detect`
   quantifies detect accuracy from the single event (no cross-join
   to download_page_viewed needed).

Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.

New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.

docs/analytics.md update lands in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(analytics): document download_intent_expressed / page_viewed / initiated

Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:25:01 +08:00
obsession
153e2b6245 Enhance OS architecture detection methods in install.ps1 (#1498) 2026-04-22 17:14:47 +08:00
Bohan Jiang
205e8c1e9c feat(analytics): client_type super-property + Desktop $pageview (MUL-1253) (#1490)
* feat(analytics): client_type super-property + Desktop $pageview (MUL-1253)

Register a `client_type` super-property ("desktop" | "web") plus optional
`app_version` inside `initAnalytics`, so every PostHog event from the
renderer can be split by client without relying on `$lib` (both Electron
and Next.js report "web"). `appVersion` flows in from `ClientIdentity`
via `CoreProvider` → `AuthInitializer`.

Add a Desktop `PageviewTracker` mounted in `DesktopShell` that fires
`$pageview` whenever the active tab's path changes, mirroring the Web
tracker. Restores the `/ → signup → workspace_created` funnel for the
desktop client and enables web-vs-desktop breakdowns.

* fix(analytics): preserve super-props on reset + cover overlay/login pageviews

Two blockers from PR review:

1. `posthog.reset()` wipes persisted super-properties, so after logout or
   account switch the next session's events silently dropped `client_type`
   and `app_version` until a full reload. Cache the set at init time and
   re-register it inside `resetAnalytics()` so the breakdown survives the
   auth transition. Added unit tests to pin the invariant.

2. Desktop `PageviewTracker` only watched the active tab path, which
   missed pre-workspace overlays (`/onboarding`, `/workspaces/new`,
   `/invite/<id>`) — those aren't tab routes on desktop — and also missed
   the logged-out `/login` state. Move the tracker to the app root and
   derive the visible path from `(user, overlay, activeTabPath)` with
   overlay > tab precedence so the `$pageview` stream matches the
   surface the user actually sees.
2026-04-22 17:02:58 +08:00
Naiyuan Qing
cd6bb48283 feat(autopilots): unified create/edit dialog with issue-modal layout (#1501)
Replace separate CreateAutopilotDialog / EditAutopilotDialog with a single
shared <AutopilotDialog mode="create"|"edit"> that mirrors the issue create
modal — dynamic sizing, expand/collapse, richtext Prompt, pill toolbar.

- Tiptap ContentEditor replaces plain textarea for Prompt; detail page
  renders description via ReadonlyContent for visual parity.
- Pills: Agent, Priority, Execution Mode, Schedule (Popover hosting
  TriggerConfigSection). 0/1/N trigger strategy: add on 0, edit inline on
  1, disabled with tooltip on 2+ (power users edit in detail page).
- Exposes priority + execution_mode at creation time (backend always
  supported them; old UI only offered them in Edit).
- parseCronExpression reverse-parses stored cron back to TriggerConfig for
  Edit-time prefill (with round-trip tests).
- PillButton extracted to packages/views/common for reuse across modals.
- DialogContent uses showCloseButton={false} so the shared header renders
  the Maximize + Close buttons next to the Rocket-prefixed breadcrumb.
- Conditional mount at call sites (`{open && <AutopilotDialog/>}`) keeps
  state fresh on each open.
- Schedule dirty detection compares cron+timezone payloads vs mount
  snapshot, and edit-mode submits against a snapshotted trigger id so
  concurrent WS refetches can't mis-target the update.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:59:37 +08:00
devv-eve
fbf41bde73 feat(selfhost): ship public GHCR deployment flow
Publish stable GHCR self-host images, switch self-host deploys to official image pulls with a source-build fallback, and move self-host signup / Google OAuth config onto runtime /api/config.
2026-04-22 16:58:42 +08:00
Bohan Jiang
936df59fa1 feat(analytics): instrument onboarding funnel (MUL-1250) (#1489)
* feat(analytics): capture onboarding funnel events + person-property $set

Closes the visibility gap introduced by the Onboarding relaunch: the
five new steps between signup and workspace_created were invisible to
PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist
intent, or starter-content acceptance at all.

Server-side events (see docs/analytics.md for full contracts):
- onboarding_questionnaire_submitted — fires once when all three
  answers first land; also $set's role/use_case/team_size on the
  person so every subsequent event is cohortable
- agent_created — not onboarding-specific; is_first_agent_in_workspace
  isolates the Step 4 signal
- onboarding_completed — fires on the actual NULL → timestamp flip
  with completion_path (full / runtime_skipped / cloud_waitlist /
  skip_existing / unknown) + joined_cloud_waitlist
- cloud_waitlist_joined — sizes hosted-runtime interest
- starter_content_decided — imported vs dismissed, split by
  agent_guided / self_serve branch on both sides

Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so
the same events can carry mutable cohort signals without a separate
identify round-trip.

* feat(analytics): wire frontend onboarding events + completion_path

- captureEvent / setPersonProperties helpers in @multica/core/analytics,
  with the same pre-init buffering as identify/pageview so config races
  don't drop step transitions
- onboarding_runtime_path_selected fires from step-platform-fork for
  the three web-fork choices (download desktop / CLI / cloud waitlist),
  plus platform_preference on person properties for downstream splits
- completeOnboarding now takes an OnboardingCompletionPath; the
  onboarding shell derives full / runtime_skipped / cloud_waitlist
  from runtime + waitlist state (lifted to the shell so StepFirstIssue
  can see both), and handleWelcomeSkip passes skip_existing
- saveQuestionnaire mirrors team_size/role/use_case into person
  properties via $set so every event on this user becomes cohortable
- StepAgent sends the template slug, StarterContentPrompt passes
  workspace_id on dismiss so the server can mirror the branch label

* docs(analytics): document onboarding funnel events + $set person properties
2026-04-22 16:28:08 +08:00
Joey
fa7e4cbdca Feat/la te x (#1365)
* 排除提交文件

* feat(editor): 添加数学公式渲染支持

- 集成 KaTeX 库用于数学公式渲染
- 在编辑器样式中添加数学节点相关 CSS 样式
- 实现 BlockMathExtension 和 InlineMathExtension 两个数学公式扩展
- 为 Markdown 组件添加 remarkMath 和 rehypeKatex 插件支持
- 在 package.json 中添加 katex、remark-math、rehype-katex 依赖
- 更新 pnpm-lock.yaml 文件以包含新的依赖包
- 为只读内容组件添加数学公式渲染功能
- 创建 math.tsx 文件实现数学公式节点的完整功能
- 添加只读内容的数学公式渲染测试用例
2026-04-22 16:04:34 +08:00
Bohan Jiang
747d9492cf feat(changelog): surface release notes from sidebar menu + update prompt (#1485)
Two entry points to multica.ai/changelog so users actually find out
what shipped:

- Sidebar user menu (both expanded popover + collapsed dropdown
  variants) gains a "What's new" item with a Sparkles icon, sitting
  above Log out. Plain `<a target="_blank">` works on both surfaces:
  web opens a new tab, desktop's main-process
  setWindowOpenHandler intercepts and routes through
  openExternalSafely. The shared view doesn't need to branch.
- Desktop's UpdateNotification "ready to restart" card grows a
  secondary "See changes" button next to "Restart now", giving the
  user a reason to actually restart instead of dismissing. Mirrors
  Conductor's update prompt pattern. The "available" / "downloading"
  states stay action-only — the changelog isn't useful before the
  download finishes.

No version-detection / unread-tracking yet. Web users still need to
click into the menu to see the changelog; that's a follow-up if the
team wants Linear-style "new" dot.
2026-04-22 15:15:18 +08:00
Naiyuan Qing
c787546ede refactor(pin): drop server-side enrichment, derive sidebar fields client-side (#1484)
`ListPins` used to join `issues` / `projects` so each pin row carried a
`title`, `status`, `identifier`, and `icon`. Convenient for the sidebar
but architecturally wrong: those fields live on a different cache key
than the pin query, so an `issue:updated` WS event invalidates
`issueKeys` and never touches `pinKeys`. The sidebar therefore showed
stale issue status / titles on pinned rows until a hard refresh —
and the same shape would silently re-emerge for any new enriched
field added later.

This refactor moves the join to the client so display data flows from
its real source of truth:

Server (`server/internal/handler/pin.go`):
- `PinnedItemResponse` keeps only pin-owned columns (id, workspace_id,
  user_id, item_type, item_id, position, created_at).
- `ListPins` no longer fetches issues / projects in the loop and no
  longer hides orphaned pins; the client decides how to render a pin
  whose target was deleted.
- `formatIdentifier` helper deleted (was only used by the enrichment
  branch); `strconv` import dropped along with it.

Types (`packages/core/types/pin.ts`):
- `PinnedItem` interface now mirrors the bare server shape. The four
  enriched fields are removed.

Sidebar (`packages/views/layout/app-sidebar.tsx`):
- New smart wrapper `PinRow` resolves each pin's display data via
  `useQuery(issueDetailOptions(...))` or `useQuery(projectDetailOptions(...))`
  with `enabled` gates on `pin.item_type` so the hook order stays
  stable. Loading renders a flat skeleton; error / 404 renders null
  (orphan pins hide themselves).
- `SortablePinItem` becomes purely presentational: it now takes
  `label` and `iconNode` as props instead of reading them off the pin
  object. dnd-kit / navigation wiring untouched.
- Same pattern as `packages/views/search/search-command.tsx:151`,
  which already uses per-row detail queries for Recent issues.

WS sync layer is unchanged: `onIssueUpdated` already patches
`issueKeys.detail`, so changing an issue's status now flows directly
into the sidebar without any cross-entity invalidate. The `pin:*`
prefix handler still invalidates `pinKeys` for create / delete /
reorder — that's still the correct signal for the pin LIST itself.

Verified: views typecheck + core typecheck + web typecheck +
desktop typecheck + go test ./internal/handler/... + vitest
(views: 165 tests, core: 83 tests) all pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:08:16 +08:00
Bohan Jiang
14a9b5293e feat(slugs): reserve homepage + expand reserved slug list (MUL-961) (#1483)
* feat(slugs): reserve homepage + expand reserved slug list (MUL-961)

- Fix: `homepage` was a live `/homepage` landing route in apps/web but not
  in the reserved list, so a user could register a workspace slug that
  shadowed the landing page. Now reserved on both backend and frontend.
- Add likely-future global routes (home, dashboard, profile, account,
  billing, notifications, search, members) so we don't have to do another
  audit/rename pass when these get wired up.
- Add API/ops prefixes (v1, v2, graphql, webhooks, sdk, tokens, cli,
  health, ws, metrics, ping) as defense-in-depth against collision with
  API aliases and ops endpoints.
- Clarify in both source files that the dotted/underscored entries in the
  "Next.js / web standards" section are currently unreachable under the
  slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` and are kept as defense-in-depth
  in case the regex is ever relaxed.
- Add audit migration 056 following the 047/049 pattern to fail loud if
  any production workspace slug collides with the newly reserved set.

* fix(slugs): rename prod conflicts in migration 056 (home → home-1, dashboard → dashboard-1)

Per db-boy's prod audit in the MUL-961 thread, two §3 slugs had live prod
workspaces at reservation time. Decision on MUL-961: force-rename both in
the audit migration (scheme 1), same playbook as MUL-972 for admin/multica/
new/www.

- `home` → `home-1`  (68a982da, zzlye, 2026-04-14)
- `dashboard` → `dashboard-1`  (ea5a332f, 王争, 2026-04-22)

Targeted UPDATEs land first, followed by a generic `<slug>-N` fallback that
handles any row that slips in between the audit snapshot and deploy. A
post-condition block re-queries the reserved set and fails loud if anything
slipped through.

Down migration reverts the two targeted renames deterministically (they're
keyed by workspace_id, so rollback is safe).

Owner outreach (email zzlye@ + 王争@ about the URL change) is tracked as a
follow-up outside this PR.
2026-04-22 15:08:06 +08:00
Bohan Jiang
b8b38381bb feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189) (#1481)
* feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189)

Subscribing to a parent issue used to surface every event from every
sub-issue in the inbox — comments, priority/due-date tweaks, assignee
shuffles, the lot — which drowned out the signal that actually matters
to a parent watcher: "did the sub-task move forward?".

notifySubscribers now consults a small allowlist (parentBubbleNotifTypes)
before walking up to the parent's subscriber list. Only status_changed
bubbles today; sub-issue subscribers themselves still get every event.
Direct notifications (issue_assigned, mentioned, task_failed targeted at
specific recipients) are unaffected — they go through notifyDirect, not
the parent-bubble path.

Tests cover the three behaviors that matter:
- status_changed on a sub-issue reaches the parent's subscriber, with
  the inbox item still pointing at the sub-issue (so the user lands on
  the actual change).
- new_comment on a sub-issue does NOT bubble.
- priority_changed on a sub-issue does NOT bubble.

* fix(test): pick next per-workspace issue number in test helpers

Both createTestIssue and createTestSubIssue inserted with the default
number=0, which collides with the uq_issue_workspace_number unique
constraint as soon as a single test creates two issues in the same
workspace (e.g. parent + sub-issue). The first failure also leaked the
parent row because t.Cleanup hadn't been registered yet, breaking every
subsequent test in the package.

Both helpers now compute number as MAX(number)+1 for the workspace, and
the parent-bubble tests register cleanup right after each insert so a
mid-test failure can't leave orphans.
2026-04-22 14:47:42 +08:00
Naiyuan Qing
3036c6418e fix(onboarding): pin sync, welcome layout, runtime bootstrap state (#1482)
Follow-ups on the onboarding flow shipped in #1411.

Pin state synchronization:
- ImportStarterContent now publishes pin:created after commit so the
  sidebar refreshes without a hard reload (previously the pins landed
  in the DB but no event was fired).
- ReorderPins publishes pin:reordered, keeping order in sync across
  web + desktop sessions.
- StarterContentPrompt.onImport invalidates queries locally, mirroring
  the useCreatePin / useDeletePin / useReorderPins onSettled pattern,
  so the originating session's refresh doesn't depend on the WS
  round-trip (WS is the signal for OTHER sessions).
- ImportStarterContent rejects malformed workspace_id up front with
  400 instead of falling through to a misleading 403.

Welcome step layout:
- Switch the two-column hero from CSS Grid to a flex row. Both
  columns share the container's full height via items-stretch +
  justify-center, so the bg-muted/40 backdrop fills edge-to-edge on
  tall viewports and left/right content stays vertically centred.

Desktop runtime bootstrap state:
- New DesktopRuntimesPage wrapper subscribes to window.daemonAPI and
  forwards a `bootstrapping` prop to RuntimeList. While the bundled
  daemon is booting, the empty state renders "Starting local
  runtime…" instead of the misleading "Run multica daemon start"
  hint. Web leaves the prop undefined — behaviour unchanged.

Small polish:
- CLI install dialog caps at 85vh with an internal scroll so the
  Connect button stays reachable when multiple runtimes are
  registered.
- Drop the env-aware CLI setup command; onboarding always targets
  cloud, so `multica setup` is enough — no need to thread apiUrl /
  appUrl through the dialog.

Developer tooling:
- pnpm dev:desktop:staging — parallel dev command that loads
  .env.staging (copilothub backend) via `electron-vite --mode
  staging`, so switching between local and staging no longer
  requires hand-editing env files.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:47:00 +08:00
Ark
26a2db2540 feat(transcript): add multi-select tool filter to agent execution dialog (#1460)
* feat(transcript): add multi-select tool filter to agent execution dialog

Adds a Filter dropdown to AgentTranscriptDialog that lets users
multi-select tool types (e.g. tool:Bash, tool:Edit) to narrow down
the event list, timeline bar, and copy output. Non-tool items (text,
thinking, error) are also filterable. Clear filters is placed at the
bottom of the dropdown with a separator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): address review comments on tool filter

- Replace index-based selection with seq-based (selectedSeq) to fix
  highlight/scroll jumping when toggling filters
- Use full tool count for the "X tool calls" chip (task-level stat)
  instead of filtered count
- Title-case filter labels: Thinking, Error (was lowercase)
- "Copy all" → "Copy filtered" when filter is active
- Replace raw <button> with DropdownMenuItem for Clear filters
  so it participates in keyboard navigation
- Drop redundant idx from row key (seq is already unique/stable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): use proportional width in timeline bar

After removing segment grouping, the timeline bar lost proportional
widths. Restore proportional width per item (1/items.length * 100%)
so each event's width reflects its share of the timeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): show individual event dividers in timeline bar

When filtering by a single type (e.g. Agent), all events share the
same color and blend into one solid bar. Split each event into its own
clickable button so users can see and click individual events, while
keeping proportional widths based on item count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): simplify timeline bar to segment-level buttons

Remove per-item nested buttons in timeline segments; each segment is now
a single clickable area. Reduces DOM nodes and aligns with the original
design where segments are coarse color blocks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(transcript): reuse getEventLabel for filter option display

Replace the manual displayMap with getEventLabel() so filter option
labels stay in sync with row labels automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): address round-2 review comments

- Remove dead `onClick` prop from TranscriptEventRow (caused
  TS6133 under noUnusedParameters; row never wired a click handler)
- Align `itemFilterKey` guard with `filterOptions` derivation
  (tool_use/tool_result type check)
- Fix TimelineBar `isSelected` from seq range to actual membership
  via `.some()` — avoids false highlight when a filtered-out seq
  falls within a segment's range

Note: `DropdownMenuItem` uses `onClick` not `onSelect` because this
codebase uses Base UI, not Radix. Base UI's Item.Props has no
`onSelect`; the inbox/members-tab code uses `onClick` as the pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Ark <lifangzhou@shizhuang-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:41:46 +08:00
Bohan Jiang
aa9932e4e1 fix(skills): unify Add Skill UX + surface every local skill with real file count (#1480)
* fix(skills): unify Add Skill UX + surface every local skill with real file count

Iterating on the local-skill import flow that just landed. Three fixes
shipped together because they all surfaced while testing the same code
path on the Skills page.

UX — fold runtime import into the existing "+ Add Skill" dialog
- Drop the standalone HardDrive icon button + the empty-state
  "Import From Runtime" buttons. Adding a skill is now a single entry
  point: the "+" header button (or empty-state button) opens one dialog
  with three tabs: Create / Import URL / From Runtime.
- Extract the runtime-import body into RuntimeLocalSkillImportPanel so
  it can mount inline as a tab. The standalone Dialog wrapper stays for
  the per-runtime "Import this skill" flow on the agent skills tab,
  which preselects runtime + skill and benefits from its own modal.
- Cap the dialog at max-h-[85vh] with a scrollable tabs body so the
  From-Runtime tab (runtime selector + skill list + name/description
  form) no longer overflows the screen on shorter displays.
- Filter the runtime selector to runtimes the caller owns. Other users'
  runtimes were listed but the import endpoint rejects them anyway,
  matching the Runtimes page's "Mine" default.
- The selected-runtime label in the trigger now shows the runtime name
  (`Claude (MacBook-Air.local) (claude)`) instead of the raw UUID — the
  shadcn SelectValue needs explicit children when items don't render
  the bare value as their label.
- Drop the placeholder Sparkles icon to the left of the skill name /
  description inputs in the detail header — it was decorative noise.

Daemon — surface every installed local skill and report the right count
- listRuntimeLocalSkills used filepath.WalkDir, which silently dropped
  every symlinked skill via the os.ModeSymlink early return. Skill
  installers like lark-cli ship every skill at ~/.agents/skills/<name>
  and symlink each one into ~/.claude/skills/, so users with dozens of
  skills only saw the few they had cloned in place. Switch to ReadDir
  + os.Stat (which follows symlinks) on the runtime root.
- collectLocalSkillFiles also failed for symlinked skill dirs because
  filepath.WalkDir does not descend into a symlinked root, so every
  such skill reported 0 files. Resolve the skill dir via EvalSymlinks
  before walking.
- Bundle file count purposely excludes SKILL.md (it travels in the
  bundle's `Content` field to avoid duplication on import). The summary
  now adds 1 back so the user-facing count matches the real file total
  — every skill has SKILL.md, we just required it to be parseable.

Tests
- New TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs seeds a
  shared installer dir, symlinks one skill into the runtime root, and
  asserts both regular and symlinked skills come back with the right
  source path (~/.claude/...) and metadata.
- TestListRuntimeLocalSkills_Claude updated to expect file_count = 2
  (one supporting file + SKILL.md) and a comment explains the +1 split.

* test(skills): drive new Add Skill dialog flow in skills-page test

Old test asserted the standalone "Import From Runtime" button. The PR
folded that into the unified "+ Add skill" dialog as the third tab, so
the test now opens the dialog, switches to the "From Runtime" tab, and
asserts the same end state.

Also stub useAuthStore so the runtime panel's "Mine"-only filter sees
the seeded runtime owner (user-1).

* fix(daemon): list nested skills, not just depth-1 entries

Per #1480 review (MUL-1246): switching listRuntimeLocalSkills from
filepath.WalkDir to flat ReadDir lost coverage for nested skill
layouts. opencode stores skills as e.g. `release/reporter/SKILL.md`,
and loadRuntimeLocalSkillBundle accepts that slash-delimited key, so
the import dialog could no longer surface skills the load endpoint
was perfectly happy to fetch.

Replace the flat ReadDir with a recursive enumerator that:

- Follows symlinks at every level (so installer-style symlinked skill
  trees still work — that was the original reason for moving off
  WalkDir).
- Short-circuits at every SKILL.md: a directory that qualifies as a
  skill is registered, and its children are NOT scanned for further
  skills. Stale nested SKILL.md files inside a parent skill's bundle
  stay part of that bundle.
- Caps recursion at maxLocalSkillDirDepth=4 (covers opencode's depth=2
  with headroom) and tracks visited resolved paths so a cyclic symlink
  can't loop forever.

New regression test seeds both a top-level skill (with a decoy
SKILL.md inside its templates dir) and a depth-2 nested skill, and
asserts the walker registers exactly two keys — "top" and
"release/reporter" — with the inner templates SKILL.md correctly
ignored.
2026-04-22 14:38:27 +08:00
Bohan Jiang
4a7de91ddf docs(make): add help description for db-reset target (#1479)
Follow-up to #1434. The merge-in of db-reset from main happened during
#1434's conflict resolution and didn't get a `##` description, so it
doesn't appear in `make help`. Add the one-line description so the
target surfaces under the Database grouping alongside the other `db-*`
commands.
2026-04-22 14:10:52 +08:00
yihong
3b426d21ee feat: add awk style make help (#1434)
* feat: add awk style make help

Signed-off-by: yihong0618 <zouzou0208@gmail.com>

* Address review nits for make help

- Add ##@ Help section so help / makehelp targets are no longer orphaned
  between the intro blurb and ##@ Self-hosting.

- Explicitly set .DEFAULT_GOAL := help and document that bare `make` now
  prints help instead of launching selfhost. This is a safer default for
  onboarding, but it is a behavior change from the previous first-target
  default (selfhost).

---------

Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2026-04-22 14:07:19 +08:00
LinYushen
b624cd98ad feat: identify clients via X-Client-Platform/Version/OS (#1477)
* feat: identify clients via X-Client-Platform/Version/OS

Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.

- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}

Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).

Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.

CORS allowlist extended for the new headers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: address client-identity PR nits

- Memoize the CoreProvider identity object on Web and Desktop, and key
  WSProvider's effect on identity primitives instead of the object
  reference, so unrelated parent re-renders no longer tear down and
  reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
  clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
  on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
  client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
  and asserts the "websocket connected" log entry surfaces them as
  structured attributes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:36:13 +08:00
gezilinll
f247a4f544 feat(skills): import runtime local skills into workspace (#1431)
* feat(skills): import runtime local skills into workspace

* fix(skills): address runtime local skill review feedback

* docs(skills): annotate local provider skill paths

---------

Co-authored-by: zhangliang <zhangliang@gaoding.com>
2026-04-22 13:16:51 +08:00
LinYushen
0b1333fb00 feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128) (#1476)
* feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128)

When the daemon process crashed mid-task the issue was stuck at
in_progress for up to 2.5h: the in-flight task timeout was the only
mechanism that ever moved the row, and the runtime heartbeat sweeper
only fires after the runtime stays offline for 45s — a quick restart
beats both windows.

This change implements the A+B plan from the issue thread:

A. lifecycle hygiene
- migration 055 adds attempt / max_attempts / parent_task_id /
  failure_reason / last_heartbeat_at to agent_task_queue
- new daemon-auth endpoint POST /runtimes/{id}/recover-orphans:
  daemon calls it on every register so the server fails any
  dispatched/running tasks the previous process left behind
- new daemon-auth endpoint POST /tasks/{id}/session: persists the
  agent's session_id + work_dir mid-flight so a crash doesn't
  lose the resume pointer (claude+codex emit MessageStatus with
  SessionID; daemon forwards on the first one it sees)
- FailAgentTask / FailStaleTasks / FailTasksForOfflineRuntimes
  now set failure_reason ('agent_error' / 'timeout' /
  'runtime_offline')

B. auto-retry with resume context
- TaskService.MaybeRetryFailedTask spawns a fresh queued attempt
  carrying parent's session_id/work_dir when the failure reason
  is infrastructure-shaped (timeout, runtime_offline,
  runtime_recovery) and attempt < max_attempts; skips autopilot
- wired into the runtime sweeper paths and TaskService.FailTask
  so the user transparently sees a new in_progress run instead of
  a stuck row
- new user-auth POST /api/issues/{id}/rerun + multica issue rerun
  CLI for the manual escape hatch

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): address PR review for orphan-task recovery (MUL-1128)

Three review-must-fix items on top of the A+B implementation:

1. recover-orphans now funnels through TaskService.HandleFailedTasks,
   the same shared post-failure pipeline used by the runtime sweeper.
   This guarantees task:failed events are emitted, agent status is
   reconciled, and issues stuck in_progress with no remaining active
   task are reset to todo even when no auto-retry is created
   (max_attempts exhausted, autopilot, non-retryable reason).

2. RerunIssue now uses CancelAgentTasksByIssueAndAgent, scoped to the
   issue's current assignee. The previous implementation called
   CancelAgentTasksByIssue, which would collateral-cancel parallel
   @-mention agents on the same issue.

3. GetLastTaskSession now considers both completed and failed tasks
   (mirroring GetLastChatTaskSession), ordering by the most recent
   timestamp. With UpdateAgentTaskSession pinning session_id/work_dir
   mid-flight, an auto-retry or manual rerun of a daemon-crash failure
   now actually resumes the prior conversation context instead of
   starting fresh — matching the stated B-branch behaviour.

go build / go vet pass; the existing service and agent test suites pass.
runtime_sweeper / handler integration tests require a local DB with the
055 migration (and the pre-existing 050 first_executed_at column).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:08:37 +08:00
Bohan Jiang
387f76d328 fix(agents): tasks tab crashes when agent has autopilot run_only tasks (#1453)
* fix(agents): tasks tab crashes when agent has autopilot run_only tasks

Autopilot `run_only` tasks have no linked issue; the server serializes
that as `issue_id: ""` (not null) via `uuidToString` on an invalid
pgtype.UUID. The agent detail Tasks tab assumed every task had a real
issue id and fed `""` into `api.getIssue(id)` → `/api/issues/` and into
`paths.issueDetail("")`, crashing the whole tab as soon as one such
task existed on the agent.

Handle the empty-issue case explicitly:

- Filter empty ids out of `issueIds` so `useQueries` doesn't fire
  `/api/issues/` for a nonexistent issue.
- Render run_only rows as non-link `<div>`s labeled "Autopilot run"
  instead of clickable issue links.

No server-side change — the `""` serialization stays as-is; callers
just need to treat it as "no issue".

* fix(agents): neutral label for issue-less tasks + regression test

Review feedback on #1453: not every task without a linked issue is an
autopilot run. `ListAgentTasks` returns the agent's full queue; both
autopilot `run_only` runs and chat-spawned tasks persist with NULL
issue_id, which arrives here as "". Labeling both as "Autopilot run"
mislabels chat tasks.

Swap the label to the neutral "Task without linked issue" and update
surrounding comments. A follow-up will surface the real task source
once the server populates it on AgentTaskResponse.

Adds a regression test that empty issue_id rows render the neutral
label, aren't wrapped in an anchor, and don't trigger a detail fetch.
2026-04-21 21:03:25 +08:00
Naiyuan Qing
3fd2fb2ae3 feat(onboarding): redesigned flow + post-landing starter content opt-in (#1411)
* docs(onboarding): add redesign proposal

Captures motivation (two activation funnels), research-backed principles,
final 5-step flow (welcome+questionnaire → workspace → runtime → agent →
first-issue), Q1/Q2/Q3 personalization matrix, backend user_onboarding
schema, API design, resume policy, and development ordering
(frontend-first with Zustand stub, backend-last, server swap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): scaffold redesigned flow and state foundation

Work-in-progress scaffold toward the redesign documented in
docs/onboarding-redesign-proposal.md. This commit is intentionally
broad — subsequent commits will replace step content and wire real
personalization. Not ready for merge.

Included:
- packages/views/onboarding/: flow orchestrator + 5 step components
  (welcome/workspace/runtime/agent/complete) and the CLI install card.
  Step content is the placeholder version; Step 1 (questionnaire) and
  Step 5 (first issue) are the next changes.
- packages/core/onboarding/: dev-phase Zustand store + types. Not
  persisted — every page refresh starts at Step 1 so each step can be
  iterated in isolation. Will swap to TanStack Query + PATCH
  /api/me/onboarding once the backend user_onboarding table ships
  (keeps the exported hook surface stable).
- packages/core/paths/resolve.ts + .test.ts: centralized
  resolvePostAuthDestination. Priority is flipped so !hasOnboarded
  wins over workspace presence — during frontend development every
  login re-enters /onboarding. useHasOnboarded() reads from the store
  so the real onboarded_at semantic lands automatically once the
  backend ships.
- Post-auth wiring: callback page, login page, landing redirect,
  dashboard guard, realtime workspace-loss handler, settings leave/
  delete, invite acceptance, and desktop app shell all delegate to
  the shared resolver instead of inline logic.
- Desktop overlay: 'onboarding' added as a WindowOverlay type
  alongside new-workspace / invite, with a navigation-adapter
  interception so push('/onboarding') opens the overlay.
- packages/core/package.json / packages/views/package.json: add new
  subpath exports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(onboarding): revise questionnaire to role-driven 3-question form

Aligns the proposal with the corrected product positioning: Multica is an
AI agent orchestration platform for diverse users (developers, product
leads, writers, founders), not a coding-focused tool.

Key changes:
- Drop Q1 "which agents do you already use?" — daemon auto-detects
  installed CLIs on PATH; asking is both redundant and less accurate
- Add Q2 "what best describes you?" (role) to drive Step 4 template
  default and Onboarding Project sub-issue filtering
- Keep Q1 team_size, refine Q3 use_case (recover writing/research
  option); all three now have "Other" with an 80-char text field
- Q3 use_case_other is embedded into Step 5 first issue prompt so
  Other users get maximally personalized aha moments, not generic ones
- Agent templates: 3 → 4 (Coding / Planning / Writing / Assistant),
  matrix driven by Q2 × Q3
- Onboarding Project sub-issues: surface Autopilot and Workspace
  Context (product differentiators), replace "orchestration" wording
- Schema JSONB example and §5/§9 execution plan updated to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(onboarding): align questionnaire shape with role-driven redesign

Prepares the core state layer for the Step 1 questionnaire rewrite.
Type-only and initial-value changes; no behavior changes (nothing was
reading the removed `existing_agents` field, since no questionnaire UI
exists yet).

- Add `Role` type (Q2: developer / product_lead / writer / founder / other)
- Add `*_other` sibling fields for team_size / role / use_case so each
  question's "Other" selection can carry 80-char free text
- Drop `existing_agents` — daemon auto-detects CLIs on PATH at Step 3,
  so the signal no longer belongs in the questionnaire
- Extend `TeamSize` / `UseCase` unions with `"other"` member
- Refine `UseCase` option label (`writing` → `writing_research`) so
  it matches the widened Q3 scope in the proposal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): implement Step 1 questionnaire

Replaces the placeholder welcome step with the 3-question questionnaire
defined in docs/onboarding-redesign-proposal.md §3.4. Answers land in
the core onboarding store for later use by Steps 4 and 5.

Added:
- packages/views/onboarding/components/option-card.tsx — OptionCard +
  OtherOptionCard. Radio-group ARIA semantics; Enter/Space select;
  Other variant reveals an 80-char input that auto-focuses on mount.
- packages/views/onboarding/steps/step-questionnaire.tsx — merges
  welcome + Q1/Q2/Q3 into one screen. Local draft state for
  responsiveness; writes to the core store only on submit. Skip/
  Continue CTA swap driven by "any answered?"; the only disabled
  case is "picked Other but the text box is blank".
- Test coverage for the CTA rules, Other-clear-on-switch behavior,
  initial-answers pre-fill, and full payload shape.

Modified:
- packages/views/onboarding/onboarding-flow.tsx — render
  questionnaire as the first step; persist answers and advance the
  stored current_step on submit. Other steps still run off local
  useState for now; full store-driven orchestration follows when
  Step 5 lands.

Removed:
- packages/views/onboarding/steps/step-welcome.tsx — superseded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): split welcome + questionnaire, unblock scroll, drop Q1 evaluating

Three fixes prompted by first real browser testing of the Step 1
questionnaire. All three are about making the flow usable before
pursuing visual polish.

1. Split Welcome and Questionnaire into two screens
   The previous merge-welcome-into-questionnaire decision dropped
   Multica's product introduction entirely. For a product with no
   established mental model (AI agents as first-class teammates in a
   task platform), first-time users need 5 seconds of framing before
   the questionnaire makes sense. StepWelcome carries that framing;
   it's UI-only (not a persisted step), shown only on first entry
   (pristine store), and skipped automatically on resume.

2. Remove `my-auto` vertical centering from both platform shells
   Long questionnaire content pushed the centered block's top above
   the scroll origin, making Continue/Skip unreachable. Top-alignment
   + natural body/overlay scroll is the boring-but-correct baseline
   for content of variable height.

3. Drop Q1 "Just exploring for now" option
   Q1 asks about team structure, not attitude. "Evaluating" was a
   category error. Low-commitment users already have a zero-friction
   path (skip all questions). Removing the option simplifies the
   question and the downstream mapping table.

Types, store initial value, proposal doc (§3.1 flow diagram, §3.4
options, §3.5 sub-issue sorting, §3.6 conditionals, §4.1 JSONB
schema, §5.2 file list, §7 decisions row, §9.2 execution order)
all synced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): center short steps, scroll long ones — correctly this time

Previous attempt removed `my-auto` thinking it was responsible for
blocked scrolling. That diagnosis was wrong: the real blocker was
the root layout's \`body { overflow: hidden }\` (an app-shell
convention so sidebar/topbar stay put while the inner content
region scrolls). Removing `my-auto` broke vertical centering of
short steps (Welcome) without fixing the scroll issue.

Correct fix:
- Web: page now owns its own scroll container — `h-full
  overflow-y-auto` on the outermost div decouples from the body's
  overflow-hidden.
- Desktop: the overlay's existing `flex-1 overflow-auto` container
  already provided scroll; just restoring `my-auto` was sufficient.
- Both platforms: inner `flex min-h-full flex-col items-center` +
  content `my-auto` gives the "short centers, long top-aligns and
  overflows down" behavior. Per the flex spec, auto margins are
  ignored on overflowing boxes (they overflow in the end direction),
  so Continue/Skip remain reachable via scroll even on long steps
  like the questionnaire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): add progress indicator + stable header anchor

Adds a consistent visual anchor at the top of every step (except
Welcome), so transitioning between steps of different content heights
no longer shifts the vertical baseline.

- packages/core/onboarding/step-order.ts — single source of truth for
  step order; indicator math reads from here so adding/reordering a
  step touches only one line
- packages/views/onboarding/components/step-header.tsx — dot row +
  "Step N of M" counter; three dot states (done/current/pending);
  accessible progressbar semantics
- onboarding-flow.tsx — non-welcome steps now render under a shared
  `<div flex flex-col gap-8>` wrapper with StepHeader on top. Maps
  the local `complete` render step to the store's `first_issue`
  until Step 5 lands (one-line function, self-deleting).
- step-welcome.tsx — keeps its own min-h-[60vh] + justify-center so
  the short intro still feels centered once the shell drops my-auto
- apps/web + apps/desktop shells — removed `my-auto`. Every
  non-welcome step now anchors to the same top position, so only the
  content below the header changes during transitions. Welcome's own
  internal centering handles its "short content, no header" case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): add web Step 3 platform fork (Desktop / CLI / waitlist)

Web users now see a three-way choice at the runtime step instead of
being dropped directly into CLI install instructions:
- Primary CTA: Download Multica Desktop (bundled runtime)
- Alternate: install the CLI (reveals existing StepRuntimeConnect)
- Alternate: join the cloud waitlist (captures email, completes
  onboarding early with cloud_waitlist_email set)

Desktop unchanged — its platform shell doesn't pass cliInstructions,
so OnboardingFlow routes it straight to StepRuntimeConnect for the
bundled-daemon auto-connect path.

Rename step-runtime.tsx → step-runtime-connect.tsx to reflect its
new single responsibility (connect UI only; platform choice lives
in StepPlatformFork).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): capture optional use-case on cloud waitlist

Adds a textarea to the waitlist form asking what the user wants to
use Multica for. Optional (submit still works with email alone) but
surfaces a clear prompt + placeholder example so most users will fill
it in. Stored as cloud_waitlist_description alongside the email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): make !hasOnboarded a first-class gate on both platforms

Triggering condition was wrong on both sides. Web's dashboard-guard
only checked hasOnboarded when the URL slug failed to resolve; desktop's
App.tsx effect returned early when wsCount > 0 before even looking at
hasOnboarded. Users with existing workspaces never got routed into
onboarding regardless of their flag state.

Also wire store.complete() into the happy-path finish — previously only
the waitlist branch wrote onboarded_at, so every normal completion
left the flag false and (now that triggers work) would loop users back
into onboarding on refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): Step 5 auto-bootstrap — welcome issue + Getting Started project

After agent creation, the flow transitions to a loader screen that
runs the bootstrap in the background:
- Creates a welcome issue with a Q3-driven prompt, assigned to the
  new agent (so it starts working immediately)
- Creates a "Getting Started" project with tutorial sub-issues
  filtered by Q1/Q2/Q3
- Stores first_issue_id + onboarding_project_id via store.complete()
- Navigates the user straight into the welcome issue detail page,
  where they see the agent already responding

Degraded path: if welcome issue fails, shows error with Retry /
Continue anyway. If project or sub-issues fail, logs and proceeds
with just the welcome issue — the aha moment still happens.

No-agent paths (runtime skip, agent skip) short-circuit to onComplete
without bootstrap.

Local flow step union now aligns with the store enum; removed the
mapLocalToStoreStep bridge and deleted the old step-complete.tsx
placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(onboarding): converge all no-agent paths to a single bootstrap step

Before: skip-runtime, skip-agent, and waitlist each finished onboarding
independently, bypassing Step 5 entirely. Users without an agent landed
in an empty workspace with no tutorial project — the "self-serve" case
had no bootstrap at all.

Now: all three paths converge on the first_issue step with agent=null.
Bootstrap branches on agent presence:
- agent ✓ → welcome issue (assigned to agent) + project + agent-guided
  sub-issues ("watch your agent do X"). Lands on the welcome issue.
- agent ✗ → project only + self-serve sub-issues ("try X yourself" —
  configure runtime, create agent, write first issue, etc.). Lands on
  the workspace issues list with the Getting Started project in the
  sidebar.

Both web and desktop shells already handle firstIssueId=undefined →
fall back to /<slug>/issues, so no shell-side change was needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): pin starter project + assign sub-issues to the user

Bootstrap now also:
- Pins the Getting Started project so users see it in the sidebar
  immediately (both paths)
- Pins the welcome issue too (path A only) so the first conversation
  with the agent stays one click away
- Assigns every sub-issue to the current user (via their workspace
  member record). Only the welcome issue stays assigned to the agent —
  that's the aha-moment hand-off; everything else is for the user to
  work through

Pin calls are fire-and-forget (failure logged but non-blocking).
Member lookup is defensive — if listMembers fails or the user isn't
found, sub-issues gracefully fall back to unassigned rather than
breaking the bootstrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(onboarding): remove cloud waitlist option

Cloud runtime is not on the immediate roadmap and there's no backend
table to persist emails. Keeping the UI around would silently drop
user submissions — small trust leak. Revisit once cloud product lands
alongside a proper waitlist table + notification pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): persist onboarded_at end-to-end

Phase 1 of bringing onboarding from dev stub to production. A single
persisted column drives every trigger — no separate user_onboarding
table yet (that's a later phase for questionnaire persistence, cloud
waitlist, analytics).

Backend
- Migration 050: ALTER TABLE "user" ADD COLUMN onboarded_at TIMESTAMPTZ
  (no backfill — existing users see onboarding next login, Skip
  affordance lands later)
- sqlc: MarkUserOnboarded with COALESCE for idempotency
- UserResponse DTO + userToResponse now emit onboarded_at via
  existing util.TimestampToPtr helper — single edit covers GetMe,
  VerifyCode, GoogleLogin, LoginWithToken
- New handler POST /api/me/onboarding/complete
- Route registered in the authenticated user-scoped group

Frontend
- User type gets onboarded_at: string | null
- api.markOnboardingComplete()
- Auth store adds refreshMe() — lightweight getMe + setUser,
  complements existing initialize()
- useHasOnboarded switches source from onboarding-store (dev stub)
  to auth-store (user.onboarded_at). Every call site — dashboard
  guard, desktop App.tsx, invite page fallback, realtime
  workspace-loss handler, settings leave/delete — picks up the
  real signal without any direct change
- onboarding-store.complete() now hits the server: POST + refreshMe
  before local state update, so the next router effect sees the
  non-null timestamp and won't bounce the user back

Triggers + route guards
- StepWorkspace drops the Skip button — every onboarding user
  must create their own workspace even if invited into one
- /onboarding page redirects already-onboarded users away (guards
  against manual URL access)
- login page + auth callback: onboarding wins over ?next= for
  unonboarded users; invite links are revisitable after onboarding

Tests
- apps/web callback tests updated: mocks now return User objects
  so onboarded_at is readable; new "onboarded user honors next"
  scenario added, "unonboarded ignores next" scenario kept
- test/helpers mockUser gets onboarded_at field
- questionnaire already-existing strict-required tests bundled in
  from a prior uncommitted change

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): review findings — dead state, error recovery, cache races

From independent review of the prior onboarded_at commit.

- Remove the dead OnboardingState.onboarded_at field, its INITIAL_STATE
  entry, and its write in store.complete(). useHasOnboarded now reads
  auth-store exclusively; leaving a parallel field here violates the
  "don't duplicate server data in Zustand" rule and risks drifting into
  a second source of truth.
- Wrap handleBootstrapDone/handleBootstrapSkip in try/catch with toast
  recovery. complete() is idempotent server-side (COALESCE), so a
  retry after a failed POST/refreshMe is free — letting the error
  bubble into the React error boundary trapped the user with no way
  forward.
- RedirectIfAuthenticated: swap `!list` for `isFetched`-gated check,
  matching the pattern added on the /onboarding page. Same one-tick
  race where a stale cache [] could fire a premature replace before
  the fresh list settles.
- (Self-review fixups picked up along the way) /onboarding page now
  waits for workspacesFetched before redirecting already-onboarded
  users, and login handleSuccess reads useAuthStore.getState() so the
  hasOnboarded value is fresh after setUser (the closure captured a
  stale pre-login value otherwise).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(onboarding): shrink store surface + firm up flow invariants

Post-review cleanup. End-to-end flow is already complete (user.onboarded_at
is the single source of truth); these are quality-of-life fixes on top.

Store surface
- Drop six dead fields from OnboardingState (workspace_id, runtime_id,
  agent_id, first_issue_id, onboarding_project_id, platform_preference)
  and the PlatformPreference type. None had readers — they were stub
  placeholders for a future user_onboarding table that isn't coming
  this phase. CLAUDE.md "don't design for hypothetical future".
- store.complete() signature simplifies to () — no more patch arg,
  since the only patch fields were the ones just deleted.

Welcome as a first-class step
- Add "welcome" to OnboardingStep enum and make it INITIAL_STATE's
  current_step. Removes the pristine-heuristic "did user see welcome?"
  check, which could misfire on remount.
- pickInitialStep() collapses to `state.current_step ?? "welcome"`.
- ONBOARDING_STEP_ORDER stays unchanged (welcome isn't a progress point).

advance() chain
- Every transition handler now persists the new current_step to the
  store (handleWorkspaceCreated, handleRuntimeNext, handleAgentCreated,
  handleAgentSkip). Refresh lands on the right step instead of
  jumping back to Step 2.

Invariants
- OnboardingFlow throws on null user instead of spreading defensive
  `?? ""` and `if (userId)` that silently degraded to unassigned
  sub-issues. Shell guards already ensure user is present.
- Desktop WindowOverlay's onComplete gains a paths.root() fallback
  when workspace is undefined — matches web's symmetry.

docs/product-overview.md: committed from untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): persist questionnaire + current_step; resume + Back

End-to-end questionnaire persistence + resume capability. User answers
are now server-side (analytics-ready); refreshing or revisiting lands
on the furthest reached step with previous answers pre-filled; a Back
button on each step lets users edit earlier answers without losing
progress.

Backend
- Migration 051: ALTER TABLE "user" ADD onboarding_current_step TEXT,
  onboarding_questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb
- sqlc: new PatchUserOnboarding with sqlc.narg for optional fields
  (COALESCE preserves unspecified columns). MarkUserOnboarded also
  clears current_step — once complete, the step pointer has no meaning
- Handler PATCH /api/me/onboarding accepting partial {current_step,
  questionnaire}. Questionnaire passthrough via json.RawMessage, no
  server-side validation of inner shape (keeps schema evolution free)
- UserResponse DTO emits both new fields; userToResponse coalesces
  JSONB to '{}' defensively

Frontend
- User type gains onboarding_current_step + onboarding_questionnaire
- api.patchOnboarding(payload)
- Delete Zustand onboarding store — replaced with plain async
  advanceOnboarding() / completeOnboarding() that call the API and
  sync auth store. Source of truth is the user object, no client-side
  shadow state that could drift
- pickInitialStep reads user.onboarding_current_step; StepQuestionnaire
  initial pre-fills from user.onboarding_questionnaire
- Monotonic furthestStepRef: Back edits don't regress server-side
  progress, and re-submit returns the user to where they were
- Back buttons on Steps 2/3/4. Back is local-only — just changes the
  rendered step, no PATCH
- Loading indicator on Welcome + Questionnaire submit buttons while
  PATCH is in flight
- CreateWorkspaceForm.onSuccess accepts Promise<void> so the flow can
  await advance() from its onCreated handler

Test mocks (helpers + callback test) updated with new User fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): resume to Step 3+ needs workspace/runtime fallback

Self-review caught: resume lands the user on their saved step, but
React state (workspace, runtime, agent) is empty on fresh mount. The
render conditions gate on those — without fallbacks the page stays
blank.

- workspaceListOptions() query fills runtimeWorkspace from cache when
  stepping past Step 2. Only one workspace exists during onboarding
  (StepWorkspace always creates one), so [0] is unambiguous.
- StepWorkspace accepts an `existing` prop. On resume / Back to Step 2
  with a pre-existing workspace, render a "Continue with <name>"
  confirmation instead of the create form, which would otherwise hit a
  slug conflict the moment the user clicks Create.
- runtimeListOptions(wsId, "me") similarly seeds Step 4's runtime —
  prefer first online, fall back to first.

Step 5 resume path unchanged: if `agent` React state is null on
re-entry, bootstrap runs the self-serve branch. Not ideal (user may
have actually created an agent), but bootstrap's list-check approach
(future work) will handle orphan detection symmetrically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(onboarding): delete all skip/resume jump logic

Flow always starts from Welcome. Questionnaire answers still pre-fill
from user.onboarding_questionnaire. current_step is still PATCHed for
future analytics but no UI code reads it for navigation.

Removed from onboarding-flow.tsx:
- pickInitialStep + isOnboardingStep (no server-driven entry point)
- furthestStepRef + resolveNextStep (no edit-vs-first-pass branching)
- runtimes useQuery + stepRuntime fallback (user walks through Step 3
  linearly, so runtime React state is always populated by Step 4)
- workspace resume fallback in runtimeWorkspace (same reasoning)

Kept:
- advanceOnboarding({ current_step, questionnaire? }) — server
  persistence, analytics-ready
- StepQuestionnaire's initial prop from stored answers
- workspaces useQuery (gated to step === "workspace" only) for
  existing-workspace detection on Step 2 to prevent slug conflicts
  when a previous onboarding was abandoned
- Back buttons + handleBack (local-only navigation)
- Error recovery on completeOnboarding via try/catch + toast

Every transition handler is now a straight advance + setStep line.
Users who close mid-flow and return walk the full flow from Welcome
again — slight extra clicks, but each step shows meaningful confirm
UI (existing workspace, connected runtimes, etc.) so it doesn't feel
like repeated work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): grandfather existing users in the onboarded_at migration

Folded the backfill into 050 itself (branch has not shipped to prod,
so editing the migration in place is clean). Without this, once this
branch deploys, every pre-existing user would be walled off into
onboarding on their next login — a real production incident.

Uses created_at rather than NOW() so analytics like "signup →
onboarded interval" read correctly for pre-launch users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): Step 1 questionnaire — two-column editorial layout

Matches the onboarding(3) design spec: full-bleed two-column on lg+
(main + "Why we ask" side rail), collapses to single column below.

- StepQuestionnaire rewritten with:
  - Mono 01/02/03 markers per question
  - Serif question headings (22px)
  - Editorial serif title ("Three answers. We'll handle the rest.")
  - Right-side rationale panel explaining what each answer unlocks
  - Sticky footer with hint + Continue CTA
  - Embeds StepHeader on the left column so it escapes the flow's
    narrow max-w-xl wrapper, same pattern Welcome uses
- OptionCard redesigned: radio-dot marker + inset ring on select,
  matches design's .opt pattern
- OtherOptionCard: text input appears below the row (not inside the
  card) with bottom-border-only styling, aligned under the label
- onboarding-flow: questionnaire now early-returns full-bleed,
  joining Welcome as a hero-layout step

Placeholder copy updated to match design examples; tests adjusted.

* fix(onboarding): questionnaire uses 3-region app-shell layout

Previous version had everything in a single scroll container with a
sticky footer. As the user scrolled into the questions, the Back
button and StepHeader progress indicator scrolled out of view, and
sticky-bottom had edge cases with width-constrained flex nesting.

Classic 3-region shell now:
- Fixed header row: Back button (left) + StepHeader progress
  indicator — persistently visible regardless of scroll position
- Scrollable middle: eyebrow / serif title / lede / 3 question
  blocks. Uses `flex-1 overflow-y-auto min-h-0` — the min-h-0 is
  the critical bit that lets a flex-1 child shrink below content
  height inside a flex column
- Fixed footer row: hint (hidden < sm) + Continue CTA — always
  reachable, never scrolled off

Right "Why we ask" panel is now an independent grid column with its
own overflow, so the two columns scroll independently instead of the
whole page having one shared scrollbar.

Side panel width reduced 520 → 480 to give the question column more
room on 1280/1366 screens where 1fr_520 left ~760px for content;
1fr_480 gives ~800-900px which comfortably fits the 620px max-w
content column plus breathing room.

* fix(onboarding): questionnaire needs DragStrip like every full-window view

Traffic lights were overlapping the StepHeader progress dots because
Step 1 escaped onboarding-flow's non-welcome wrapper (which renders
<DragStrip />) without rendering its own. The codebase convention per
packages/views/platform/drag-strip.tsx is: every full-window view
places a DragStrip as the first flex child of each visible column.

Adds DragStrip at the top of both the left (shell) and right
("Why we ask") columns, matching step-welcome.tsx which already did
this. Traffic lights now land in the 48px transparent strip with no
content collision; dragging from any top edge moves the window on
Electron; border-l between columns runs edge-to-edge.

Also made the right column's scroll container use
`min-h-0 flex-1 overflow-y-auto` so its internal scroll activates
independently of the left column.

(Separately investigated: useImmersiveMode is no longer called
anywhere in production code — the codebase has fully committed to
the DragStrip pattern. No action needed on the hook itself.)

* style(onboarding): drop top/bottom borders on questionnaire shell

* style(onboarding): use chat-style scroll fade mask instead of border

The questionnaire's scroll area now fades softly at top/bottom edges
via `useScrollFade` (already used by chat-message-list.tsx) — the
same mask-image linear-gradient pattern that fades content under the
header/footer based on scroll position:

- At top: only bottom fades (hint: more content below)
- At bottom: only top fades (hint: content above)
- In middle: both fade
- Fits entirely: no mask

This replaces the removed border-b/border-t on the header/footer with
a softer, more editorial visual separation while giving an actual
scroll-position affordance the border can't.

* feat(onboarding): show "n of 3 answered" progress next to Continue

Gives the user a glance-able progress signal as they fill the
questionnaire. Static text, no extra UI primitives, no dynamic
state variants — just `{n} of 3 answered` updating in place,
left of the Continue button.

Replaces the static "Your answers shape the next screens..." hint,
which was always there regardless of progress and added noise.

Same canContinue gate as before (all 3 answered), just derived
from the new per-question check so we don't compute validity twice.

* style(onboarding): drop redundant lede under questionnaire title

The title already conveys the "we'll handle the rest for you"
promise — the lede just rephrased it at length. Removed; bumped the
question-list top margin (mt-8 → mt-10) to keep breathing room.

* feat(onboarding): land redesigned flow + post-landing starter content opt-in

This commit bundles the final onboarding-redesign work that sat in the
working tree with today's architectural reshape of how starter content
is handled. Splitting across sqlc-regenerated files would be fragile,
so it ships as one logical unit — "onboarding is ready for production".

Flow redesign (Steps 1–5)
-------------------------
- Editorial two-column shells on Steps 1/2/3/4 (DragStrip + hero column
  + aside panel) — Welcome, Questionnaire, Workspace, Runtime, Agent
- Web-only Step 3 fork (Download desktop / Install CLI / Cloud waitlist)
  lives alongside desktop's direct runtime picker; cloud path is
  interest-capture only, doesn't advance the flow
- DragStrip extracted to packages/views/platform as a cross-platform
  component — 48px transparent drag row, no-op on web
- recommend-template.ts + test: Q1–Q3 → AgentTemplate mapping

Cloud waitlist
--------------
- Migration 052: cloud_waitlist_email VARCHAR(254) + cloud_waitlist_reason TEXT
- Handler: net/mail.ParseAddress + length bounds + reason trim
- Frontend: CloudWaitlistExpand component + api.joinCloudWaitlist

Drop persisted onboarding_current_step
--------------------------------------
- The interim implementation persisted the user's furthest-reached step;
  the final design starts every entry at Welcome, so the column is dead
- Migration 051 no longer adds it; migration 053 drops it IF EXISTS on
  any environment that ran the interim 051 — schema converges cleanly
- UserResponse / User type / patchOnboarding signature all drop the field

Post-landing starter content (new architecture)
-----------------------------------------------
Why: the old design ran bootstrap inside Step 5 (welcome issue + Getting
Started project + sub-issues, all in one try block). That had three
defects — (1) non-idempotent: Retry after partial failure created
duplicates; (2) sub-issue assignee raced listMembers → showed as
"Unknown"; (3) skipped users (paths A/C/D) never got any starter
content. All three are structural, not patchable.

New design: onboarding ends at completeOnboarding() as before (gate is
unchanged for useDashboardGuard). The 4 completion paths (Welcome skip
/ full flow / Runtime skip / Error recover) all just call
completeOnboarding() and navigate to workspace. On landing, a
StarterContentPrompt dialog renders exactly once per user
(starter_content_state == null) with Import / No thanks. The dialog is
mandatory — no X, no ESC, no outside-click — so state always ends in a
terminal value.

- Migration 054: starter_content_state TEXT, backfill 'skipped_legacy'
  for pre-feature onboarded users so they're never prompted
- Server POST /api/me/starter-content/import: transactional claim
  (NULL → 'imported') + bulk create project + optional welcome issue +
  sub-issues + pins, all in one tx. 409 Conflict on second call
- Server POST /api/me/starter-content/dismiss: transactional NULL → 'dismissed'
- Import decides agent-guided vs self-serve by inspecting the workspace's
  agent list at dialog time — fixes path A (Welcome skip + existing
  agent) which was previously excluded from starter content
- starter-content-templates.ts replaces bootstrap.ts: pure template
  builders, no API calls. Copy is reviewed as UI; server owns atomicity
- StepFirstIssue is now just completeOnboarding() + navigate; error
  surface collapses to a Retry button (no more "Continue anyway" branch)
- OnboardingCelebration + just-completed.ts removed (replaced by
  StarterContentPrompt which reads server state, not sessionStorage)

Handler hardening
-----------------
- PatchOnboarding: MaxBytesReader 16KB so the JSONB column can't be
  weaponized as bulk storage (every /api/me read returns the payload)
- JoinCloudWaitlist: net/mail format check + explicit 254-char cap
- ImportStarterContent: MaxBytesReader 64KB (templates are markdown-heavy
  but still bounded); welcome issue's agent_id verified in-workspace

Tests
-----
- Existing onboarding_test.go (waitlist) passes
- step-platform-fork.test.tsx + recommend-template.test.ts (new)
- apps/web test helpers updated for User.starter_content_state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): resolve Unknown assignee/creator + tighten prompt copy

Two surface issues on the post-landing starter content dialog:

1. Unknown assignee & Created by
-------------------------------
ImportStarterContent stored `member.id` (the membership row UUID) in
`assignee_id` and `creator_id` for sub-issues. That mismatched the rest
of the codebase — AssigneePicker and resolveActor in issue.go both
store `user_id` for type="member", and `useActorName.getMemberName`
looks members up by `user_id`. The mismatch meant the lookup never
matched any member and fell through to the "Unknown" fallback.

Fix: use `parseUUID(userID)` for both fields. The existing membership
check stays for the 403 signal; we just no longer need the returned
`member.ID`.

2. Dialog copy too long, button labels unclear
----------------------------------------------
Old copy was 3–4 paragraphs of instruction; users need to read less
than that to make a binary choice. Buttons "Import starter tasks" and
"No thanks" also didn't make it clear what "No thanks" actually does —
it starts a blank workspace, so say so.

New:
  - Title: "Welcome — add starter tasks?"
  - Body: one sentence describing the seeded content
  - Left button: "Start blank workspace"
  - Right button: "Add starter tasks"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(onboarding): server decides starter content branch

Problem: the old ImportStarterContent gated the agent-guided vs
self-serve branch on a client-supplied `welcome_issue.agent_id` or
null `welcome_issue`. The client made that decision by reading its
React Query cache of the workspace's agent list — any timing quirk
(cache not populated, stale, race with WS event) could lie to the
server, and there was no way for the server to disagree. Users with
an agent in the DB could still end up on the self-serve branch.

Fix: the server is now authoritative. The client always sends both
template arrays (agent_guided_sub_issues, self_serve_sub_issues) and
a welcome_issue_template (title + description + priority, NO agent_id).
Inside the import transaction the server runs ListAgents on the
workspace — if there's at least one agent, it picks agents[0] (same
ordering the client used: created_at ASC), uses agent_guided_sub_issues,
and creates the welcome issue assigned to that agent. Otherwise it
uses self_serve_sub_issues and skips the welcome issue.

Side effect: the Unknown assignee/creator bug is structurally gone —
no client-supplied id flows into assignee_id/creator_id for type=
"member". The server uses actorID = parseUUID(userID) everywhere,
matching resolveActor in issue.go.

Client surface also simplifies: StarterContentPrompt drops
useQuery(agentListOptions), the hasAgent check, the agentsFetched
button gate, and the branch-specific copy. Dialog description is a
single generic line ("If you already have an agent, we'll also seed
a welcome issue it replies to right away"). buildImportPayload no
longer takes an agentId parameter — one unconditional return shape.

Payload grows ~15 KB (both sub-issue arrays always present); still
well under the 64 KB MaxBytesReader cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(onboarding): clarify runtime prerequisite, revert dialog agent list

Step 3 runtime (desktop step-runtime-connect.tsx) — scanning and empty
subtitles now name the local AI coding tools Multica drives (Claude
Code, Codex, Cursor, and others), so users understand a runtime alone
isn't enough: they also need one of those tools installed on the
machine. Uses "and others" rather than a closed list so we don't lock
the copy to exactly three integrations.

StarterContentPrompt dialog — reverted the short-lived "try Coding,
Planning, Writing agents and more" rewrite. That was a misread of
feedback meant for the Step 3 prerequisite, not the dialog. The
dialog's current single-sentence "how agents, issues, and context
work in Multica" is enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:32:33 +08:00
Kagura
1a565a221a fix(server): handle race in CompleteTask and FailTask for parallel agents 2026-04-21 19:23:31 +08:00
Bohan Jiang
536f4286f1 docs: add v0.2.11 changelog (2026-04-21) (#1447)
* docs: add v0.2.11 changelog (2026-04-21)

Combines the v0.2.9 / v0.2.10 / v0.2.11 releases (plus post-v0.2.11
main commits) into a single landing-page entry, covering the PostHog
pipeline, desktop cross-platform packaging, board pagination and the
recent inbox / agent-task / markdown fixes.

* docs: trim v0.2.11 changelog to user-visible highlights

Drop minor fixes and CLI/daemon polish items — keep only the headline
features and the visible user-facing fixes.

* docs: reprioritize v0.2.11 changelog for external readers

Drop internal MUL-/#PR references, swap in the higher-impact fixes
(daemon workspace isolation, multica update + Windows daemon, board
card description, PostHog default off) that a self-hosted user
actually notices.

* docs: drop PostHog items from v0.2.11, promote multica update to feature

Analytics plumbing is not user-perceivable; replace the PostHog feature
and the PostHog default-off fix with multica update (CLI self-update)
as a feature and keep the Windows daemon persistence as a fix.

* docs: add OpenClaw model read fix to v0.2.11 changelog
2026-04-21 17:41:27 +08:00
Bohan Jiang
c6d54e8ce5 fix(ui): replace smiley with check mark in quick emoji list (#1446)
Swap the 4th quick reply emoji 😄 for  so approval-style
acknowledgements are one tap away.
2026-04-21 17:27:40 +08:00
yushen
20c9d985f5 ci: clarify release tag filters 2026-04-21 17:24:20 +08:00
Bohan Jiang
6366e2f4ba fix(inbox): don't archive after deleting an issue (#1444)
* fix(inbox): don't archive after deleting an issue

Deleting an issue from the Inbox page was calling the archive API on the
inbox item right after deleteIssue succeeded. Because the inbox_item row
has ON DELETE CASCADE on issue_id, it was already gone by then and the
archive call 404'd with "inbox item not found", surfacing a "Failed to
archive" toast.

Drop the redundant archive call and invalidate the inbox cache through
the issue:deleted WS handler so every tab stays in sync without an extra
round trip.

* fix(inbox): keep stale selection on /inbox instead of the deleted issue

When another tab deletes the selected inbox issue, onInboxIssueDeleted
prunes the cache and `selected` becomes null. The existing fallback then
redirected to the issue detail page — which is also gone, so the user
landed on a "This issue does not exist..." screen instead of back in the
inbox list.

Track the last key that actually resolved against the inbox list. If it
used to be in the list and just disappeared, clear the selection and
stay on /inbox. Only shared links that were never in the user's inbox
continue to fall back to the issue detail page.

Also add ws-updaters tests covering onInboxIssueDeleted and
onInboxIssueStatusChanged.
2026-04-21 17:12:53 +08:00
Bohan Jiang
642844c736 feat(issues): paginate every status column, not just done (#1422)
* feat(issues): paginate every status column, not just done

Previously the workspace issues list fetched all non-done/cancelled
issues in a single unbounded `open_only=true` request and only
paginated the done column. In workspaces with many open issues this
ballooned the initial payload and skipped pagination entirely.

Restructure the issue list cache into per-status buckets
(`{ byStatus: { [status]: { issues, total } } }`) fetched in parallel,
generalize `useLoadMoreDoneIssues` into `useLoadMoreByStatus(status,
myIssuesOpts?)`, and render an infinite-scroll sentinel inside every
accordion group and kanban column. Sort and filter stay client-side,
matching the done column's existing behavior.

Backend `ListIssues` already supports per-status pagination, so no
API changes are required.

* fix(issues): handle project / hidden-column / lookup regressions from paginated list cache

After bucketing the issue list cache by status, three consumers that
treated `issueListOptions()` as a complete local index broke:

- `project-detail.tsx` filtered the workspace list by `project_id`
  client-side, so projects whose issues sat past the first 50-per-status
  page rendered empty. Switch to `myIssueListOptions(wsId,
  'project:<id>', { project_id })` so the server returns only this
  project's issues; add `project_id` to `ListIssuesParams` /
  `MyIssuesFilter` / api client.
- `board-view.tsx` HiddenColumnsPanel read counts from the in-memory
  `issues` array — a paginated fragment. Pass `myIssuesOpts` through to
  a per-row subcomponent that reads the real per-status total from the
  cache.
- `tasks-tab.tsx` and `search-command.tsx` used the list as a global
  lookup for task titles / Recent items / current-issue chrome. Switch
  both to per-id `issueDetailOptions` via `useQueries` so they're
  independent of which page the issue lands on.

Drop the now-redundant `doneTotal` override prop on BoardView/ListView
and the `allIssues` prop on BoardView (only HiddenColumnsPanel consumed
it).

Tests updated: tasks-tab now mocks `api.getIssue`; search-command mocks
`issueDetailOptions` + `useQueries`; project-issue-metrics drops the
`doneColumnCount` assertion.
2026-04-21 16:48:55 +08:00
yushen
6ecf15e62c ci: add desktop smoke build workflow 2026-04-21 16:44:19 +08:00
devv-eve
52c9bd72cb fix(desktop): unblock Windows + Linux release packaging (#1443)
Two unrelated bugs were preventing the GitHub-hosted runner desktop
release matrix from succeeding:

1. Windows job failed with `spawnSync electron-vite ENOENT`. On
   Windows the package-local binaries are `.cmd` shims and Node's
   `spawnSync` does not consult PATHEXT unless going through a shell.
   Pass `shell: true` for both the electron-vite and electron-builder
   spawns; on POSIX hosts these are real executables so the shell hop
   is harmless.

2. Linux `.deb`/`.rpm` job failed with electron-builder errors:
   `Please specify project homepage` and `Please specify author
   'email'`. fpm requires a maintainer when generating .deb, and
   electron-builder derives it from the app package.json metadata. Add
   `description`, `homepage`, `repository`, `author` (with
   email) and `license` to apps/desktop/package.json so the Linux
   targets have the metadata they need.

Refs: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
Refs: https://www.electron.build/configuration.html#metadata

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 16:37:53 +08:00
Bohan Jiang
7ada72faa6 fix(server/task): synthesize result comment for comment-triggered tasks too (#1440)
Agents can end a comment-triggered run without calling `multica issue comment
add` — the final reply stays in terminal / run-log text and never reaches
the user, even though the run panel shows "Completed". PR #1372 addressed
this via prompt wording, but compliance is inherently best-effort.

The server already had an exact fix for the assignment-triggered branch:
`HasAgentCommentedSince` + fallback synthesis from `payload.Output`. The
comment-triggered branch was explicitly exempted on the theory that the
agent "replies via CLI with --parent, so posting here would create a
duplicate" — but that is precisely the path that's failing.

Remove the `!task.TriggerCommentID.Valid` guard so the invariant "every
completed issue task has at least one agent comment on the issue" holds for
both branches. The existing `HasAgentCommentedSince` check still prevents
duplicates for compliant agents, and `createAgentComment` already threads
the synthesized comment under `task.TriggerCommentID` when present.

Regression tests cover both:
  - comment-triggered + silent agent → synthesized comment threaded under trigger
  - comment-triggered + agent already posted → no duplicate
2026-04-21 16:09:59 +08:00
Bohan Jiang
df86f559e0 fix(desktop): default shareable URL to localhost web in dev (#1438)
The renderer's navigation adapter fell back to https://multica.ai when
VITE_APP_URL was unset (i.e. desktop dev builds), so "Copy link" in a dev
build produced a production URL instead of one pointing at the running
dev web frontend. Match the fallback used by pages/login.tsx
(http://localhost:3000) so dev links stay on the dev host.
2026-04-21 16:06:32 +08:00
Bohan Jiang
d5071abb75 fix(inbox): stop remounting IssueDetail on new comment/reaction (MUL-1199) (#1439)
The inbox detail panel keyed `<IssueDetail>` by `selected.id` (inbox-item
id). `deduplicateInboxItems` picks the most recent inbox notification per
issue, so every new `comment:created` / `reaction:added` event for the
currently open issue produced a fresh inbox item with a new id — flipping
the React key and forcing a full unmount/remount of `IssueDetail`. That
wiped the comment composer draft, dropped focus, and reset scroll.

Key on `selected.issue_id` instead: stable for the life of an open issue
(so input + scroll survive incoming events) and still changes when the
user picks a different issue (so state resets between issues, as before).
2026-04-21 16:05:23 +08:00
Naiyuan Qing
ba003eee83 fix(server/comment): remove HTML sanitizer that was corrupting Markdown (#1387) (#1436)
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:

  - "> quote"   -> "&gt; quote"     (blockquote lost, see #1303)
  - '"foo"'     -> '&#34;foo&#34;'    (literal entities visible)
  - "\n\n2." -> " 2."             (ordered list items merged into prose)

Comment content is stored as Markdown source. XSS is already handled at
two layers:

  - Render: rehype-sanitize in packages/ui/markdown and
    packages/views/editor/readonly-content (mention:// allowlist,
    data-href restricted to http(s), class restricted to
    code/div/span/pre).
  - Edit: @tiptap/markdown is configured with html:false, so Markdown
    source containing raw HTML tags is treated as plain text.

Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.

The PR #1342 workaround in the editor serializer can be dropped once
this lands.

Co-authored-by: devv-eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 15:40:30 +08:00
devv-eve
a3a6158d96 fix: harden desktop packaging PATH lookup (#1435)
Co-authored-by: Eve <eve@multica.ai>
2026-04-21 15:35:26 +08:00
Bohan Jiang
9481350ef0 fix(analytics): disable posthog-js default autocapture and recording (#1433)
posthog-js ships with autocapture, heatmaps, dead-click detection,
session recording, exception capture, and surveys all on by default.
Staging verification showed the Activity view flooded with "clicked
button" / "clicked span with text \"…\"" events — they leak
user-typed content into PostHog, burn the billed event budget, and
dilute the explicit funnel. Our product analytics surface is narrow
and intentional (see docs/analytics.md): only the events we emit
server-side plus one manual $pageview belong. Opt all the auto
surfaces off at init time so the Activity view reflects the funnel.
2026-04-21 15:11:00 +08:00
devv-eve
637bdc8eb3 feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping

Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.

Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.

* feat(server): emit signup and workspace_created analytics events

Wires analytics.Client through handler.New and main, then emits the first
two funnel events:

- signup fires from findOrCreateUser (which now reports isNew), covering
  both the verification-code and Google OAuth entry points — a single
  emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
  with is_first_workspace computed from a post-commit ListWorkspaces count
  so we can distinguish fresh-user activation from returning-user
  expansion.

Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.

* refactor(analytics): drop is_first_workspace from workspace_created

Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.

PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.

* docs(analytics): document self-host safety defaults

Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.

* feat(analytics): emit runtime_registered, issue_executed, team_invite_*

Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:

- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
  reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
  and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
  UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
  first_executed_at IS NULL flips the column for the first time. Retries,
  re-assignments, and comment-triggered follow-up tasks hit the WHERE
  clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
  buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
  from AcceptInvitation, closing the expansion funnel.

Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.

* feat(config): surface PostHog key via /api/config

Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.

* feat(analytics): wire posthog-js identify + UTM capture on the client

Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.

captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.

Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
  cookie and token paths. A getConfig/getMe race is handled by buffering
  a pending identify inside the analytics module and flushing it once
  initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
  loginWithToken and resetAnalytics() on logout so the next login merges
  cleanly without bleeding events.

* docs(analytics): describe runtime_registered, issue_executed, invite events

Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.

* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed

Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.

Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.

* fix(analytics): wire $pageview and harden signup_source cookie payload

Two frontend fixes from the PR review:

- PageviewTracker, mounted under WebProviders, fires capturePageview on
  every Next.js App Router path / query-string change. Without this the
  capturePageview helper in @multica/core/analytics was never called and
  the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
  *before* JSON.stringify, and drops the whole cookie when the serialised
  payload still exceeds 512 chars. Previously the overall slice(0, 256)
  could leave a half-JSON string on the wire that neither the backend nor
  PostHog could parse.

Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.

* fix(analytics): URL-decode signup_source cookie on read

Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.

* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding

Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.

---------

Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-04-21 14:42:52 +08:00
LinYushen
6f63fae41a feat(desktop): support macOS cross-platform packaging (#1262)
* feat(desktop): support macOS cross-platform packaging

* fix(desktop): use releaseType instead of publishingType in electron-builder publish config

publishingType is not a valid electron-builder key; the correct GitHub
provider option is releaseType. The previous value was silently ignored,
causing uploads to be skipped and breaking auto-update.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): standardize artifact naming across desktop and CLI

Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a
filename alone reveals kind, version, platform, and CPU arch.

Desktop (apps/desktop/electron-builder.yml):
  mac     → multica-desktop-<v>-mac-<arch>.{dmg,zip}
  linux   → multica-desktop-<v>-linux-<arch>.{deb,AppImage}
    (fixes `\${name}` expanding the scoped `@multica/desktop` into a
    broken `@multica/desktop-*` filename path)
  windows → multica-desktop-<v>-windows-<arch>.exe

CLI (.goreleaser.yml):
  multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz
  (adds `-cli` marker + version; switches `_` to `-` for consistency)

Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`:
  - drop mac x64 (Intel not a target yet)
  - add linux arm64
  Final: mac arm64, win x64/arm64, linux x64/arm64.

Downstream updates so install paths match the new CLI names:
  - scripts/install.sh
  - scripts/install.ps1 (URL + checksum regex)
  - CLI_INSTALL.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): use multica_{os}_{arch} CLI archive naming

Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}'
asset names. Install scripts and the desktop CLI bootstrap now resolve
assets via checksums.txt so they work without hardcoding versions.

The Go self-update path queries the GitHub release API and accepts
either the new or legacy 'multica-cli-<version>-...' names so existing
releases keep updating cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): ship both legacy and versioned CLI archive names

GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and
'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every
release. The legacy name keeps already-released CLIs self-updating; the
versioned name is what new clients should use going forward.

Self-update / install paths flipped to prefer the versioned name and
fall back to legacy:
  - server/internal/cli/update.go (multica update)
  - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap)
  - scripts/install.sh, scripts/install.ps1 (fresh install)

Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(desktop): also build Linux .rpm packages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1

Address review feedback on PR #1262:

- .github/workflows/release.yml: add a 'desktop' job that runs after the
  CLI 'release' job and packages the Desktop installers for Linux
  (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes
  them to the same GitHub Release via electron-builder. macOS Desktop
  continues to ship through the manual release-desktop skill so it can
  be signed and notarized with Apple Developer credentials.

- scripts/install.ps1: detect Windows ARM64 hosts via
  RuntimeInformation::OSArchitecture so the new windows-arm64 CLI
  archive is downloaded on ARM64 machines instead of always falling
  back to amd64.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision

electron-builder's update metadata file is hardcoded to `latest.yml` for
Windows regardless of arch (only Linux gets an arch-suffixed name; see
app-builder-lib's getArchPrefixForUpdateFile). With two separate
electron-builder invocations for Windows x64 and arm64, both publish
`latest.yml` to the same GitHub Release and the second upload silently
overwrites the first — leaving one of the two architectures with auto-
update metadata pointing at the other arch's installer.

Route Windows arm64 to its own `latest-arm64` channel:

* scripts/package.mjs appends `-c.publish.channel=latest-arm64` only
  for the Windows arm64 invocation, so x64 keeps producing `latest.yml`
  and arm64 produces `latest-arm64.yml` alongside it.
* updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows
  arm64 clients so they fetch the matching metadata file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 23:33:41 -07:00
Bohan Jiang
c5a00d8b8c fix(agent/openclaw): extract real model from meta.agentMeta.model (#1426)
OpenClaw's `--json` result blob carries the actual LLM identifier in
`meta.agentMeta.model` (e.g. `deepseek-chat`, `claude-sonnet-4`),
alongside `provider` and the usage breakdown. The backend was reading
the surrounding `agentMeta.usage` and `agentMeta.sessionId` but skipping
the `model` field entirely, then attributing every run's tokens to
`opts.Model` — which for openclaw is the *agent name* passed via
`--agent`, not a real model identifier — falling all the way through to
"unknown" when no agent.model was configured.

Surface the runtime-reported model:

- `openclawEventResult` gains a `model` string.
- `buildOpenclawEventResult` reads `agentMeta.model` (trimmed; empty
  string when absent for forward-compat with older runtimes / partial
  outputs).
- `processOutput` propagates it through the result-blob branch.
- `Execute`'s usage map prefers `scanResult.model`, falling back to
  `opts.Model` then `"unknown"` — preserving the prior behavior path
  for any runtime that doesn't surface its own model yet.

Two unit tests cover both the populated and missing cases.

Refs: #1395
2026-04-21 14:32:31 +08:00
Bohan Jiang
4ac43e9e49 feat(daemon): log agent invocation at info level (#1428)
Surface the actual exec path + argv for every agent backend at INFO
so operators can see the exact command without flipping to debug.
Also add the missing log line in pi.go for consistency with the
other nine backends.
2026-04-21 14:30:07 +08:00
devv-eve
03e21aee80 Fix skills.sh nested directory imports (#1423)
Co-authored-by: Eve <eve@multica.ai>
2026-04-20 23:11:33 -07:00
Bohan Jiang
632fdde700 fix(cli): keep Windows daemon alive after terminal closes + unblock multica update (#1420)
* fix(cli): detach daemon from parent console on Windows

CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.

* fix(cli): make `multica update` work while the binary is running on Windows

On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.

Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.

Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).

* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events

With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.

Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
2026-04-21 13:03:48 +08:00
Bohan Jiang
cc1ccedaf3 test(storage): lock S3 upload URL behavior across all env combos (#1421)
Extract the URL assembly at the end of S3Storage.Upload into a helper
(uploadedURL) so the four env-var combinations can be covered by a
table-driven test without mocking s3.PutObject. This locks in the fix
from #1300 — cdn > endpoint > bucket — so future refactors can't
silently regress the CDN-wins-over-custom-endpoint case.

No behavior change.
2026-04-21 12:57:36 +08:00
Bohan Jiang
8eb81aa396 fix(daemon): enforce workspace isolation for agent execution (#1235) (#1260)
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027
/ #1235: an agent running for workspace A ended up commenting on (and
renaming) a two-day-old issue in workspace B.

#1249/#1259 fixed resolution for autopilot tasks and consolidated the
task-workspace resolver, and #1294 populated workspace_id in the claim
response for run_only autopilot tasks. Those closed the known fallthroughs
but the failure mode is still broader: whenever the daemon or server fails
to supply a workspace, the CLI silently falls back to
`~/.multica/config.json`, which is user-global, not workspace-scoped. On a
host running daemons for multiple workspaces, a single gap in workspace
propagation is enough to leak writes across workspaces.

This PR adds three coordinated guards so no single layer's bug can cause a
cross-workspace write:

1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the
   agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env,
   both daemon-only markers) and in that context refuses to fall back to
   the user-global CLI config. Human / script usage (no agent env) is
   unchanged: flag → env → config fallback chain still applies.

2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now
   captures the runtime's workspace from `requireDaemonRuntimeAccess` and
   enforces `resolved_task_workspace == runtime_workspace` after the
   existing issue/chat/autopilot branches. On mismatch or empty, the
   handler explicitly cancels the just-dispatched task (via
   `TaskService.CancelTask`, which also reconciles agent status) and
   returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had
   already transitioned the task to 'dispatched' and the agent status to
   'working', so a plain 500 would leave both stuck for the ~5 min
   stale-task sweep window.

3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the
   agent when `task.WorkspaceID` is empty (defense-in-depth against
   server bugs and reused workdirs).

Tests:
- `cmd/multica/cmd_agent_test.go`:
  `TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests
  covering the full fallback matrix (outside agent context still reads
  config; agent context uses env; agent context with empty env returns
  empty; task-id-only marker also counts; requireWorkspaceID surfaces the
  agent-context error message).
- `internal/handler/daemon_test.go`:
  `TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` —
  constructs a data-inconsistent task (runtime_id in workspace A,
  issue_id in workspace B) and asserts the handler returns 500 AND
  leaves the task in 'cancelled' state (not 'dispatched').

Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup
workspace filter, cross-workspace audit of agent-facing endpoints,
observability) are out of scope for this PR and tracked separately.
2026-04-21 12:55:12 +08:00
Matthew Lal
965bf731ab Prefer CDN domain over raw endpoint URL in attachment links (#1300)
When both AWS_ENDPOINT_URL and CLOUDFRONT_DOMAIN are configured, the
uploaded file URL returned by S3Storage.Upload now uses the CDN domain
instead of the raw S3-compatible endpoint.

This enables S3-compatible backends (MinIO, R2, B2, Wasabi, etc.) to be
paired with a separate public-read domain — previously the CDN domain was
silently ignored whenever a custom endpoint was set, forcing clients to
hit the raw S3 API endpoint which typically requires signed requests.

No behavior change for deployments that set only one of the two vars:
pure AWS S3 with CloudFront, AWS S3 without a CDN, and MinIO/R2 without
a CDN all continue to return the same URLs as before.
2026-04-21 12:49:32 +08:00
Kagura
0db7d2fb64 fix(issues): include description in list queries for board card display (#1375) (#1377)
The ListIssues and ListOpenIssues SQL queries omitted the description
column, so the API response never included description data. Board cards
checked issue.description (always null) and never rendered it, even when
the Description card property was enabled.

Add description to both SQL queries, the generated Go structs/scan calls,
and the response mapping functions.
2026-04-21 12:20:10 +08:00
Jiayuan Zhang
4368e1be18 docs: add v0.2.8 changelog (2026-04-20) (#1418)
Summarizes recent releases (v0.2.7 → v0.2.8) on the landing page
Change Log in both en and zh.

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-21 11:45:19 +08:00
Naiyuan Qing
bb31afbbce Revert "fix(server/comment): remove HTML sanitizer that was corrupting Markdo…" (#1413)
This reverts commit 4a25b91590.
2026-04-21 09:56:58 +08:00
devv-eve
4a25b91590 fix(server/comment): remove HTML sanitizer that was corrupting Markdown (#1387)
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:

  - "> quote"   -> "&gt; quote"     (blockquote lost, see #1303)
  - '"foo"'     -> '&#34;foo&#34;'    (literal entities visible)
  - "\n\n2." -> " 2."             (ordered list items merged into prose)

Comment content is stored as Markdown source. XSS is already handled at
two layers:

  - Render: rehype-sanitize in packages/ui/markdown and
    packages/views/editor/readonly-content (mention:// allowlist,
    data-href restricted to http(s), class restricted to
    code/div/span/pre).
  - Edit: @tiptap/markdown is configured with html:false, so Markdown
    source containing raw HTML tags is treated as plain text.

Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.

The PR #1342 workaround in the editor serializer can be dropped once
this lands.

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 09:37:43 +08:00
devv-eve
9e47b83f02 feat(agent): add Kimi CLI as agent runtime (#1400)
* feat(agent): add Kimi CLI as agent runtime

Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.

Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.

Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
  LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
  MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
  file (Kimi reads AGENTS.md natively via /init), and writes skills under
  `.kimi/skills/` so they are auto-discovered by the project-level skill
  loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(agent/kimi): support per-agent model selection via ACP set_model

Wire Kimi into the model dropdown introduced in #1399:

- ListModels gets a 'kimi' case that drives the same ACP
  initialize + session/new handshake as Hermes; both share a new
  discoverACPModels helper and parseACPSessionNewModels parser
  so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
  opts.Model is non-empty, mirroring the Hermes flow. Failures
  fail the task instead of silently falling back to Kimi's
  default model — silent fallback would hide that the dropdown
  pick wasn't honoured.

Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(agent): address code review feedback on Kimi runtime

- Share ACP provider-error sniffer between hermes and kimi. Previously
  only hermes promoted stderr-observed 4xx/5xx into a failed task;
  kimi would report "completed + empty output" when the Moonshot
  upstream rejected a request (expired token, rate limit, …). Rename
  hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
  the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
  ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
  Message struct has only one relevant field (Tool), so passing it
  twice was a dead fallback. Document that the function normalises
  residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
  fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
  that returns a JSON-RPC error for session/set_model, asserts that
  the task result surfaces status=failed with the model name + upstream
  message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
  (missing cursor, gemini, copilot).

All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.

* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval

Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.

The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.

While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.

Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
  asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
  handler test packages.

* fix(agent/acp): auto-approve session/request_permission from agent

The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.

The real fix is on our side: respond to session/request_permission.

ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.

Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.

Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
  (reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
  kimiBlockedArgs gives the wrong impression that it does something.
  Comment in kimi.go now points at handleAgentRequest as the real fix.

Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
  session/request_permission, assert the reply echoes the id and
  carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
  unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
  with a negative assertion: no dead --yolo / --auto-approve / -y on
  argv, since they'd pretend to do something they can't.

All: go build ./..., go vet ./..., go test ./pkg/agent/... green.

* fix(agent/acp): surface kimi tool input/output via content blocks

Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.

Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).

Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
  Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
  content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
  rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
  (single/multiple text blocks, terminal-block skip, empty input).

* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI

kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.

Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix 4335c198) but command was missing.

Changes:
- hermesClient now tracks pending tool calls per toolCallId. Hermes
  path is unchanged — rawInput is present at tool_call time, so
  emit-immediately-then-flag-emitted still fires on the initial frame.
- kimi path defers MessageToolUse until status=completed / failed.
  tool_call_update in_progress frames update the buffered argsText
  (cumulative, so overwrite); on completion we parse the accumulated
  JSON into Message.Input. Malformed JSON falls back to `{"text": …}`
  so non-JSON tool args still render.
- Orphan completion frames (no matching tool_call seen — e.g. daemon
  restarted mid-task) synthesise ToolUse from the update's own
  title/kind/rawInput so the UI still gets a header.
- extractACPToolCallText now also renders FileEditToolCallContent
  blocks as a compact header ("--- path / +++ path / (edited: N → M
  bytes)"). kimi emits these for Write / StrReplaceFile / Patch when
  the tool's display block is a DiffDisplayBlock.

Tests:
- TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming
  in_progress + completed. Asserts no emission until complete, then
  [ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")].
- TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls
  back to Input.text.
- TestHermesClientHandleToolCallCompleteOrphan: completed frame
  without a start → ToolUse synthesised from update's rawInput.
- TestExtractACPToolCallText: diff + new-file-diff cases.

All agent / daemon / handler test packages green.

---------

Co-authored-by: Eve <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-21 02:18:30 +08:00
Jiayuan Zhang
b291db11c2 feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.

Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.

Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.

Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
2026-04-21 00:06:34 +08:00
Bohan Jiang
824d943848 fix(auth): derive cookie Secure flag from FRONTEND_ORIGIN scheme (#1390)
The session cookie's Secure flag was tied to APP_ENV, and the
docker-compose self-host stack defaults APP_ENV to "production". On
plain-HTTP self-host deployments (LAN IP, private network) the browser
silently drops Secure cookies, leaving every subsequent /api/* call
anonymous and surfacing as 401 "auth: no token found" right after a
successful login.

Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get
Secure cookies and plain-HTTP origins get non-secure cookies the
browser will actually store. Also harden cookieDomain() against the
other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and
browsers reject. Log a one-shot warning and fall back to host-only.

Docs: correct the COOKIE_DOMAIN description (it was labelled as
CloudFront-only but applies to session cookies too) and call out the
IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and
.env.example.

Refs #1321
2026-04-20 19:53:15 +08:00
Bohan Jiang
779c72e835 fix(views): clear agent live state when switching issues (#1389)
AgentLiveCard kept its taskStates map across issueId prop changes, and
its merge logic only added newly-fetched tasks without removing stale
ones. Navigating from Issue A (with a running agent) to Issue B via
cmd+k left A's sticky agent status card pinned on B's page.

Key AgentLiveCard and TaskRunHistory by issue id so React remounts
them when the issue changes, guaranteeing fresh state per issue.

Closes MUL-1147
2026-04-20 18:47:46 +08:00
Jiayuan Zhang
e830575efc feat(issues): add expand toggle to comment and reply editors (#1386)
Mirrors the new-issue modal's expand behavior on the inline comment and
reply editors so users can compose long text without feeling cramped.
2026-04-20 18:19:40 +08:00
Bohan Jiang
193046fabc docs: add v0.2.7 changelog (2026-04-18) (#1385)
* docs: add v0.2.7 changelog entry (2026-04-18)

* docs: trim v0.2.7 changelog to headline items
2026-04-20 17:49:22 +08:00
Bohan Jiang
c76c790b32 fix(daemon/execenv): make posting result comment an explicit workflow step (#1372)
Agents were silently finishing tasks without ever posting results to the
issue — their final reply stayed in terminal/log output only. See MUL-1124.

Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with
results" inside the body of step 4 (a nested clause in the default workflow
description), so skill-driven flows jumped straight from "do the work" to
`status in_review`.

- Hoist posting the result comment into its own explicit, numbered step in
  both assignment-triggered and comment-triggered workflows, with the exact
  `multica issue comment add` invocation inlined.
- Add a hard warning at the top of the Output section that terminal / chat
  text is never delivered to the user.
- Add regression test covering both workflow branches.
2026-04-20 17:48:06 +08:00
LinYushen
07034f4455 feat(server): configurable pgxpool size with sane defaults (#1381)
* feat(server): configurable pgxpool size with sane defaults

pgxpool.New(ctx, url) silently sets MaxConns = max(4, NumCPU). On the
prod pods that resolved to 4, which got fully saturated by daemon
claim/heartbeat traffic (~3800 acquires/s) and showed up as ~900ms
acquire waits on every query — the actual root cause of the 3s+
/tasks/claim tail latency. The db pool stats logging from #1378
confirmed this with empty_acquire_delta == acquire_count_delta.

Switch to pgxpool.ParseConfig + NewWithConfig and apply per-pod
defaults of MaxConns=25 / MinConns=5, both overridable via env vars
(DATABASE_MAX_CONNS / DATABASE_MIN_CONNS) so the size can be tuned
in prod without a redeploy.

The defaults follow the standard 'small pool, lots of waiters' guidance
for Postgres (PG community / HikariCP formula
`(core_count * 2) + effective_spindle_count`); 25 leaves headroom for
bursts and occasional long queries while staying safely under typical
managed-Postgres max_connections ceilings when multiplied across pods.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): respect DATABASE_URL pool_* params; add precedence tests

Address review feedback on #1381:

- Configuration precedence is now explicit: DATABASE_MAX_CONNS env >
  pool_max_conns query param on DATABASE_URL > built-in default. Same
  for min_conns. Previously the env-empty path unconditionally
  overwrote whatever ParseConfig had read from the URL — a silent
  regression for deployments that already tuned pool size via the
  connection string.
- Add unit tests in dbstats_test.go covering each precedence branch
  (defaults, URL-only, env-over-URL, partial URL, invalid env,
  min>max clamp).
- Move pool tuning vars out of 'Required Variables' into a new
  'Database Pool Tuning (Optional)' section in SELF_HOSTING_ADVANCED.md
  so self-hosters don't think they need to set them.
- Add commented entries in .env.example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): invalid pool env falls back to URL/code default, never pgx 4

Address second round of review on #1381:

Previous code passed cfg.MaxConns / cfg.MinConns as the envInt32 fallback,
which meant an invalid DATABASE_MAX_CONNS value silently fell back to
ParseConfig's value — i.e. pgx's built-in default of 4/0 when the URL had
no pool_* params. That's exactly the bad value this PR exists to remove,
and the previous test (TestPoolSizing_InvalidEnvFallsBack) accidentally
locked it in.

Compute the non-env fallback first (URL pool_* if present, else code
default 25/5) and pass that to envInt32. Misconfigured env now lands on
the same value as if the env were unset — never on the pgx default.

Replace the loose 'max > 0' assertion with two precise tests:
- invalid env + no URL param → code default (25/5)
- invalid env + URL param    → URL value

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 17:07:19 +08:00
LinYushen
9fa08fb16a chore(server): log pgxpool config + periodic stats to confirm pool exhaustion (#1378)
After merging the per-phase claim slow-logs (#1376), the prod data showed
the smoking gun: unrelated endpoints (claim, heartbeat, /api/workspaces,
ping) all completed at the *same wall-clock instant* with durations
clustered at ~1.4s and ~2.88s — and within the claim breakdown,
list_pending_ms was 713ms even when list_pending_count=0.

A 0-row indexed scan can't take 713ms, and unrelated endpoints don't
synchronize their completion by accident. The only explanation that fits
is requests blocking on a shared resource and being released together.
The most likely culprit is pgxpool connection-acquire wait: pgxpool.New
is called with no config, so MaxConns defaults to max(4, NumCPU) — under
the daemon poll fan-in this is trivially exhausted.

This change adds the observability needed to confirm/refute that without
changing pool sizing yet (pool sizing is a follow-up once we have data):

- logPoolConfig: prints MaxConns / MinConns / MaxConnLifetime /
  MaxConnIdleTime / HealthCheckPeriod once at startup. Surfacing the
  effective limit is critical because the default is surprisingly small
  and easy to mistake for 'database is slow'.

- runDBStatsLogger: samples pool.Stat() every 15s (matches daemon
  heartbeat cadence for easy correlation). Emits INFO with TotalConns /
  AcquiredConns / IdleConns and per-window deltas (acquire_count,
  empty_acquire, canceled_acquire, avg_acquire_ms). Auto-upgrades to
  WARN whenever empty_acquire_delta > 0 or canceled_acquire_delta > 0
  — those are the direct symptom of a request having to wait because
  no idle connection was available.

If on prod we see 'db pool pressure' WARN lines coincident with the
claim_endpoint slow lines, the hypothesis is confirmed and the fix
becomes a one-liner (pool config tuning + the existing N+1 reduction
ideas to lower demand).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:36:49 +08:00
LinYushen
cf74327aa6 chore(server): add slow-path timing logs for /tasks/claim (#1376)
* chore(server): add slow-path timing logs for /tasks/claim

We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.

Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:

- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
  build_ms so we can tell whether the slowness is in the actual claim
  query or the post-claim response assembly (GetAgent, LoadAgentSkills,
  GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
  branch's 4 queries).

- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
  list_pending_count, agents_tried, claim_loop_ms — directly validates
  the suspected N+1 amplification (one ListPendingTasksByRuntime + one
  ClaimTask per unique agent).

- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
  claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
  LOCKED cost from the surrounding metadata reads.

Pure observability change. No behavior change in the request path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(server): widen claim slow-log to cover post-claim DB work and error paths

Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.

Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
  and dispatch_ms phase fields. Error paths now also emit a slow log
  with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
  outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
  error paths now also carry phase timings (outcome=unauth / error_claim).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:21:32 +08:00
Bohan Jiang
951f51408a fix(agent/comments): prevent resumed sessions from reusing stale --parent UUID (#1374)
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard

Resumed Claude sessions keep prior turns' tool calls in context, so a
comment-triggered task could reuse the PREVIOUS turn's --parent UUID
instead of the current trigger's. The reply landed in the wrong thread
(MUL-1125): backend stored exactly what the agent sent, but the agent
pulled a stale UUID from its own conversation memory.

Two layers of defense:

1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt
   and execenv.InjectRuntimeConfig emit the same "use this exact
   --parent, do not reuse values from previous turns" block. The
   per-turn prompt now carries the current TriggerCommentID, which it
   previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read
   mid-session).

2. Handler-side guard in CreateComment: when an agent posts from inside
   a comment-triggered task (X-Agent-ID + X-Task-ID, task has
   TriggerCommentID), require parent_id == task.TriggerCommentID or
   return 409. Assignment-triggered tasks are untouched.

* fix(agent/comments): scope parent_id guard to the task's own issue

Two issues from CI + GPT-Boy's review:

1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an
   agent legitimately commenting on a different issue while its current
   task was comment-triggered would get 409'd with the wrong issue's
   trigger comment id. Narrow the guard to fire only when the request's
   issue matches the task's own issue — cross-issue agent activity
   stays unblocked.

2. The integration test tried to insert a second queued task for the
   same (agent, issue), which hits the idx_one_pending_task_per_issue_agent
   unique index. Replace the assignment-triggered-task sub-case with a
   cross-issue regression test (the scenario we now need to cover anyway):
   post on issue B while X-Task-ID points at a comment-triggered task on
   issue A, expect 201.
2026-04-20 15:56:16 +08:00
Bohan Jiang
be78b66e4e feat(autopilot): multi-select days in weekly trigger config (#1368)
Replace the single day picker in the "Weekly" frequency with a multi-select
so users can schedule on any combination of weekdays (e.g. Mon/Wed/Fri)
in addition to the existing "Weekdays" Mon-Fri preset.

The backend already accepts any day-of-week list in the cron expression,
so this is a frontend-only change. Relabels the tab to "Days" to reflect
the new behavior.
2026-04-20 15:01:36 +08:00
Bohan Jiang
ec73710dd2 fix(agent/codex): surface stderr tail in initialize / turn startup errors (#1314)
* fix(agent/codex): surface stderr tail in initialize / turn startup errors

When codex app-server exits before the JSON-RPC handshake completes —
e.g. because the user put a flag in custom_args that the subcommand
rejects — the Result.Error users see is `codex initialize failed:
codex process exited`, while codex's actual complaint (typically
something like `error: unexpected argument '-m' found`) only lives in
daemon logs.

Wrap the stderr writer with a bounded stderrTail that still forwards
to the slog logWriter but also retains the last 2 KiB of bytes
written. Include that tail on the three startup failure paths
(initialize, startOrResumeThread, turn/start). Runtime cancellation
paths are left untouched — they're our own abort and the stderr
context isn't a clear signal there.

Refs #1308. Complement to #1310 / #1312 — lets "bad custom_args fail
loudly" actually be workable by giving the failure a real message.

* fix(agent/codex): join cmd.Wait() before sampling stderr tail

Addressing review of #1314: reading stderrBuf.Tail() right after
c.request returns "codex process exited" was racy. Nothing in that
path synchronizes with os/exec's internal stderr copy goroutine —
cmd.Wait() is the only documented join point. The original defer ran
cmd.Wait() later, but by then we had already built Result.Error from
a potentially-empty Tail().

Replace the ad-hoc deferred stdin.Close()/cmd.Wait() with a
sync.Once-wrapped drainAndWait closure. Call it explicitly on the
three startup failure paths before sampling the tail; keep it as the
cleanup defer so the success path behaves identically.

Also add TestCodexExecuteSurfacesStderrWhenChildExitsEarly: spawns a
real subprocess that prints to stderr and exits before responding to
initialize, runs it through Execute, and asserts Result.Error
contains the stderr hint. This covers the full timing path the
reviewer flagged, which the helper-level tests in this PR did not.
2026-04-20 14:38:32 +08:00
Bohan Jiang
62a7c05589 feat(desktop): hourly update poll + manual check button in settings (#1366)
* feat(desktop): hourly update poll + manual check button in settings

The previous updater only ran one check 5s after launch, so a missed
or failed initial check meant the user had to fully restart the app to
see a new release. Add a 1h background poll for long sessions and a
"Check now" button under a new Updates tab in Settings so the user can
trigger a check on demand without waiting.

The button reuses the existing autoUpdater pipeline — when an update is
available the existing corner notification still drives the download
flow; the settings tab only surfaces the immediate check result
(up-to-date / available / error).

* fix(desktop): trust electron-updater's isUpdateAvailable for the manual check

Per review: deriving `available` from a version-string compare is wrong —
`updateInfo.version` can differ from `app.getVersion()` while
electron-updater still suppresses `update-available` (pre-release channels,
staged rollouts, downgrade scenarios, min-system-version gates). In those
cases the settings tab would say "vX is available" but no corner download
prompt would ever appear. Use `result?.isUpdateAvailable` instead, which is
electron-updater's own answer.
2026-04-20 14:32:54 +08:00
devv-eve
c0be1b7ce9 fix(slugs): audit admin/multica/new/www + reserve in slug list (MUL-972) (#1359)
Follow-up to PR #1188 / migration 047, which intentionally omitted the
five historical conflict slugs (admin / multica / new / setup / www) from
the reserved-slug audit because each had one production workspace using
it at the time and we did not want to block deploy on owner outreach.

MUL-972 closed that loop on prd for four of the five:

  * admin   (99cd10e4-…) → renamed to legacy-admin-99cd10e4
  * multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa
  * new     (e391e3ed-…) → renamed to legacy-new-e391e3ed
  * www     (5e8d38b2-…) → workspace deleted (was empty: 0 issues /
                           projects / agents, owner-only member; 18
                           workspace-FK relations all CASCADE)

This PR:

1. Adds migration 049_audit_legacy_reserved_slugs which audits those
   four slugs against workspace.slug at startup. If any future workspace
   slips in with one of them, startup fails loudly via RAISE EXCEPTION
   instead of being silently shadowed by a global route. Mirrors the
   structure of 047.

2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list
   in both the Go handler and the shared TS list (admin was already in
   both). Keeps the two lists in lockstep per the convention enforced
   in workspace_reserved_slugs.go header.

setup is STILL exempt from the audit and is intentionally NOT added to
the reserved list. The setup workspace (b43f0bc2-…) is a real production
user (owner: Roberto Betancourth, building a chants/Alabanzas app) and
is being handled out-of-band via owner outreach. A separate follow-up
migration will fold setup into the audit once that workspace's slug has
been migrated.

Migration is intentionally shipped AFTER the prd data fix (not before):
049 will RAISE EXCEPTION on any remaining conflict, so we want the data
state clean first. Rollout order:
  prd data fix (done by db-boy on 2026-04-20) → this PR.

Tested:
  - go test ./server/internal/handler/ -run TestReserved → pass
  - pnpm --filter @multica/core test consistency → pass (4/4 in
    consistency.test.ts; global-prefix↔reserved invariant holds)

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-19 23:21:31 -07:00
Bohan Jiang
4ce3e5ddf4 fix(auth): hand off session to Desktop when web is already logged in (#1364)
When Desktop opens /login?platform=desktop in the browser and the user
already has a valid web session, the page previously bounced them to
their workspace and Desktop never received a token. Now we mint a bearer
token via issueCliToken and redirect through the multica:// deep link so
Desktop completes sign-in without a second Google round-trip.

Refs: MUL-1080
2026-04-20 14:12:32 +08:00
Bohan Jiang
bd445782d5 fix(openclaw): stop passing unsupported flags and actually deliver AgentInstructions (#1362)
Fixes #1332.

Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:

1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
   any agent configured with a Model field crashed in ~700ms with
   `exit status 1`. Remove both forwards, and add them to
   openclawBlockedArgs so custom_args can't reintroduce the crash.
   Model is bound at registration time via `openclaw agents
   add/update --model`.

2. AgentInstructions were written to `{workDir}/AGENTS.md` by
   execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
   from its own workspace dir — the file was never read, so every
   agent's Instructions field was silently discarded. Populate
   opts.SystemPrompt for the openclaw provider in runTask and
   prepend it to the `--message` payload in the backend so the
   model actually receives the instructions.

Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.

Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
2026-04-20 14:01:41 +08:00
devv-eve
5fa1da448f fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures

The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:

- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
  so any task that completed without a session_id (early agent crash,
  missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
  follow-up chat message claimed in between resumed against a stale (or
  NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
  a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
  fell back to the existing GetLastChatTaskSession query, so a single
  bad turn could permanently drop the conversation memory.

This change:

- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
  existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
  the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
  service) to forward session_id/work_dir, so failed/blocked tasks that
  built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
  chat_session pointer is missing, and include failed tasks in that
  lookup so a single failure can't lose the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): forward session_id/work_dir on blocked + timeout paths

runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:

- timeout returned a naked error, so handleTask called FailTask with
  empty session info and the chat resume pointer was either left stale
  or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
  SessionID / WorkDir, so even though FailTask now COALESCEs into
  chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
  even when a real session_id had been built.

All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): capture session id from session.start as fallback

The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.

Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): parse premiumRequests as float to preserve session id

Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.

This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.

Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-19 22:50:33 -07:00
mike.xu
556c68292f fix(cli): use rundll32 instead of cmd start on Windows (#1202)
Windows 下 CLI 登录用 cmd /c start 打开浏览器,cmd.exe 会把 URL 中的 & 解释为命令分隔符,导致 OAuth 回调 URL 中 &state=... 和 &cli_state=... 参数被截断。

改用 rundll32 url.dll,FileProtocolHandler,将 URL 原样传递给操作系统 shell 处理程序,不对特殊字符做任何解释。

Authored-by: xushi-mike <xushi_1983@hotmail.com>
2026-04-20 13:43:23 +08:00
Bohan Jiang
96ee5bba52 docs(selfhost): surface APP_ENV + 888888 gating in .env.example (#1361)
The v0.2.6 self-host security fix (#1307) defaults APP_ENV to "production"
in docker-compose.selfhost.yml, which disables the 888888 master verification
code. The follow-up docs PR (#1313) covered SELF_HOSTING.md and the
installers, but `.env.example` — the file users actually copy and edit —
still makes no mention of APP_ENV, so operators who don't read the prose
docs hit the exact same "888888 stopped working after upgrade" confusion
reported in #1331.

- Add APP_ENV= to .env.example with a comment block that spells out the three
  cases (Docker default, local dev, evaluation) and warns against enabling
  the bypass on public instances. Keeping the value empty preserves the
  current `make dev` UX (Go server reads empty → treats as non-production →
  888888 works locally) while `${APP_ENV:-production}` in the compose file
  still ensures public Docker deployments are safe by default.
- Update the existing 888888 mention under # Email so it no longer
  contradicts the new gating rule.
- Update the `make selfhost` post-start banner, which still told operators
  to "Log in with any email + verification code: 888888" even after #1307
  disabled that path by default.
2026-04-20 13:26:42 +08:00
Jiayuan Zhang
2ab89d4690 feat(editor): create sub-issue from selected text in bubble menu (#1348)
Adds a "Create sub-issue from selection" button to the editor bubble
menu. When an issue context is present (description editor, comment
input, reply input, comment edit), selecting text and clicking the
button creates a new issue parented under the current issue and
replaces the selection with a mention link to the new issue.
2026-04-20 12:40:20 +08:00
Azaan Ali Raza
b428f36ca6 feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930

- Added environment variables to control signups
- Updated frontend to hide signup text when disabled
- Added backend check to block new user creation via magic link
- Updated .env.example
2026-04-19 21:02:42 -07:00
Jiayuan Zhang
239ce3d40f fix(editor): blur ContentEditor on Escape (#1338)
ESC did nothing inside the issue description editor because browsers
don't blur contenteditable elements by default, leaving users stuck in
the editor with no keyboard escape hatch.

Add a blur-shortcut extension mirroring TitleEditor's behavior and wire
it into ContentEditor's edit-mode extension set.
2026-04-20 10:17:32 +08:00
Jiayuan Zhang
a7e9801c83 feat(views): show issue title in detail page header (#1344)
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
2026-04-20 00:36:10 +08:00
Jiayuan Zhang
b8907dda8d fix(views): prevent infinite re-render loops in sidebar and chat resize (#1322)
* fix(sidebar): stabilize useQuery default arrays to prevent render loop

Inline `= []` defaults on `useQuery` return a new array reference on
every render when `data` is undefined (query disabled or mid-load).
Downstream effects/memos that depend on the value then fire every
render; the pinned-items `useEffect` compounds this by calling
`setLocalPinned` each time, so under sustained `data === undefined`
(e.g. backend unreachable, WebSocket in reconnect loop) React trips
its "Maximum update depth exceeded" guard and the sidebar becomes
unusable.

Use module-level empty-array constants so the default identity stays
stable across renders.

* fix(chat): short-circuit ResizeObserver update when bounds unchanged

The resize observer always called `setRevision(r => r + 1)` from its
callback, even when `clientWidth`/`clientHeight` were identical to the
previous reading. Any spurious notification — sub-pixel layout jitter
during mount, or an ancestor reflow triggered by an unrelated state
update — then fed back into the same render cycle and could exceed
React's update-depth limit.

Guard the state bump by comparing against the previous bounds, and
leave `setBoundsReady(true)` outside the guard since it's idempotent.
2026-04-18 23:24:32 +08:00
Bohan Jiang
6cd49e132d docs(selfhost): clarify 888888 master code is disabled by default in Docker (#1313)
Following #1307, the Docker self-host stack defaults to APP_ENV=production,
which disables the 888888 master verification code on auth.go:169. The
installer banners and self-hosting docs still told operators to log in with
888888, leaving them stuck.

Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md,
and self-hosting.mdx to document the three login paths: configure
RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for
private evaluation, or read the dev verification code from backend container
logs. Also warn against enabling APP_ENV=development on public instances.
2026-04-18 14:30:35 +08:00
Bohan Jiang
a6db465e46 fix(ui/agents): drop Codex-incompatible --model example from custom args tab (#1310)
* fix(agent/codex): route custom_args -m/--model to thread/start payload

Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.

Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.

Fixes #1308.

* fix(ui/agents): drop Codex-incompatible --model example from custom args tab

The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.

Refs #1308.

* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"

This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.

The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.

Refs #1308.
2026-04-18 14:25:58 +08:00
Kagura
965561a6cc fix(selfhost): pass APP_ENV to backend container, default to production (#1307) 2026-04-18 14:25:23 +08:00
Bohan Jiang
163f34f918 feat(agents): show launch mode preview in custom args tab (#1312)
* feat(agent): add LaunchHeader per agent type

Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.

Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.

Refs #1308.

* feat(handler/runtime): surface launch_header on runtime response

runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.

Refs #1308.

* feat(ui/agents): show launch mode preview in custom args tab

Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.

Refs #1308.
2026-04-18 14:18:42 +08:00
Bohan Jiang
2317533da4 fix(auth): validate next= redirect target to prevent open redirect (#1309)
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth

Extracts a reusable helper that returns a post-login redirect URL only
when it's a safe single-slash relative path, and null otherwise. Rejects
absolute URLs, protocol-relative URLs, backslashes, and control
characters so call sites can safely pass the result to router.push().

Keeping the rule in a single helper (with direct unit tests) avoids
each consumer re-implementing the validation and drifting.

* fix(auth): validate next= redirect target to prevent open redirect

Closes #1116

Next.js router.push accepts absolute URLs, so a crafted
`/login?next=https://evil.example` would send the user off-origin
after a successful login. The Google OAuth callback has the same
vector via the `state=next:<url>` payload.

Sanitize both entry points through `sanitizeNextUrl` from
`@multica/core/auth` so only safe single-slash relative paths survive;
null results fall through to the existing workspace-list-based default
without any hard-coded path.

---------

Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
2026-04-18 13:24:01 +08:00
niceSprite
d81e6a14a6 fix(comment): assignee on_comment path should use reply id, not thread root (#1302)
* fix(comment): assignee on_comment path should use reply id, not thread root

Symmetric fix to #871 — that PR fixed the @mention path but missed the
assignee on_comment path in the same file. Replies on agent-assigned
issues were still getting trigger_comment_id = parent_id, so the daemon
fed the parent comment's content to the resumed claude session, which
then either exited with 'Already replied to comment <parent>' or silently
misrouted its answer depending on model / session state.

Reply placement (flat-thread grouping) is already decoupled from
trigger_comment_id by TaskService.createAgentComment's parent
normalization (added alongside #871), so passing comment.ID directly is
safe and matches the mention path's post-#871 behavior.

Fixes #1301

Made-with: Cursor

* test(comment): assert assignee on_comment records reply id as trigger_comment_id

Integration regression guard for #1301. Asserts that after a member posts
a reply under an agent-authored thread, the enqueued agent task's
trigger_comment_id matches the new reply, not the thread root. Without
the companion fix in comment.go the old parent-override would store the
root id and the daemon would feed stale content (via prompt.go
BuildPrompt) to the agent.

Made-with: Cursor

---------

Co-authored-by: fuxiao <fuxiao@zyql.com>
2026-04-18 13:20:11 +08:00
Bohan Jiang
e198a67f8f docs(prompt): warn agents that mention syntax is an action, not a text reference (#1306)
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.

Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
2026-04-18 13:09:07 +08:00
Bohan Jiang
0ed16fc1b1 fix(autopilots): spin the Loader2 icon while a run is in progress (#1305)
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.

Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
2026-04-18 13:05:52 +08:00
niceSprite
746f33a38b fix(claude): clear fresh session_id on resume failure so daemon fallback fires (#1285)
When --resume targets a dead session, claude prints
"No conversation found with session ID: ..." to stderr, emits a stream-json
system init with a fresh session_id, then exits with code 1. The backend
was treating that fresh id as the authoritative session, so
daemon.go's retry-with-fresh-session fallback (SessionID == "" guard)
never triggered. Every subsequent task for the same (issue, agent) pair
stayed permanently broken until the server-side session_id was cleared by
hand.

Fix: when --resume was requested but the emitted session_id differs AND
the run failed, drop the fresh id from Result so the daemon's existing
fallback can do its job. Factored into a pure helper and unit-tested.

Fixes #1284

Co-authored-by: fuxiao <fuxiao@zyql.com>
2026-04-18 12:59:30 +08:00
Kagura
aa9305f7e4 fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1294)
* fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1276)

* test: add regression test for #1276 — ClaimTaskByRuntime autopilot workspace_id
2026-04-18 12:47:29 +08:00
Korkyzer
63800f05ff fix(agent): add per-agent mcp_config field to restore MCP access (#1168)
* fix(agent): add per-agent mcp_config field to restore MCP access

Closes #1111

The --strict-mcp-config flag was added defensively in #592 to prevent
Claude agents from inheriting MCP state from the outer Claude Code session.
It was meant to be paired with --mcp-config <path> to inject a controlled
set of MCPs, but that path was never implemented, which silently stripped
all user-scope MCPs from spawned agents.

This PR completes the original design by:

- Adding a nullable mcp_config jsonb column to the agents table
- Wiring mcp_config through AgentResponse, Create/Update requests
- Piping it into ExecOptions.McpConfig in the daemon
- Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs
- Blocklisting --mcp-config in claudeBlockedArgs to prevent override
  via custom_args

Does not touch Codex provider (tracked separately in #674).
Does not implement Multica MCP auto-injection (out of scope).

* fix: disambiguate JSON null vs absent for mcp_config
2026-04-18 01:35:22 +08:00
LinYushen
133a1f1c16 ci(release): restrict tag pattern to semver and reject -dirty tags (#1280)
The release workflow previously triggered on 'v*', which matched a
stray 'v0.2.5-dirty' tag pushed to the repository. GoReleaser ran
again and overwrote the Homebrew formula with a 0.2.5-dirty version
whose tarball URLs 404.

Tighten the trigger to semver-shaped tags and add an explicit guard
that fails the job if the tag name contains '-dirty' (which can come
from 'git describe --tags --dirty').

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 07:34:25 -07:00
Bohan Jiang
b1b66ab05d ci: exclude apps/docs from frontend build/typecheck/test (#1279)
Docs site is no longer auto-deployed via Vercel (disabled in
dashboard), so building it on every PR adds friction without
catching anything actionable. Use turbo's negative filter to
skip @multica/docs across all three tasks.
2026-04-17 22:00:23 +08:00
niceSprite
0fc9641bf6 fix(docker): add restart: unless-stopped to self-host compose (#1274)
Self-hosted services (postgres, backend, frontend) should restart
automatically on failure or host reboot. This is standard practice
for production docker-compose deployments.

Co-authored-by: Zhazha <zhazha@openclaw.internal>
2026-04-17 21:57:55 +08:00
Naiyuan Qing
4223d32b37 fix(sidebar): prevent pin drag from reloading page and smooth drop animation (#1271)
- Mark AppLink draggable={false} and add pointer-events-none while
  dragging, so the browser's native <a> drag (which otherwise navigates
  to the pin's href on mouse release) is suppressed.
- Introduce a component-local pinnedItems snapshot gated by an
  isDraggingRef, so a mid-drag TQ cache write (optimistic or WS
  refetch) cannot reorder the DOM under dnd-kit's drop animation.
  Mirrors the pattern already used by board-view.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:11:39 +08:00
devv-eve
b2307a5ee9 fix(execenv): write Copilot skills to .github/skills/ for native discovery (#1270)
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md
(per the official cli-config-dir-reference docs), not from .agent_context/skills/.
Previously, skills injected for the copilot provider were placed under
.agent_context/skills/ and only referenced by name in AGENTS.md, meaning
Copilot would not actually pick them up.

- resolveSkillsDir: add a dedicated copilot case writing to .github/skills/
- Update doc comments in context.go and runtime_config.go
- Add TestWriteContextFilesCopilotNativeSkills covering the new path and
  ensuring .agent_context/skills/ is not created for copilot

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 03:07:32 -07:00
Bohan Jiang
c85c43ed0e docs: add v0.2.5 changelog entry (2026-04-17) (#1269) 2026-04-17 17:54:11 +08:00
devv-eve
eecb3a2bc8 fix(desktop): use releaseType instead of publishingType in electron-builder publish config (#1268)
electron-builder 26.8.1 rejects publishingType under the GitHub publisher;
the correct option for selecting draft/prerelease/release is releaseType.
Using publishingType caused schema validation to fail during packaging.

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:47:57 +08:00
Black
2c1478a69c fix(agents): make issue tasks easier to open from agent details (#1152)
* fix(agents): make issue tasks easier to open from agent details

Make task rows in the Tasks tab navigate directly to the related issue
detail page when issue data is available, using AppLink for cross-platform
compatibility. Rows without resolved issue data remain non-clickable.
Adds a subtle hover shadow to make the interactive area more discoverable.

Closes #1129

* fix(agents): use workspace issue paths in tasks tab

* test(agents): cover tasks tab issue links
2026-04-17 17:40:27 +08:00
Bohan Jiang
bf31fa4b39 fix(web): move /docs rewrite to beforeFiles (#1266)
* feat(docs): mount docs site at /docs subpath via basePath + multi-zone

Configure the Fumadocs site so it can be served at multica.ai/docs:

- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
  the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
  segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
  basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
  drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling

Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:

- Add DOCS_URL env (default http://localhost:4000) and rewrites for
  /docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv

* fix(web): move /docs rewrite to beforeFiles so [workspaceSlug] doesn't shadow it

The /docs rewrite was running in the default afterFiles slot, which is
evaluated *after* file-system routing. apps/web/app/[workspaceSlug]/
matched /docs first as a workspace named "docs" (which doesn't exist) and
returned 404 before the rewrite to the docs Vercel project ever fired.

Splitting rewrites into beforeFiles/afterFiles puts /docs and
/docs/:path* ahead of route resolution so they always proxy to the docs
zone.
2026-04-17 16:28:31 +08:00
Bohan Jiang
9b45e0d4a6 feat(cli): add issue subscriber commands (#1265)
* feat(cli): add `issue subscriber` commands

Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.

* fix(issues): default subscribe target to resolveActor, not X-User-ID

When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.

Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
2026-04-17 16:26:00 +08:00
460 changed files with 38506 additions and 13979 deletions

View File

@@ -4,8 +4,23 @@ POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
# You can also set pool_max_conns / pool_min_conns as query params on
# DATABASE_URL; env vars below take precedence over URL params.
# DATABASE_MAX_CONNS=25
# DATABASE_MIN_CONNS=5
# Server
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
# "production" by default, so 888888 is DISABLED — a public instance can't
# be logged into with any email + 888888.
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
# - Docker self-host on a private network you fully control, or evaluation
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
# enable on a publicly reachable instance.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
@@ -21,17 +36,28 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Self-host image channel
# Default stable release channel. Pin to an exact release like v0.2.4 if you
# want to stay on a specific version. If the selected tag has not been
# published to GHCR yet, use make selfhost-build / the build override instead.
MULTICA_IMAGE_TAG=latest
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
# changing it only requires restarting the backend / compose stack. No web
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=
@@ -40,6 +66,13 @@ CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
# Leave empty for single-host deployments (localhost, LAN IP, or a single
# hostname) — session cookies become host-only, which is what the browser
# wants. Only set it when the frontend and backend sit on different
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
# to an IP address: RFC 6265 forbids IP literals in the cookie Domain
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
@@ -63,3 +96,25 @@ NEXT_PUBLIC_WS_URL=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this
# only requires restarting the backend / compose stack — not rebuilding web.
# It is not hot-reloaded.
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=
# ==================== Analytics (PostHog) ====================
# Product analytics events feed the acquisition → activation → expansion funnel.
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=

View File

@@ -30,7 +30,7 @@ jobs:
run: pnpm install
- name: Build, type check, and test
run: pnpm build && pnpm typecheck && pnpm test
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
backend:
runs-on: ubuntu-latest
@@ -48,8 +48,22 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
# (which would flip the server binary itself onto the Redis-backed
# realtime relay + request stores); the tests talk to this Redis
# directly so they run alongside the Postgres-backed suite.
REDIS_TEST_URL: redis://localhost:6379/1
steps:
- name: Checkout
uses: actions/checkout@v6

59
.github/workflows/desktop-smoke.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Desktop Smoke Build
on:
workflow_dispatch:
permissions:
contents: read
jobs:
desktop:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish never
- name: Upload Desktop artifacts (${{ matrix.target }})
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.target }}
path: apps/desktop/dist
if-no-files-found: error

View File

@@ -3,13 +3,59 @@ name: Release
on:
push:
tags:
- "v*"
# GitHub Actions uses glob patterns here, not regex. Match versioned
# tags broadly at the trigger layer, then enforce strict semver below.
- "v*.*.*"
- "!v*-dirty*"
permissions:
contents: write
packages: write
jobs:
verify:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.release_meta.outputs.tag_name }}
is_stable: ${{ steps.release_meta.outputs.is_stable }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate tag name
id: release_meta
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
exit 1
fi
if [[ "$tag" == *-dirty* ]]; then
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
if [[ "$tag" == *-* ]]; then
echo "is_stable=false" >> "$GITHUB_OUTPUT"
else
echo "is_stable=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run tests
run: cd server && go test ./...
release:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -23,9 +69,6 @@ jobs:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run tests
run: cd server && go test ./...
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -34,3 +77,298 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# Multi-arch images are built natively per platform on dedicated runners
# (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm) and merged into a
# manifest list. This avoids QEMU emulation, which was making the Next.js
# arm64 build run for 30+ minutes per release.
docker-backend-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute backend image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
labels: |
org.opencontainers.image.title=Multica Backend
org.opencontainers.image.description=Multica self-hosted backend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-backend-${{ env.PLATFORM_PAIR }}
build-args: |
VERSION=${{ needs.verify.outputs.tag_name }}
COMMIT=${{ github.sha }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-backend,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-backend-merge:
needs: [verify, docker-backend-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-backend-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute backend image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-backend@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-backend:${{ steps.meta.outputs.version }}
docker-web-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute web image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
labels: |
org.opencontainers.image.title=Multica Web
org.opencontainers.image.description=Multica self-hosted web frontend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-web-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-web-${{ env.PLATFORM_PAIR }}
build-args: |
REMOTE_API_URL=http://backend:8080
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-web,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-web-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-web-merge:
needs: [verify, docker-web-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-web-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-web-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute web image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-web@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it
# can be signed + notarized with Apple Developer credentials that are
# not (yet) wired into CI.
desktop:
needs: release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# electron-builder's GitHub publisher reads this:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable code signing on Linux/Windows for now — the public
# release is unsigned for these platforms, the CLI carries the
# trust boundary. Set CSC_LINK in repo secrets to enable
# Windows signing later.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always

1
.gitignore vendored
View File

@@ -57,3 +57,4 @@ _features/
server/server
data/
.kilo
.idea

View File

@@ -21,12 +21,12 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: default
# Legacy archive name kept so already-released CLIs (whose `multica update`
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
# once those versions are no longer in use.
- id: legacy
formats:
- tar.gz
format_overrides:
@@ -34,6 +34,16 @@ archives:
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
# Versioned archive name used by current CLI / install scripts /
# desktop bootstrap going forward.
- id: versioned
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
checksum:
name_template: "checksums.txt"
@@ -48,6 +58,8 @@ changelog:
brews:
- name: multica
ids:
- versioned
repository:
owner: multica-ai
name: homebrew-tap

85
.vercelignore Normal file
View File

@@ -0,0 +1,85 @@
# Deploy the frontend apps from the monorepo root.
# Keep apps/web, apps/docs, shared packages, and root workspace metadata.
# Exclude unrelated workspaces and local artifacts that can make
# `vercel deploy` upload far more than the app needs.
.agent_context
.claude
.context
.env*
.envrc
.tool-versions
_features
.kilo
.idea
.DS_Store
.husky
.vscode
/.dockerignore
/.goreleaser.yml
/AGENTS.md
/CLAUDE.md
/CLI_AND_DAEMON.md
/CLI_INSTALL.md
/CONTRIBUTING.md
/Dockerfile
/Dockerfile.web
/HANDOFF_ARCHITECTURE_AUDIT.md
/Makefile
/README.md
/README.zh-CN.md
/SELF_HOSTING.md
/SELF_HOSTING_ADVANCED.md
/SELF_HOSTING_AI.md
/docker-compose*.yml
/playwright.config.ts
/skills-lock.json
/.github/
/docker/
/docs/
/e2e/
/server/
/apps/desktop/
/scripts/
*.log
*.pid
*.tsbuildinfo
.cache
.next
.pnpm-store
.turbo
.vercel
coverage
test-results
playwright-report
data
node_modules
bin
dist
out
build
dist-electron
# Deployment-only trims: tests and lint configs are not used by `next build`.
**/__tests__/**
**/test/**
**/*.test.*
**/*.spec.*
/packages/eslint-config/
/apps/web/components.json
/apps/web/eslint.config.mjs
/apps/web/vitest.config.ts
# Root repo metadata not needed in the deployment source.
/.env.example
/.gitattributes
/.gitignore
/LICENSE
*.app
*.dmg

View File

@@ -106,6 +106,7 @@ pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
```
### CI Requirements
@@ -190,55 +191,28 @@ Every path in the desktop app falls into exactly one category. Choosing the wron
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace identity singleton
### Workspace context
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
1. API client's `X-Workspace-Slug` header.
2. Zustand per-workspace storage namespace.
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order:
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list (no extra fetch).
1. Read destination from cached workspace list.
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
3. `navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
Reversing step 4 with steps 13 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS window-move)
### Drag region (macOS)
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via `useImmersiveMode` in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
**Pattern**: flex child at top, not absolute overlay.
```tsx
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
{/* page content — interactive elements need their own "no-drag" */}
</div>
</div>
```
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Height matches `MainTopBar` (48px / `h-12`) for consistency.
Canonical examples: `components/window-overlay.tsx`, `pages/login.tsx`.
### UX vs platform chrome
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (drag strip, `useImmersiveMode`, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules

View File

@@ -332,6 +332,27 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
### Subscribers
```bash
# List subscribers of an issue
multica issue subscriber list <issue-id>
# Subscribe yourself to an issue
multica issue subscriber add <issue-id>
# Subscribe another member or agent by name
multica issue subscriber add <issue-id> --user "Lambda"
# Unsubscribe yourself
multica issue subscriber remove <issue-id>
# Unsubscribe another member or agent
multica issue subscriber remove <issue-id> --user "Lambda"
```
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
### Execution History
```bash

View File

@@ -76,7 +76,8 @@ fi
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
VERSION="${LATEST#v}"
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz

View File

@@ -592,6 +592,19 @@ If you want to stop PostgreSQL and keep your local databases:
make db-down
```
If you want a fresh database for the current checkout only (drops the
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
```bash
make stop # stop backend/frontend first
make db-reset
make start
```
- only affects the current env's database; other worktree databases are untouched
- refuses to run if `DATABASE_URL` points at a remote host
- pass `ENV_FILE=.env.worktree` to target a specific worktree
If you want to wipe all local PostgreSQL data for this repo:
```bash

View File

@@ -36,11 +36,11 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_APP_VERSION=dev
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

166
Makefile
View File

@@ -1,4 +1,4 @@
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,10 +36,23 @@ define REQUIRE_ENV
fi
endef
# ---------- Self-hosting (Docker Compose) ----------
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
##@ Help
help: ## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
makehelp: help ## Alias for `make help`
# ---------- Self-hosting (Docker Compose) ----------
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -51,8 +64,16 @@ selfhost:
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
echo " make selfhost-build"; \
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml up -d
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -66,7 +87,11 @@ selfhost:
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in with any email + verification code: 888888"; \
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -77,16 +102,57 @@ selfhost:
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
# Stop all Docker Compose self-host services
selfhost-stop:
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
##@ One-click
# First-time setup: install deps, start DB, run migrations
setup:
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
@@ -97,8 +163,7 @@ setup:
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
# Start all services (backend + frontend)
start:
start: ## Start backend and frontend for the current checkout and run migrations first
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@@ -112,8 +177,7 @@ start:
pnpm dev:web & \
wait
# Stop all services
stop:
stop: ## Stop backend and frontend processes for the current checkout
$(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@@ -125,33 +189,52 @@ stop:
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
# Full verification: typecheck + unit tests + Go tests + E2E
check:
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up:
db-up: ## Start the shared PostgreSQL container used by main and worktrees
@$(COMPOSE) up -d postgres
db-down:
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
@$(COMPOSE) down
worktree-env:
# Drop + recreate the current env's database, then run all migrations.
# Use for a clean slate in local dev. Only affects the DB named in
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
# worktree DBs are untouched. Refuses to run against a remote host.
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
$(REQUIRE_ENV)
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
esac
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
@bash scripts/init-worktree-env.sh .env.worktree
setup-main:
setup-main: ## Prepare the main checkout using .env
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main:
start-main: ## Start the main checkout using .env
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main:
stop-main: ## Stop the main checkout processes defined by .env
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main:
check-main: ## Run the full verification pipeline for the main checkout
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
@@ -160,65 +243,68 @@ setup-worktree:
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
start-worktree: ## Start this worktree using .env.worktree
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree:
stop-worktree: ## Stop this worktree's backend and frontend processes
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree:
check-worktree: ## Run the full verification pipeline for this worktree
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
##@ Individual commands
# One-command dev: auto-setup env/deps/db/migrations, then start all services
dev:
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
@bash scripts/dev.sh
# Go server only
server:
server: ## Run only the Go server for the current checkout
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon:
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
cli:
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
multica:
multica: ## Run the multica CLI entrypoint directly from the Go source tree
cd server && go run ./cmd/multica $(MULTICA_ARGS)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build:
build: ## Build the server, CLI, and migrate binaries into server/bin
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database
migrate-up:
##@ Database
migrate-up: ## Create the target DB if needed, then apply database migrations
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down:
migrate-down: ## Create the target DB if needed, then roll back database migrations
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc:
sqlc: ## Regenerate sqlc code
cd server && sqlc generate
# Cleanup
clean:
##@ Cleanup
clean: ## Remove generated server binaries and temp files
rm -rf server/bin server/tmp

View File

@@ -85,7 +85,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
---
@@ -184,13 +185,3 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -172,13 +172,3 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -24,9 +24,9 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -54,6 +54,10 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
@@ -63,9 +67,15 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
### Step 3 — Install CLI & Start Daemon
@@ -152,14 +162,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Rebuilding After Updates
## Upgrading
```bash
git pull
make selfhost
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -182,6 +193,7 @@ JWT_SECRET=$(openssl rand -hex 32)
Then start everything:
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```

View File

@@ -14,6 +14,15 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Database Pool Tuning (Optional)
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
@@ -23,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
### Google OAuth (Optional)
@@ -33,6 +42,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -44,7 +65,14 @@ For file uploads and attachments, configure S3 and CloudFront:
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server
@@ -218,7 +246,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -234,15 +262,31 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then rebuild:
Then restart the stack:
```bash
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml up -d
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
### WebSocket for LAN / Non-localhost Access
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
```bash
# In .env
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
# Rebuild the web image so the build-time value is baked in
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
```
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
## Health Check
@@ -258,8 +302,9 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.

View File

@@ -21,31 +21,34 @@ mac:
- zip
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
# `${name}` produces for scoped package names.
artifactName: multica-desktop-${version}-${arch}.${ext}
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
# so the filename alone surfaces kind, version, platform, and CPU arch.
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
# unaffected because `pnpm package` already requires the Developer ID
# signing cert — notarization is a strict superset.
notarize: true
dmg:
artifactName: multica-desktop-${version}-${arch}.${ext}
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
linux:
target:
- AppImage
- deb
artifactName: ${name}-${version}-${arch}.${ext}
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
publish:
provider: github
owner: multica-ai
repo: multica
# Align with our CLI release flow which pre-creates a *published* GitHub
# Release via `gh release create`. The electron-builder default of
# `publishingType: draft` conflicts with `existingType=release` and causes
# `releaseType: draft` conflicts with `existingType=release` and causes
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
# which breaks electron-updater auto-update on installed clients.
publishingType: release
releaseType: release
npmRebuild: false

View File

@@ -2,17 +2,31 @@
"name": "@multica/desktop",
"version": "0.1.0",
"private": true,
"description": "Multica Desktop — native desktop client for the Multica platform.",
"homepage": "https://multica.ai",
"repository": {
"type": "git",
"url": "https://github.com/multica-ai/multica.git",
"directory": "apps/desktop"
},
"author": {
"name": "Multica",
"email": "support@multica.ai"
},
"license": "UNLICENSED",
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "node scripts/package.mjs",
"package:all": "node scripts/package.mjs --all-platforms --publish never",
"lint": "eslint .",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
@@ -25,6 +39,7 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/geist-mono": "^5.2.7",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",

View File

@@ -13,7 +13,7 @@
// skip the build and fall through to auto-install at runtime. A genuine
// Go compile error is fatal — you want that to block dev, not hide.
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
@@ -23,8 +23,54 @@ const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
const binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const PLATFORM_TO_GOOS = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
function runtimePlatformFromArgs(argv) {
const flagIndex = argv.indexOf("--target-platform");
if (flagIndex === -1) return process.platform;
return argv[flagIndex + 1] ?? "";
}
function runtimeArchFromArgs(argv) {
const flagIndex = argv.indexOf("--target-arch");
if (flagIndex === -1) return process.arch;
return argv[flagIndex + 1] ?? "";
}
function normalizeRuntimePlatform(platform) {
if (platform in PLATFORM_TO_GOOS) return platform;
throw new Error(
`[bundle-cli] unsupported target platform: ${platform}. ` +
"Use darwin, linux, or win32.",
);
}
function normalizeRuntimeArch(arch) {
if (SUPPORTED_ARCHS.has(arch)) return arch;
throw new Error(
`[bundle-cli] unsupported target architecture: ${arch}. ` +
"Use x64 or arm64.",
);
}
function binaryNameForPlatform(platform) {
return platform === "win32" ? "multica.exe" : "multica";
}
const targetPlatform = normalizeRuntimePlatform(
runtimePlatformFromArgs(process.argv.slice(2)),
);
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
const goos = PLATFORM_TO_GOOS[targetPlatform];
const goarch = targetArch === "x64" ? "amd64" : targetArch;
const binName = binaryNameForPlatform(targetPlatform);
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
@@ -61,8 +107,9 @@ if (hasGo()) {
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
);
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
execFileSync(
"go",
[
@@ -70,10 +117,19 @@ if (hasGo()) {
"-ldflags",
ldflags,
"-o",
join("bin", binName),
srcBinary,
"./cmd/multica",
],
{ cwd: serverDir, stdio: "inherit" },
{
cwd: serverDir,
stdio: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
GOOS: goos,
GOARCH: goarch,
},
},
);
} else {
console.warn(
@@ -88,9 +144,11 @@ if (!(await exists(srcBinary))) {
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
`auto-installing the latest release at runtime.`,
);
await rm(destDir, { recursive: true, force: true });
process.exit(0);
}
await rm(destDir, { recursive: true, force: true });
await mkdir(destDir, { recursive: true });
await copyFile(srcBinary, destBinary);
await chmod(destBinary, 0o755);

View File

@@ -5,11 +5,11 @@
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
// into resources/bin/), then `electron-vite build` to produce the
// main/preload/renderer bundles under out/, then invokes electron-builder
// with `-c.extraMetadata.version=<derived>` so the override applies at
// build time without mutating the tracked package.json.
// Builds the Electron bundles once, then for each requested target
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
// the override applies at build time without mutating the tracked
// package.json.
//
// The electron-vite step is important: electron-builder only packages
// whatever is already in out/, so skipping it (or relying on stale
@@ -25,11 +25,50 @@
// version-derivation logic without shelling out.
import { execFileSync, spawnSync, execSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { delimiter, dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(here, "..");
const bundleCliScript = resolve(here, "bundle-cli.mjs");
const PLATFORM_CONFIG = {
mac: {
aliases: new Set(["--mac", "--macos", "-m"]),
builderFlag: "--mac",
runtimePlatform: "darwin",
label: "macOS",
},
win: {
aliases: new Set(["--win", "--windows", "-w"]),
builderFlag: "--win",
runtimePlatform: "win32",
label: "Windows",
},
linux: {
aliases: new Set(["--linux", "-l"]),
builderFlag: "--linux",
runtimePlatform: "linux",
label: "Linux",
},
};
const ARCH_FLAGS = new Map([
["--x64", "x64"],
["--arm64", "arm64"],
["--ia32", "ia32"],
["--armv7l", "armv7l"],
["--universal", "universal"],
]);
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
const MAC_ALL_PLATFORM_TARGETS = [
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
];
function sh(cmd) {
try {
@@ -77,20 +116,231 @@ function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function main() {
// Step 1: build + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
function uniqueOrdered(values) {
return [...new Set(values)];
}
// Step 2: build the Electron main/preload/renderer bundles. Without
export function envWithLocalBins(env = process.env, root = desktopRoot) {
const pathKey =
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
const existingPath = env[pathKey] ?? "";
const localBins = uniqueOrdered([
resolve(root, "node_modules", ".bin"),
resolve(root, "..", "..", "node_modules", ".bin"),
]);
const mergedPath = uniqueOrdered([
...localBins,
...String(existingPath)
.split(delimiter)
.filter(Boolean),
]).join(delimiter);
return { ...env, [pathKey]: mergedPath };
}
function hostPlatformKey(platform = process.platform) {
if (platform === "darwin") return "mac";
if (platform === "win32") return "win";
if (platform === "linux") return "linux";
throw new Error(`[package] unsupported host platform: ${platform}`);
}
function hostArchKey(arch = process.arch) {
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
throw new Error(
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
);
}
function expandPlatformShorthand(token) {
if (!/^-[mwl]{2,}$/.test(token)) return null;
const expanded = [];
for (const char of token.slice(1)) {
if (char === "m") expanded.push("mac");
if (char === "w") expanded.push("win");
if (char === "l") expanded.push("linux");
}
return uniqueOrdered(expanded);
}
function platformKeyForToken(token) {
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
if (config.aliases.has(token)) return platform;
}
return null;
}
function platformTargetsTemplate() {
return { mac: [], win: [], linux: [] };
}
export function parsePackageArgs(argv) {
const sharedArgs = [];
const platformTargets = platformTargetsTemplate();
const requestedPlatforms = [];
const requestedArchs = [];
let allPlatforms = false;
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--all-platforms") {
allPlatforms = true;
continue;
}
const expandedPlatforms = expandPlatformShorthand(token);
if (expandedPlatforms) {
requestedPlatforms.push(...expandedPlatforms);
continue;
}
const platform = platformKeyForToken(token);
if (platform) {
requestedPlatforms.push(platform);
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
platformTargets[platform].push(argv[i + 1]);
i += 1;
}
continue;
}
const arch = ARCH_FLAGS.get(token);
if (arch) {
requestedArchs.push(arch);
continue;
}
sharedArgs.push(token);
}
return {
allPlatforms,
sharedArgs,
platformTargets,
requestedPlatforms: uniqueOrdered(requestedPlatforms),
requestedArchs: uniqueOrdered(requestedArchs),
};
}
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
if (parsed.allPlatforms) {
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
throw new Error(
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
);
}
if (platform !== "darwin") {
throw new Error(
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
);
}
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
}
const platforms =
parsed.requestedPlatforms.length > 0
? parsed.requestedPlatforms
: [hostPlatformKey(platform)];
const archs =
parsed.requestedArchs.length > 0
? parsed.requestedArchs
: [hostArchKey(arch)];
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
if (unsupported.length > 0) {
throw new Error(
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
"Use --x64 or --arm64.",
);
}
return platforms.flatMap((targetPlatform) =>
archs.map((targetArch) => ({
platform: targetPlatform,
arch: targetArch,
})),
);
}
function formatTarget(target) {
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
}
export function builderArgsForTarget(
target,
parsed,
version,
{
disableMacNotarize = false,
hostPlatform = process.platform,
useScopedOutputDir = false,
} = {},
) {
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
const requestedTargets = parsed.platformTargets[target.platform];
if (
target.platform === "linux" &&
hostPlatform !== "linux" &&
requestedTargets.length === 0
) {
// electron-builder only guarantees AppImage/Snap when cross-building
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
// to AppImage unless the caller explicitly requests Linux targets.
builderArgs.push("AppImage");
} else {
builderArgs.push(...requestedTargets);
}
builderArgs.push(`--${target.arch}`);
builderArgs.push(...parsed.sharedArgs);
if (useScopedOutputDir) {
builderArgs.push(
`-c.directories.output=dist/${target.platform}-${target.arch}`,
);
}
// electron-builder's update metadata file is `latest.yml` for Windows
// regardless of arch (only Linux gets an arch suffix automatically — see
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
// channel override, building Windows x64 and arm64 in two invocations
// makes both publish `latest.yml` to the same GitHub Release, so the
// second upload overwrites the first and one of the two architectures
// ends up with no auto-update metadata. Route Windows arm64 to its own
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
// the renderer-side updater pins the matching channel per arch.
if (target.platform === "win" && target.arch === "arm64") {
builderArgs.push("-c.publish.channel=latest-arm64");
}
return builderArgs;
}
function main() {
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const parsed = parsePackageArgs(passthrough);
const buildMatrix = resolveBuildMatrix(parsed);
console.log(
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
);
// Step 1: build the Electron main/preload/renderer bundles. Without
// this step electron-builder silently packages whatever is already in
// out/, which on a fresh checkout (or after a partial build) ships an
// app that white-screens because the renderer bundle is missing.
//
// CI invokes this script via `node scripts/package.mjs`, so we cannot
// rely on pnpm/npm to inject package-local binaries into PATH.
//
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
// PATHEXT when spawning a bare command without a shell — it would fail
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
// through the shell is harmless. See
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
const viteResult = spawnSync("electron-vite", ["build"], {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (viteResult.error) {
console.error(
@@ -103,7 +353,7 @@ function main() {
process.exit(viteResult.status ?? 1);
}
// Step 3: derive the version that should be written into the app.
// Step 2: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -113,43 +363,62 @@ function main() {
);
}
// Step 4: assemble electron-builder args.
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
// credentials, and would otherwise hit a hard failure at the notarize
// step. Detect the missing env and flip notarize off for this run only.
if (!process.env.APPLE_TEAM_ID) {
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
if (disableMacNotarize) {
console.warn(
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
);
builderArgs.push("-c.mac.notarize=false");
}
builderArgs.push(...passthrough);
const useScopedOutputDir = buildMatrix.length > 1;
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
// Step 3: for each requested target, build the matching CLI into
// resources/bin/ and package that target in isolation.
for (const target of buildMatrix) {
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
execFileSync(
"node",
[
bundleCliScript,
"--target-platform",
PLATFORM_CONFIG[target.platform].runtimePlatform,
"--target-arch",
target.arch,
],
{
stdio: "inherit",
cwd: desktopRoot,
},
);
process.exit(1);
const builderArgs = builderArgsForTarget(target, parsed, version, {
disableMacNotarize,
hostPlatform: process.platform,
useScopedOutputDir,
});
// Step 4: invoke electron-builder for the current target only.
// `shell: true` for the same Windows `.cmd` shim reason as the
// electron-vite invocation above.
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.

View File

@@ -1,5 +1,13 @@
import { delimiter, resolve } from "node:path";
import { describe, it, expect } from "vitest";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
import {
builderArgsForTarget,
envWithLocalBins,
normalizeGitVersion,
parsePackageArgs,
resolveBuildMatrix,
stripLeadingSeparator,
} from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -59,3 +67,207 @@ describe("stripLeadingSeparator", () => {
expect(stripLeadingSeparator([])).toEqual([]);
});
});
describe("parsePackageArgs", () => {
it("collects per-platform targets and shared args", () => {
expect(
parsePackageArgs([
"--win", "nsis",
"--mac", "dmg", "zip",
"--arm64",
"--publish", "never",
]),
).toEqual({
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: {
mac: ["dmg", "zip"],
win: ["nsis"],
linux: [],
},
requestedPlatforms: ["win", "mac"],
requestedArchs: ["arm64"],
});
});
it("expands combined short flags", () => {
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
"mac",
"win",
]);
});
it("tracks the all-platforms shortcut", () => {
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
});
});
describe("resolveBuildMatrix", () => {
it("defaults to the current host platform and arch", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([{ platform: "mac", arch: "arm64" }]);
});
it("expands all-platforms on macOS", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: true,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
]);
});
it("rejects unsupported architectures", () => {
expect(() =>
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["universal"],
},
"darwin",
"arm64",
),
).toThrow(/unsupported Desktop CLI architecture/);
});
});
describe("builderArgsForTarget", () => {
it("adds scoped output directories for multi-target builds", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "arm64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["arm64"],
},
"1.2.3",
{
disableMacNotarize: true,
hostPlatform: "darwin",
useScopedOutputDir: true,
},
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"-c.mac.notarize=false",
"--win",
"nsis",
"--arm64",
"--publish",
"never",
"-c.directories.output=dist/win-arm64",
"-c.publish.channel=latest-arm64",
]);
});
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "always"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "win32", useScopedOutputDir: true },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--win",
"nsis",
"--x64",
"--publish",
"always",
"-c.directories.output=dist/win-x64",
]);
});
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
expect(
builderArgsForTarget(
{ platform: "linux", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["linux"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "darwin" },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--linux",
"AppImage",
"--x64",
"--publish",
"never",
]);
});
});
describe("envWithLocalBins", () => {
it("prepends desktop-local binary directories to PATH", () => {
const desktopRoot = "/repo/apps/desktop";
const result = envWithLocalBins(
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
desktopRoot,
);
expect(result.PATH.split(delimiter)).toEqual([
resolve(desktopRoot, "node_modules", ".bin"),
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
"/usr/local/bin",
"/usr/bin",
]);
});
it("preserves an existing Path key and avoids duplicate entries", () => {
const desktopRoot = "/repo/apps/desktop";
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
const result = envWithLocalBins(
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
desktopRoot,
);
expect(result).not.toHaveProperty("PATH");
expect(result.Path.split(delimiter)).toEqual([
desktopBin,
workspaceBin,
"runner-bin",
]);
});
});

View File

@@ -8,35 +8,15 @@ import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
// Desktop prefers the bundled `multica` CLI shipped inside the app for
// same-repo builds, but it can also repair or bootstrap a managed copy in
// userData on first launch when the bundled binary is missing or unusable.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
@@ -92,14 +72,8 @@ async function sha256OfFile(path: string): Promise<string> {
async function verifyChecksum(
archivePath: string,
assetName: string,
expected: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
@@ -118,7 +92,14 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
async function installFresh(): Promise<string> {
const target = managedCliPath();
const assetName = platformAssetName();
const checksums = await fetchChecksums();
const assetName = selectPlatformReleaseAssetName(checksums.keys());
const expectedChecksum = checksums.get(assetName);
if (!expectedChecksum) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
@@ -130,7 +111,7 @@ async function installFresh(): Promise<string> {
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName);
await verifyChecksum(archivePath, assetName, expectedChecksum);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
@@ -143,6 +124,7 @@ async function installFresh(): Promise<string> {
}
await mkdir(dirname(target), { recursive: true });
await rm(target, { force: true }).catch(() => {});
await rename(extractedBin, target);
await chmod(target, 0o755);
@@ -166,8 +148,10 @@ async function installFresh(): Promise<string> {
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(): Promise<string> {
export async function ensureManagedCli(
options: { forceInstall?: boolean } = {},
): Promise<string> {
const target = managedCliPath();
if (existsSync(target)) return target;
if (existsSync(target) && !options.forceInstall) return target;
return installFresh();
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
describe("selectPlatformReleaseAssetName", () => {
it("prefers the versioned archive name when both exist", () => {
const assetNames = [
"checksums.txt",
"multica_darwin_amd64.tar.gz",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("falls back to the legacy archive name when only legacy is present", () => {
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica_darwin_amd64.tar.gz",
);
});
it("matches the renamed darwin archive from release assets", () => {
const assetNames = [
"checksums.txt",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
"multica-cli-1.2.3-darwin-arm64.tar.gz",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("matches the renamed windows zip archive", () => {
const assetNames = [
"multica-cli-1.2.3-windows-amd64.zip",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
"multica-cli-1.2.3-windows-amd64.zip",
);
});
it("fails when the current platform asset is missing", () => {
expect(() =>
selectPlatformReleaseAssetName(
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
"darwin",
"arm64",
),
).toThrow(/no release asset found/);
});
});

View File

@@ -0,0 +1,62 @@
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
function platformArchiveDescriptor(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): { os: string; arch: string; ext: string } {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[platform];
const mappedArch = archMap[arch];
if (!os || !mappedArch) {
throw new Error(
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
);
}
const ext = platform === "win32" ? "zip" : "tar.gz";
return { os, arch: mappedArch, ext };
}
export function selectPlatformReleaseAssetName(
assetNames: Iterable<string>,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): string {
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
platform,
arch,
);
const names = [...assetNames];
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
// only ship the legacy archive keep working.
const suffix = `-${os}-${mappedArch}.${ext}`;
const matches = names.filter(
(name) =>
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
);
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
throw new Error(
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
);
}
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
if (names.includes(legacyName)) {
return legacyName;
}
throw new Error(`no release asset found for current platform: ${suffix}`);
}

View File

@@ -0,0 +1,33 @@
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
// Electron ships with no default right-click menu, so a user selecting text
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
// menu using `roles`, which keeps i18n + accelerator handling native.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable } = params;
const hasSelection = selectionText.trim().length > 0;
const menu = new Menu();
if (isEditable && editFlags.canCut) {
menu.append(new MenuItem({ role: "cut" }));
}
if (hasSelection && editFlags.canCopy) {
menu.append(new MenuItem({ role: "copy" }));
}
if (isEditable && editFlags.canPaste) {
menu.append(new MenuItem({ role: "paste" }));
}
if (isEditable && editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(new MenuItem({ role: "selectAll" }));
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}

View File

@@ -316,6 +316,36 @@ function bundledCliPath(): string {
);
}
async function probeCliBinary(
bin: string,
source: "bundled" | "managed" | "path",
): Promise<string | null> {
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
if (typeof parsed.version === "string" && parsed.version.length > 0) {
return parsed.version;
}
console.warn(
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
);
return null;
} catch (err) {
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
return null;
}
}
/**
* Returns a usable `multica` binary path. Priority:
* 1. Cached result from a previous successful resolve.
@@ -339,27 +369,55 @@ async function resolveCliBinary(): Promise<string | null> {
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
const version = await probeCliBinary(bundled, "bundled");
if (version) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
cachedCliBinaryVersion = version;
return bundled;
}
}
const managed = managedCliPath();
if (existsSync(managed)) {
cachedCliBinary = managed;
return managed;
const version = await probeCliBinary(managed, "managed");
if (version) {
cachedCliBinary = managed;
cachedCliBinaryVersion = version;
return managed;
}
}
try {
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
const installed = await ensureManagedCli({
forceInstall: existsSync(managed),
});
const version = await probeCliBinary(installed, "managed");
if (version) {
cachedCliBinary = installed;
cachedCliBinaryVersion = version;
return installed;
}
console.warn(
`[daemon] managed CLI at ${installed} failed validation after install`,
);
} catch (err) {
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
const onPath = findCliOnPath();
cachedCliBinary = onPath;
return onPath;
}
const onPath = findCliOnPath();
if (onPath) {
const version = await probeCliBinary(onPath, "path");
if (version) {
cachedCliBinary = onPath;
cachedCliBinaryVersion = version;
return onPath;
}
}
cachedCliBinary = null;
cachedCliBinaryVersion = null;
return null;
})();
try {
@@ -370,11 +428,10 @@ async function resolveCliBinary(): Promise<string | null> {
}
/**
* Reads the version of the currently resolved CLI binary by invoking
* `multica version --output json`. Cached for the process lifetime — the
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
* Reads the version of the currently resolved CLI binary. Cached for the
* process lifetime — the bundled binary doesn't change after bundle time.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* etc.) so callers can fail open.
* wrong-arch bundled binary, etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
@@ -383,24 +440,7 @@ async function getCliBinaryVersion(): Promise<string | null> {
cachedCliBinaryVersion = null;
return null;
}
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
cachedCliBinaryVersion = parsed.version ?? null;
} catch (err) {
console.warn("[daemon] failed to read CLI binary version:", err);
cachedCliBinaryVersion = null;
}
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
return cachedCliBinaryVersion;
}

View File

@@ -6,6 +6,7 @@ import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -109,6 +110,8 @@ function createWindow(): void {
return { action: "deny" };
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
@@ -193,6 +196,16 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer
// already carries X-Client-Version and X-Client-OS.
ipcMain.on("app:get-info", (event) => {
const p = process.platform;
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
event.returnValue = { version: app.getVersion(), os };
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.

View File

@@ -1,9 +1,31 @@
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Windows arm64 ships its own update metadata channel because
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
// arches would otherwise collide on the same file in the GitHub Release.
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
// of this pact. Pin the channel here so arm64 clients fetch
// `latest-arm64.yml` instead of the x64 metadata.
if (process.platform === "win32" && process.arch === "arm64") {
autoUpdater.channel = "latest-arm64";
}
const STARTUP_CHECK_DELAY_MS = 5_000;
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export type ManualUpdateCheckResult =
| {
ok: true;
currentVersion: string;
latestVersion: string;
available: boolean;
}
| { ok: false; error: string };
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
@@ -37,10 +59,42 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.quitAndInstall(false, true);
});
// Check for updates after a short delay to avoid blocking startup
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
try {
const result = await autoUpdater.checkForUpdates();
const currentVersion = app.getVersion();
// Trust electron-updater's own decision rather than re-deriving it from
// a version-string compare. The two diverge for pre-release channels,
// staged rollouts, downgrades, and minimum-system-version gates — in
// those cases updateInfo.version differs from app.getVersion() but no
// `update-available` event fires, so showing "available" here would
// promise a download prompt that never appears.
return {
ok: true,
currentVersion,
latestVersion: result?.updateInfo.version ?? currentVersion,
available: result?.isUpdateAvailable ?? false,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
});
// Initial check shortly after startup so we don't block boot.
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 5000);
}, STARTUP_CHECK_DELAY_MS);
// Background poll so long-running sessions still pick up new releases
// without requiring the user to restart the app.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Periodic update check failed:", err);
});
}, PERIODIC_CHECK_INTERVAL_MS);
}

View File

@@ -1,6 +1,11 @@
import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
appInfo: {
version: string;
os: "macos" | "windows" | "linux" | "unknown";
};
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
@@ -53,6 +58,10 @@ interface UpdaterAPI {
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
checkForUpdates: () => Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
>;
}
declare global {

View File

@@ -1,7 +1,32 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
// (async ipc.invoke) would race the ApiClient construction in initCore and
// the first few HTTP requests would go out without X-Client-Version/OS.
function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" | "unknown" } {
try {
const info = ipcRenderer.sendSync("app:get-info") as
| { version: string; os: "macos" | "windows" | "linux" | "unknown" }
| undefined;
if (info && typeof info.version === "string" && typeof info.os === "string") return info;
} catch {
// fall through
}
// Fallback: derive OS from process.platform; version unknown.
const p = process.platform;
const os: "macos" | "windows" | "linux" | "unknown" =
p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
return { version: "unknown", os };
}
const appInfo = fetchAppInfo();
const desktopAPI = {
/** App version + normalized OS. Read once at preload time so the renderer
* can use it synchronously when initializing the API client. */
appInfo,
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
@@ -96,6 +121,10 @@ const updaterAPI = {
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
checkForUpdates: (): Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
> => ipcRenderer.invoke("updater:check"),
};
if (process.contextIsolated) {

View File

@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { PageviewTracker } from "./components/pageview-tracker";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
@@ -90,11 +92,28 @@ function AppContent() {
// account switches (user A logout → user B login) should not trigger a
// daemon restart here — daemon-manager already restarts on user change
// via syncToken.
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const wsCount = workspaces?.length ?? 0;
const wsCount = workspaces.length;
const hasOnboarded = useHasOnboarded();
// Onboarding and zero-workspace both resolve to an overlay, but
// onboarding wins: a user who hasn't completed it gets the onboarding
// overlay regardless of how many workspaces already exist.
useEffect(() => {
if (!user || !workspaceListFetched) return;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return;
if (!hasOnboarded) {
open({ type: "onboarding" });
return;
}
if (wsCount === 0) {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
@@ -104,32 +123,22 @@ function AppContent() {
// warning because `switchWorkspace` is a Zustand setState that the
// TabBar is subscribed to. useLayoutEffect flushes both renders before
// the user sees anything, so there's no visible flicker.
//
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
// the first fetch, so without this guard we'd run validation against an
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
// back to `workspaces[0]` once the real list arrives — losing the user's
// last-opened workspace on every app start.
useLayoutEffect(() => {
if (!workspaces) return;
const validSlugs = new Set(workspaces.map((w) => w.slug));
const tabStore = useTabStore.getState();
tabStore.validateWorkspaceSlugs(validSlugs);
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
tabStore.switchWorkspace(workspaces[0].slug);
}
}, [workspaces]);
// Bidirectional new-workspace overlay: visible when there are no
// workspaces to enter, hidden as soon as one exists. Gated on
// `workspaceListFetched` so the initial render doesn't flash the
// overlay before the list arrives. The overlay's own `invite` type is
// not touched here — that's an in-flight task owned by the user.
useEffect(() => {
if (!user) return;
if (!workspaceListFetched) return;
const { overlay, open, close } = useWindowOverlayStore.getState();
const isEmpty = wsCount === 0;
if (isEmpty) {
if (!overlay) open({ type: "new-workspace" });
} else if (overlay?.type === "new-workspace") {
close();
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (!activeWorkspaceSlug && workspaces.length > 0) {
switchWorkspace(workspaces[0].slug);
}
}, [user, workspaceListFetched, wsCount]);
}, [workspaces, workspaceListFetched]);
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
@@ -158,8 +167,15 @@ function AppContent() {
);
}
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
// Pageview tracker sits at the app root so it covers every visible
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
// would miss the logged-out and overlay states.
return (
<>
<PageviewTracker />
{user ? <DesktopShell /> : <DesktopLoginPage />}
</>
);
}
// Backend the daemon should connect to — same URL the renderer talks to.
@@ -187,12 +203,20 @@ async function handleDaemonLogout() {
}
export default function App() {
const { version, os } = window.desktopAPI.appInfo;
// Stable identity reference so downstream effects (WS reconnect) don't
// tear down on every parent render.
const identity = useMemo(
() => ({ platform: "desktop", version, os }),
[version, os],
);
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
identity={identity}
>
<AppContent />
</CoreProvider>

View File

@@ -12,7 +12,7 @@ import {
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
@@ -123,17 +123,16 @@ export function DesktopShell() {
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
{/* Content area with inset styling */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so its empty
* state can distinguish "no runtime registered" from "runtime is on its
* way" — without the bundled daemon's status, the page shows a
* misleading "Run multica daemon start" hint during the few seconds
* between page load and the daemon's first registration.
*
* `bootstrapping` is true while the daemon is installing, starting, or
* already running but hasn't surfaced as a server-side runtime yet.
* RuntimeList only shows the spinner when the runtime list is also
* empty, so once the daemon registers (and the list fills) the flag
* has no visible effect.
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
useEffect(() => {
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
status.state === "installing_cli" ||
status.state === "starting" ||
status.state === "running";
return (
<RuntimesPage
topSlot={<DaemonRuntimeCard />}
bootstrapping={bootstrapping}
/>
);
}

View File

@@ -0,0 +1,69 @@
import { useEffect } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes.
*
* Desktop has three layers that can own the visible page:
*
* 1. Logged-out state → `/login`. No workspace context, no tabs.
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
* that match the equivalent web routes. Overlays are NOT tab routes on
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
* tab path alone would either miss them or mislabel them as "/".
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
*
* The overlay takes precedence over the tab path because it is visually in
* front of the tab system; the logged-out state shadows both because the
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
* with what the user actually sees.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
* implementation in `apps/web/components/pageview-tracker.tsx`.
*/
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const activeTabPath = useTabStore((s) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
});
const path = resolvePath(user, overlay, activeTabPath);
useEffect(() => {
if (!path) return;
capturePageview(path);
}, [path]);
return null;
}
function resolvePath(
user: unknown,
overlay: WindowOverlay | null,
activeTabPath: string | null,
): string | null {
if (!user) return "/login";
if (overlay) return overlayPath(overlay);
return activeTabPath;
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":
return "/workspaces/new";
case "onboarding":
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
}
}

View File

@@ -5,6 +5,7 @@ import {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
X,
Plus,
@@ -39,6 +40,7 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
};

View File

@@ -110,12 +110,25 @@ export function UpdateNotification() {
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<button
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
<div className="mt-2 flex items-center gap-1.5">
{/* Secondary "See changes" — gives the user a reason to
restart by surfacing what they're about to get. Opens
in the default browser via the shared openExternal
bridge so the URL hits the same allow-list as every
other outbound link. */}
<button
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
See changes
</button>
<button
onClick={handleInstall}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,86 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date"; currentVersion: string }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const [state, setState] = useState<CheckState>({ status: "idle" });
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
const result = await window.updater.checkForUpdates();
if (!result.ok) {
setState({ status: "error", message: result.error });
return;
}
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date", currentVersion: result.currentVersion },
);
}, []);
return (
<div>
<h2 className="text-lg font-semibold">Updates</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch.
</p>
<div className="mt-6 divide-y">
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates appear as a notification in the corner.
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
You&apos;re on the latest version (v{state.currentVersion}).
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is available see the download prompt
in the corner.
</p>
)}
{state.status === "error" && (
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
<AlertCircle className="size-3.5" />
{state.message}
</p>
)}
</div>
<div className="shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleCheck}
disabled={state.status === "checking"}
>
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking
</>
) : (
"Check now"
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useImmersiveMode } from "@multica/views/platform";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { OnboardingFlow } from "@multica/views/onboarding";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
@@ -9,18 +9,21 @@ import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Window-level transition overlay: renders above the tab system when the
* user is in a pre-workspace flow (create workspace, accept invite).
* user is in a pre-workspace flow (onboarding, create workspace, accept
* invite).
*
* This component is a thin **platform shell**:
* - Hands the window-drag strip and macOS traffic-light hiding
* (`useImmersiveMode`) — both are platform-specific, web has neither
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
* doesn't leak through
* This component is intentionally thin — just a fixed positioning shell
* that covers the tab system. It does NOT hide traffic lights or provide
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
* native macOS traffic lights stay visible and the page content can fill
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
* pre-dashboard flows and keeps platform chrome consistent across every
* "not-in-dashboard" surface.
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
* components under `packages/views/`, so web and desktop render identical
* content. The platform split is: UX in shared code, chrome here.
* card) live inside the shared view components under `packages/views/`,
* so web and desktop render identical content.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
@@ -34,8 +37,6 @@ function WindowOverlayInner() {
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
useImmersiveMode();
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
@@ -44,42 +45,35 @@ function WindowOverlayInner() {
const onBack = wsList.length > 0 ? close : undefined;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Window-drag strip. Rendered as a flex *child* (not absolute
overlay) so it owns its own 48px of real layout space — the
prior absolute-positioned approach relied on z-index stacking
to beat the content wrapper's no-drag, which in practice didn't
hit-test reliably for `-webkit-app-region` on the welcome
screen. A real flex row with nothing else in it has no such
ambiguity: any pixel at top-48 is drag, full stop.
Height matches `MainTopBar` (48px) so the drag-to-grab area
feels consistent with the rest of the app. The strip is
invisible; macOS traffic lights would normally sit here but
`useImmersiveMode` has hidden them for the overlay's lifetime. */}
<div
aria-hidden
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div
className="flex-1 min-h-0 overflow-auto"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
</div>
<div className="fixed inset-0 z-50 flex flex-col overflow-auto bg-background">
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {
close();
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
/>
)}
</div>
);
}

View File

@@ -25,6 +25,8 @@
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-serif;
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
"Apple Garamond", Baskerville, "Times New Roman", serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}

View File

@@ -4,6 +4,11 @@ import App from "./App";
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
import "@fontsource-variable/inter";
// Editorial serif — matches web's next/font Source_Serif_4. Loaded app-wide so
// onboarding headings and any future editorial surface can use `font-serif`
// (see tokens.css @theme inline). Variable font = one file covers all weights.
import "@fontsource-variable/source-serif-4";
import "@fontsource-variable/source-serif-4/wght-italic.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";

View File

@@ -1,4 +1,5 @@
import { LoginPage } from "@multica/views/auth";
import { DragStrip } from "@multica/views/platform";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
@@ -14,11 +15,7 @@ export function DesktopLoginPage() {
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
<div
className="h-[38px] shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<DragStrip />
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
onSuccess={() => {

View File

@@ -15,10 +15,11 @@ import {
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
// that actually points somewhere a teammate can open.
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
// Public web app URL — injected at build time via .env.production. In dev
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
// link" in a dev build yields a URL that points at the running dev
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
/**
* Extract the leading workspace slug from a path, or null if the path isn't
@@ -53,6 +54,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
}
return true;
}
if (path === "/onboarding") {
overlay.open({ type: "onboarding" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {
@@ -106,18 +114,32 @@ export function DesktopNavigationProvider({
// resolve the active router here only to subscribe once per tab switch.
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
const [pathname, setPathname] = useState(
router?.state.location.pathname ?? "/",
// Mirror the active tab router's full location (pathname + search) so
// shell-level consumers of useNavigation() can read URL search params.
// Must stay in sync with TabNavigationProvider below; a partial shape
// here (just pathname) silently broke focus-mode anchor resolution on
// `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",
search: router?.state.location.search ?? "",
}),
);
useEffect(() => {
if (!router) {
setPathname("/");
setLocation({ pathname: "/", search: "" });
return;
}
setPathname(router.state.location.pathname);
setLocation({
pathname: router.state.location.pathname,
search: router.state.location.search,
});
return router.subscribe((state) => {
setPathname(state.location.pathname);
setLocation({
pathname: state.location.pathname,
search: state.location.search,
});
});
}, [activeTabId, router]);
@@ -142,8 +164,8 @@ export function DesktopNavigationProvider({
back: () => {
currentActiveTab()?.router.navigate(-1);
},
pathname,
searchParams: new URLSearchParams(),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
@@ -159,7 +181,7 @@ export function DesktopNavigationProvider({
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
[pathname],
[location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -13,14 +13,15 @@ import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { ChatPage } from "@multica/views/chat";
import { SettingsPage } from "@multica/views/settings";
import { Server } from "lucide-react";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
@@ -113,12 +114,13 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
element: <DesktopRuntimesPage />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
{
path: "settings",
element: (
@@ -130,6 +132,12 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -101,6 +101,7 @@ interface TabStore {
const ROUTE_ICONS: Record<string, string> = {
inbox: "Inbox",
chat: "MessageSquare",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",

View File

@@ -14,7 +14,8 @@ import { create } from "zustand";
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string };
| { type: "invite"; invitationId: string }
| { type: "onboarding" };
interface WindowOverlayStore {
overlay: WindowOverlay | null;

View File

@@ -169,6 +169,16 @@ Stop PostgreSQL and keep local databases:
make db-down
```
Reset only the current checkout's database (drops `POSTGRES_DB`, recreates it, re-runs all migrations). Other worktree databases are untouched.
```bash
make stop
make db-reset
make start
```
> `make db-reset` refuses to run if `DATABASE_URL` points at a remote host.
Wipe all local PostgreSQL data:
```bash

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 log in with any email + code **`888888`**.
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -53,21 +53,31 @@ make selfhost
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
<Callout>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
</Callout>
### Step 3 — Install CLI & Start Daemon
@@ -147,14 +157,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Rebuilding After Updates
## Upgrading
```bash
git pull
make selfhost
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -187,6 +198,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -198,7 +221,14 @@ For file uploads and attachments, configure S3 and CloudFront:
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server

View File

@@ -11,32 +11,51 @@ function createWrapper() {
);
}
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
const {
mockSendCode,
mockVerifyCode,
mockIssueCliToken,
searchParamsState,
authStateRef,
} = vi.hoisted(() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockIssueCliToken: vi.fn(),
searchParamsState: { params: new URLSearchParams() },
authStateRef: {
state: {
sendCode: vi.fn(),
verifyCode: vi.fn(),
user: null as null | { id: string; email: string },
isLoading: false,
},
},
}));
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/login",
useSearchParams: () => new URLSearchParams(),
useSearchParams: () => searchParamsState.params,
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
user: null,
isLoading: false,
};
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
// than silently drifting behind a mock reimplementation.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
authStateRef.state.sendCode = mockSendCode;
authStateRef.state.verifyCode = mockVerifyCode;
const useAuthStore = Object.assign(
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
(selector: (s: typeof authStateRef.state) => unknown) =>
selector(authStateRef.state),
{ getState: () => authStateRef.state },
);
return { useAuthStore };
return { ...actual, useAuthStore };
});
// Mock auth-cookie
@@ -51,6 +70,7 @@ vi.mock("@multica/core/api", () => ({
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
issueCliToken: mockIssueCliToken,
},
}));
@@ -59,6 +79,9 @@ import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
searchParamsState.params = new URLSearchParams();
authStateRef.state.user = null;
authStateRef.state.isLoading = false;
});
it("renders login form with email input and continue button", () => {
@@ -131,4 +154,44 @@ describe("LoginPage", () => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
// Regression: MUL-1080 — if the user is already authenticated on the web
// and the Desktop app redirects them to /login?platform=desktop, the web
// must exchange the cookie session for a bearer token and hand it off via
// the multica:// deep link, not silently redirect to the workspace page.
it("mints a token and deep-links to Desktop when already logged in with platform=desktop", async () => {
searchParamsState.params = new URLSearchParams({ platform: "desktop" });
authStateRef.state.user = { id: "u1", email: "test@multica.ai" };
mockIssueCliToken.mockImplementation(() =>
Promise.resolve({ token: "handoff-jwt" }),
);
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
});
try {
render(<LoginPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockIssueCliToken).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith(
"multica://auth/callback?token=handoff-jwt",
);
});
expect(
await screen.findByRole("button", { name: "Open Multica Desktop" }),
).toBeInTheDocument();
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
});

View File

@@ -1,20 +1,36 @@
"use client";
import { Suspense, useEffect } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { useConfigStore } from "@multica/core/config";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import {
paths,
resolvePostAuthDestination,
useHasOnboarded,
} from "@multica/core/paths";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import { Loader2 } from "lucide-react";
import { captureDownloadIntent } from "@multica/core/analytics";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import Link from "next/link";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
function LoginPageContent() {
const router = useRouter();
const qc = useQueryClient();
const googleClientId = useConfigStore((state) => state.googleClientId);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const searchParams = useSearchParams();
@@ -22,40 +38,67 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
// `next` carries a protected URL the user was originally headed to
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
// "/issues" default — if `next` is absent we decide after login based on
// the user's workspace list.
const nextUrl = searchParams.get("next");
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
// cannot bounce the user off-origin after a successful login.
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
const [desktopToken, setDesktopToken] = useState<string | null>(null);
const [desktopError, setDesktopError] = useState("");
const hasOnboarded = useHasOnboarded();
// Already authenticated — honor ?next= or fall back to first workspace
// (or /workspaces/new if the user has none). Skip this entire path when
// (or /onboarding if the user has none). Skip this entire path when
// the user arrived to authorize the CLI.
useEffect(() => {
if (isLoading || !user || cliCallbackRaw) return;
if (isDesktopHandoff) {
// Desktop opened the browser for login but the web session is already
// authenticated — mint a bearer token from the cookie session and hand
// it off via deep link instead of silently redirecting to the workspace.
api
.issueCliToken()
.then(({ token }) => {
setDesktopToken(token);
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
})
.catch((err) => {
setDesktopError(
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
);
});
return;
}
if (!hasOnboarded) {
router.replace(paths.onboarding());
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.replace(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
const handleSuccess = () => {
// Read the latest user snapshot directly — the closure's `hasOnboarded`
// was captured before login completed and would be stale here.
const currentUser = useAuthStore.getState().user;
const onboarded = currentUser?.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
if (nextUrl) {
router.push(nextUrl);
return;
}
// The LoginPage view populates the workspace list cache before calling
// onSuccess, so it's safe to read here.
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.push(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
router.push(resolvePostAuthDestination(list, onboarded));
};
// Build Google OAuth state: encode platform + next URL so the callback
@@ -67,6 +110,52 @@ function LoginPageContent() {
.filter(Boolean)
.join(",") || undefined;
// While the desktop handoff is in progress (or has produced a token/error),
// render a dedicated screen instead of flashing the login form or redirecting
// away to a workspace page.
if (isDesktopHandoff && user) {
if (desktopError) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
<CardDescription>{desktopError}</CardDescription>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Opening Multica</CardTitle>
<CardDescription>
{desktopToken
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
: "Preparing Desktop sign-in..."}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
{desktopToken ? (
<Button
variant="outline"
onClick={() => {
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
}}
>
Open Multica Desktop
</Button>
) : (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
)}
</CardContent>
</Card>
</div>
);
}
return (
<LoginPage
onSuccess={handleSuccess}
@@ -85,6 +174,22 @@ function LoginPageContent() {
: undefined
}
onTokenObtained={setLoggedInCookie}
extra={
// Web-only nudge toward the desktop app. Copy is hardcoded EN
// for now because the login route sits outside the landing
// group's LocaleProvider — if this page ever becomes
// locale-aware, the strings live in positioning doc §3.3.
<span className="text-xs text-muted-foreground">
Prefer the desktop app?{" "}
<Link
href="/download"
onClick={() => captureDownloadIntent("login")}
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
>
Download
</Link>
</span>
}
/>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import {
paths,
resolvePostAuthDestination,
useHasOnboarded,
} from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboarding";
/**
* Web shell for the onboarding flow. The route is the platform chrome on
* web (matching `WindowOverlay` on desktop); content is the shared
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
*
* On complete: if a workspace was just created, navigate into it;
* otherwise fall back to root (proxy / landing picks the user's first ws
* or bounces to onboarding if still zero).
*
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
* slot so the flow can render it inside the CLI dialog. The commands it
* shows are hardcoded — nothing environmental to thread through.
*/
export default function OnboardingPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user && hasOnboarded,
});
useEffect(() => {
if (isLoading || !user) {
if (!isLoading && !user) router.replace(paths.login());
return;
}
if (hasOnboarded && workspacesFetched) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
if (isLoading || !user || hasOnboarded) return null;
// Layout: page owns its own scroll (root layout sets `body {
// overflow: hidden }` for the app-shell convention). OnboardingFlow
// owns the per-step width constraint internally — Welcome renders a
// wide two-column hero, all other steps wrap themselves at max-w-xl.
return (
<div className="h-full overflow-y-auto bg-background">
<OnboardingFlow
onComplete={(ws) => {
// No more firstIssueId handoff — the welcome issue is created
// inside the workspace via StarterContentPrompt, not during
// onboarding. Always land on the workspace issues list (or
// root if the flow never produced a workspace).
if (ws) {
router.push(paths.workspace(ws.slug).issues());
} else {
router.push(paths.root());
}
}}
runtimeInstructions={<CliInstallInstructions />}
/>
</div>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { LandingHeader } from "@/features/landing/components/landing-header";
import { LandingFooter } from "@/features/landing/components/landing-footer";
import { DownloadHero } from "@/features/landing/components/download/hero";
import { AllPlatforms } from "@/features/landing/components/download/all-platforms";
import { CliSection } from "@/features/landing/components/download/cli-section";
import { CloudSection } from "@/features/landing/components/download/cloud-section";
import { useLocale } from "@/features/landing/i18n";
import {
detectOS,
type DetectResult,
} from "@/features/landing/utils/os-detect";
import type { LatestRelease } from "@/features/landing/utils/github-release";
import { captureDownloadPageViewed } from "@multica/core/analytics";
const ALL_RELEASES_URL =
"https://github.com/multica-ai/multica/releases";
export function DownloadClient({ release }: { release: LatestRelease }) {
const [detected, setDetected] = useState<DetectResult | null>(null);
const versionUnavailable = release.version === null;
useEffect(() => {
let cancelled = false;
detectOS().then((result) => {
if (cancelled) return;
setDetected(result);
// Fires once per page mount after detect resolves. Carries the
// detect outcome + version-unavailable flag so PostHog can split
// Safari-mac-arm64 fallback rate, Intel-Mac dead-end rate, and
// rate-limit degraded sessions. `first_detected_os/arch` is
// $set_once'd on the person so every downstream event gains a
// platform dimension (useful for "Android visitors who later
// downloaded Windows" style cross-device queries once we land
// the desktop install closure).
captureDownloadPageViewed({
detected_os: result.os,
detected_arch: result.arch,
detect_confident: result.archConfident,
version_available: !versionUnavailable,
});
});
return () => {
cancelled = true;
};
}, [versionUnavailable]);
const releaseHtmlUrl = release.htmlUrl ?? ALL_RELEASES_URL;
return (
<>
{/* Positioning context for the dark-variant LandingHeader —
mirrors multica-landing.tsx. The header is `absolute top-0
inset-x-0`, so it anchors to this `relative` wrapper and
scrolls off together with the dark hero below. Without the
wrapper, `absolute` would escape to the initial containing
block and read as fixed. */}
<div className="relative">
<LandingHeader variant="dark" />
<DownloadHero
detected={detected}
assets={release.assets}
versionUnavailable={versionUnavailable}
version={release.version}
/>
</div>
<AllPlatforms
assets={release.assets}
fallbackHref={ALL_RELEASES_URL}
version={release.version}
detected={detected}
/>
<CliSection />
<CloudSection />
<VersionInfoFooter
version={release.version}
releaseHtmlUrl={releaseHtmlUrl}
/>
<LandingFooter />
</>
);
}
function VersionInfoFooter({
version,
releaseHtmlUrl,
}: {
version: string | null;
releaseHtmlUrl: string;
}) {
const { t } = useLocale();
const d = t.download.footer;
return (
<section className="bg-white pb-16 text-[#0a0d12] sm:pb-20">
<div className="mx-auto flex max-w-[920px] flex-wrap items-center gap-x-6 gap-y-2 border-t border-[#0a0d12]/8 px-4 pt-8 text-[13px] text-[#0a0d12]/60 sm:px-6 lg:px-8">
{version ? (
<>
<span>
{d.currentVersion.replace("{version}", version)}
</span>
<span aria-hidden className="text-[#0a0d12]/25">
·
</span>
<Link
href={releaseHtmlUrl}
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
target="_blank"
rel="noreferrer"
>
{d.releaseNotes.replace("{version}", version)}
</Link>
<span aria-hidden className="text-[#0a0d12]/25">
·
</span>
</>
) : (
<>
<span>{d.versionUnavailable}</span>
<span aria-hidden className="text-[#0a0d12]/25">
·
</span>
</>
)}
<Link
href={ALL_RELEASES_URL}
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
target="_blank"
rel="noreferrer"
>
{d.allReleases}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { fetchLatestRelease } from "@/features/landing/utils/github-release";
import { DownloadClient } from "./download-client";
// Vercel ISR: the server fetch inside fetchLatestRelease carries
// `next: { revalidate: 300 }`, which makes GitHub API cost at most
// one request per region per 5 minutes. Page-level revalidate mirrors
// that window so the first paint also refreshes every 5 minutes.
export const revalidate = 300;
export const metadata: Metadata = {
title: "Download Multica",
description:
"Download Multica for macOS, Windows, or Linux — or install the CLI for servers and remote dev boxes.",
openGraph: {
title: "Download Multica",
description:
"Get the Multica desktop app with a bundled daemon, or install the CLI for servers and remote dev boxes.",
url: "/download",
},
alternates: {
canonical: "/download",
},
};
export default async function DownloadPage() {
const release = await fetchLatestRelease();
return <DownloadClient release={release} />;
}

View File

@@ -67,7 +67,7 @@ export default async function LandingLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} landing-light h-full overflow-x-hidden overflow-y-auto bg-white`}>
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
</div>
</>

View File

@@ -0,0 +1 @@
export { ChatPage as default } from "@multica/views/chat";

View File

@@ -3,14 +3,19 @@
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
extra={
<>
<SearchCommand />
<StarterContentPrompt />
</>
}
>
{children}
</DashboardLayout>

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { paths } from "@multica/core/paths";
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
id: "user-1",
name: "Test",
email: "test@multica.ai",
avatar_url: null,
onboarded_at: null,
onboarding_questionnaire: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
// exercised rather than silently diverging from the source of truth.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
return {
...actual,
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ loginWithGoogle: mockLoginWithGoogle }),
};
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
googleLogin: vi.fn(),
},
}));
import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
});
it("unonboarded user lands on /onboarding regardless of next=", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
});
it("unonboarded user with no next= also lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
mockSearchParams.set("state", "next:https://evil.example");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
});
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
});
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
});

View File

@@ -3,9 +3,9 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
import { api } from "@multica/core/api";
import {
Card,
@@ -42,7 +42,9 @@ function CallbackContent() {
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
// Strip "next:" prefix, then drop anything that isn't a safe relative path
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
const redirectUri = `${window.location.origin}/auth/callback`;
@@ -60,18 +62,17 @@ function CallbackContent() {
} else {
// Normal web flow
loginWithGoogle(code, redirectUri)
.then(async () => {
.then(async (loggedInUser) => {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
// URL is now the source of truth for the current workspace — the
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
// in the first workspace's issues, or /workspaces/new for zero-workspace users.
const [first] = wsList;
const defaultDest = first
? paths.workspace(first.slug).issues()
: paths.newWorkspace();
router.push(nextUrl || defaultDest);
const onboarded = loggedInUser.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
router.push(
nextUrl || resolvePostAuthDestination(wsList, onboarded),
);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -3,3 +3,44 @@
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
* @multica/ui/styles/base.css
* ============================================================================= */
/* The landing route tree is intentionally always-light (hero/cli/cloud
* sections use hardcoded dark/light palettes). Shared components rendered
* inside (e.g. CloudWaitlistExpand on /download) use semantic tokens that
* otherwise flip to dark values under the `.dark` class set by next-themes,
* producing a palette mismatch against the hardcoded section. Re-declare
* tokens to their light values so nested token-driven components stay in
* lockstep with the surrounding design. */
.landing-light,
.landing-light * {
color-scheme: light;
}
.landing-light {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Inter, Geist_Mono } from "next/font/google";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { cn } from "@multica/ui/lib/utils";
@@ -39,6 +39,23 @@ const geistMono = Geist_Mono({
variable: "--font-mono",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
});
// Editorial serif used for onboarding headlines. Italic support for h1 em
// accents (e.g. "...on one shared board."). Only loaded on routes that
// render the font; layout-shift-prevention handled by next/font's synthetic
// fallback metrics, same as Inter.
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
style: ["normal", "italic"],
variable: "--font-serif",
fallback: [
"ui-serif",
"Iowan Old Style",
"Apple Garamond",
"Baskerville",
"Times New Roman",
"serif",
],
});
export const viewport: Viewport = {
width: "device-width",
@@ -89,7 +106,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />

View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { capturePageview } from "@multica/core/analytics";
/**
* Fires a PostHog $pageview whenever the Next.js App Router path or query
* string changes. Mounted once at the root so every route transition is
* covered, including transitions into workspace-scoped subtrees.
*
* PostHog's own `capture_pageview: true` auto-capture is deliberately
* disabled in `initAnalytics` so we own the event shape — this component
* is what actually fires the event. Before this existed the acquisition
* funnel's `/ → signup` step was empty.
*/
export function PageviewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!pathname) return;
const qs = searchParams?.toString();
const url = qs ? `${pathname}?${qs}` : pathname;
capturePageview(url);
}, [pathname, searchParams]);
return null;
}

View File

@@ -1,11 +1,14 @@
"use client";
import { Suspense, useMemo } from "react";
import { CoreProvider } from "@multica/core/platform";
import packageJson from "../package.json";
import { WebNavigationProvider } from "@/platform/navigation";
import {
setLoggedInCookie,
clearLoggedInCookie,
} from "@/features/auth/auth-cookie";
import { PageviewTracker } from "./pageview-tracker";
// Legacy token in localStorage → keep this session in token mode so users who
// logged in before the cookie-auth migration stay authed. They migrate to
@@ -32,8 +35,20 @@ function deriveWsUrl(): string | undefined {
return `${proto}//${window.location.host}/ws`;
}
// Build-time version preferred (CI sets NEXT_PUBLIC_APP_VERSION to a git tag
// or sha so different deploys are distinguishable in server logs); fall back
// to the package.json version so local dev still reports something useful.
const WEB_VERSION =
process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version || "dev";
export function WebProviders({ children }: { children: React.ReactNode }) {
const cookieAuth = !hasLegacyToken();
// Stable identity reference so downstream effects keyed on it don't see a
// new object on every parent render.
const identity = useMemo(
() => ({ platform: "web", version: WEB_VERSION }),
[],
);
return (
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
@@ -41,7 +56,13 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
cookieAuth={cookieAuth}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
identity={identity}
>
{/* Suspense boundary is required by Next.js for useSearchParams in
a client component mounted this high in the tree. */}
<Suspense fallback={null}>
<PageviewTracker />
</Suspense>
<WebNavigationProvider>{children}</WebNavigationProvider>
</CoreProvider>
);

View File

@@ -1,8 +1,91 @@
"use client";
import {
type MouseEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { LandingHeader } from "./landing-header";
import { LandingFooter } from "./landing-footer";
import { useLocale } from "../i18n";
import type { Locale } from "../i18n/types";
const MONTHS_EN = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
type ParsedDate = { year: number; month: number; day: number };
function parseDate(dateStr: string): ParsedDate {
const parts = dateStr.split("-");
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
};
}
function monthYearLabel(year: number, month: number, locale: Locale) {
if (!year || !month) return "";
if (locale === "zh") return `${year}\u5e74${month}\u6708`;
return `${MONTHS_EN[month - 1]} ${year}`;
}
function fullDateLabel(dateStr: string, locale: Locale) {
const { year, month, day } = parseDate(dateStr);
if (!year || !month || !day) return dateStr;
if (locale === "zh") return `${year}\u5e74${month}\u6708${day}\u65e5`;
return `${MONTHS_EN[month - 1]} ${day}, ${year}`;
}
type Release = {
version: string;
date: string;
title: string;
changes: string[];
features?: string[];
improvements?: string[];
fixes?: string[];
};
type MonthGroup = {
key: string;
year: number;
month: number;
entries: Release[];
};
function groupByMonth(entries: readonly Release[]): MonthGroup[] {
const groups: MonthGroup[] = [];
for (const entry of entries) {
const { year, month } = parseDate(entry.date);
const key = `${year}-${month}`;
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.entries.push(entry);
} else {
groups.push({ key, year, month, entries: [entry] });
}
}
return groups;
}
function anchorId(version: string) {
return `release-${version.replace(/\./g, "-")}`;
}
function ChangeList({ items }: { items: string[] }) {
return (
@@ -21,74 +104,222 @@ function ChangeList({ items }: { items: string[] }) {
}
export function ChangelogPageClient() {
const { t } = useLocale();
const { t, locale } = useLocale();
const categoryLabels = t.changelog.categories;
const entries = t.changelog.entries;
const groups = useMemo(() => groupByMonth(entries), [entries]);
const [activeVersion, setActiveVersion] = useState<string>(
entries[0]?.version ?? ""
);
const navLockRef = useRef<number | null>(null);
useEffect(() => {
if (entries.length === 0) return;
const visible = new Set<string>();
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((e) => {
const v = (e.target as HTMLElement).dataset.version;
if (!v) return;
if (e.isIntersecting) visible.add(v);
else visible.delete(v);
});
// Ignore observer updates while we're programmatically scrolling
// to a clicked target — otherwise the active indicator flickers
// through each passing entry.
if (navLockRef.current !== null) return;
const firstVisible = entries.find((r) => visible.has(r.version));
if (firstVisible) {
setActiveVersion(firstVisible.version);
return;
}
const scrollY = window.scrollY;
let best = entries[0]?.version ?? "";
for (const r of entries) {
const el = document.getElementById(anchorId(r.version));
if (!el) continue;
if (el.getBoundingClientRect().top + scrollY <= scrollY + 160) {
best = r.version;
}
}
setActiveVersion(best);
},
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
);
entries.forEach((r) => {
const el = document.getElementById(anchorId(r.version));
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [entries]);
const jumpTo =
(version: string) => (e: MouseEvent<HTMLAnchorElement>) => {
const el = document.getElementById(anchorId(version));
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${anchorId(version)}`);
setActiveVersion(version);
if (navLockRef.current !== null) {
window.clearTimeout(navLockRef.current);
}
navLockRef.current = window.setTimeout(() => {
navLockRef.current = null;
}, 800);
};
return (
<>
<LandingHeader variant="light" />
<main className="bg-white text-[#0a0d12]">
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="mx-auto max-w-[1080px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<div className="lg:grid lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16">
<aside className="hidden lg:block">
<nav
aria-label={t.changelog.toc}
className="sticky top-28 max-h-[calc(100vh-8rem)] overflow-y-auto pb-8 pr-2"
>
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#0a0d12]/50">
{t.changelog.toc}
</h3>
<div className="mt-16 space-y-16">
{t.changelog.entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
<div className="relative mt-5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-[4px] top-7 bottom-2 w-px bg-[#0a0d12]/10"
/>
return (
<div key={release.version} className="relative">
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{release.date}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
<ol className="space-y-5">
{groups.map((group) => (
<li key={group.key}>
<p className="ml-6 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#0a0d12]/45">
{monthYearLabel(group.year, group.month, locale)}
</p>
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
)}
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
</div>
) : (
<ChangeList items={release.changes} />
)}
<ol className="mt-1.5">
{group.entries.map((release) => {
const isActive =
release.version === activeVersion;
const { day } = parseDate(release.date);
return (
<li key={release.version}>
<a
href={`#${anchorId(release.version)}`}
onClick={jumpTo(release.version)}
aria-current={isActive ? "true" : undefined}
className={[
"group relative flex items-center gap-3 rounded-md py-1 pr-2 text-[13px] transition-colors",
isActive
? "text-[#0a0d12]"
: "text-[#0a0d12]/55 hover:text-[#0a0d12]/80",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"relative z-10 block size-[9px] shrink-0 rounded-full border transition-all duration-200",
isActive
? "border-[#0a0d12] bg-[#0a0d12] ring-4 ring-[#0a0d12]/8"
: "border-[#0a0d12]/25 bg-white group-hover:border-[#0a0d12]/60",
].join(" ")}
/>
<span
className={[
"w-[1.25rem] shrink-0 text-right tabular-nums",
isActive
? "font-semibold"
: "font-medium",
].join(" ")}
>
{day}
</span>
<span className="tabular-nums text-[11px] text-[#0a0d12]/35">
v{release.version}
</span>
</a>
</li>
);
})}
</ol>
</li>
))}
</ol>
</div>
);
})}
</nav>
</aside>
<div className="mx-auto min-w-0 max-w-[720px] lg:mx-0">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="mt-16 space-y-16">
{entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
return (
<section
key={release.version}
id={anchorId(release.version)}
data-version={release.version}
className="relative scroll-mt-28"
>
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{fullDateLabel(release.date, locale)}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
)}
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
</div>
) : (
<ChangeList items={release.changes} />
)}
</section>
);
})}
</div>
</div>
</div>
</div>
</main>

View File

@@ -0,0 +1,239 @@
import Link from "next/link";
import {
captureDownloadInitiated,
type DownloadInitiatedPayload,
} from "@multica/core/analytics";
import { useLocale } from "../../i18n";
import type { DetectResult } from "../../utils/os-detect";
import type { DownloadAssets } from "../../utils/parse-release-assets";
import { AppleIcon, LinuxIcon, WindowsIcon } from "./os-icons";
type Platform = DownloadInitiatedPayload["platform"];
type Arch = DownloadInitiatedPayload["arch"];
type Format = DownloadInitiatedPayload["format"];
interface Props {
assets: DownloadAssets;
/** Link to GitHub releases page, used when individual asset URLs
* couldn't be resolved (API down / parse failure). */
fallbackHref: string;
/** Release tag (e.g. "v0.2.13"); null on fetch failure. */
version: string | null;
/** Current OS/arch guess. Used only to compute `matched_detect` on
* the download_initiated event — the row UI itself is static. */
detected: DetectResult | null;
}
/**
* Full matrix of platform + arch + format links. Always visible
* regardless of which platform the Hero resolved to — lets power
* users grab any build directly.
*/
export function AllPlatforms({
assets,
fallbackHref,
version,
detected,
}: Props) {
const { t } = useLocale();
const d = t.download.allPlatforms;
const trackClick = (platform: Platform, arch: Arch, format: Format) => {
if (!version) return;
captureDownloadInitiated({
platform,
arch,
format,
version,
// Manual pick from the matrix — Hero is the primary CTA.
primary_cta: false,
// True only when the row matches what we guessed client-side.
// Lets us measure detect accuracy from the miss rate on this
// event alone (no need to cross-join to download_page_viewed).
matched_detect:
!!detected &&
detected.os === platform &&
detected.arch === arch,
});
};
return (
<section
id="all-platforms"
className="bg-white py-20 text-[#0a0d12] sm:py-24"
>
<div className="mx-auto max-w-[920px] px-4 sm:px-6 lg:px-8">
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
{d.title}
</h2>
<div className="mt-10 overflow-hidden rounded-2xl border border-[#0a0d12]/10">
<Row
icon={<AppleIcon className="text-[#0a0d12]" />}
label={d.macLabel}
formats={[
{
label: d.formatDmg,
href: assets.macArm64Dmg,
onClick: () => trackClick("mac", "arm64", "dmg"),
},
{
label: d.formatZip,
href: assets.macArm64Zip,
onClick: () => trackClick("mac", "arm64", "zip"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<WindowsIcon className="text-[#0a0d12]" />}
label={d.winX64Label}
formats={[
{
label: d.formatExe,
href: assets.winX64Exe,
onClick: () => trackClick("windows", "x64", "exe"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<WindowsIcon className="text-[#0a0d12]" />}
label={d.winArm64Label}
formats={[
{
label: d.formatExe,
href: assets.winArm64Exe,
onClick: () => trackClick("windows", "arm64", "exe"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<LinuxIcon className="text-[#0a0d12]" />}
label={d.linuxX64Label}
formats={[
{
label: d.formatAppImage,
href: assets.linuxAmd64AppImage,
onClick: () => trackClick("linux", "x64", "appimage"),
},
{
label: d.formatDeb,
href: assets.linuxAmd64Deb,
onClick: () => trackClick("linux", "x64", "deb"),
},
{
label: d.formatRpm,
href: assets.linuxAmd64Rpm,
onClick: () => trackClick("linux", "x64", "rpm"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<LinuxIcon className="text-[#0a0d12]" />}
label={d.linuxArm64Label}
formats={[
{
label: d.formatAppImage,
href: assets.linuxArm64AppImage,
onClick: () => trackClick("linux", "arm64", "appimage"),
},
{
label: d.formatDeb,
href: assets.linuxArm64Deb,
onClick: () => trackClick("linux", "arm64", "deb"),
},
{
label: d.formatRpm,
href: assets.linuxArm64Rpm,
onClick: () => trackClick("linux", "arm64", "rpm"),
},
]}
unavailable={d.unavailable}
isLast
/>
</div>
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.intelNote}</p>
{isFallbackNeeded(assets) ? (
<p className="mt-2 text-[13px] text-[#0a0d12]/60">
<Link
href={fallbackHref}
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
target="_blank"
rel="noreferrer"
>
{t.download.footer.allReleases}
</Link>
</p>
) : null}
</div>
</section>
);
}
// ------------------------------------------------------------
// Row
// ------------------------------------------------------------
interface RowProps {
icon: React.ReactNode;
label: string;
formats: {
label: string;
href: string | undefined;
onClick: () => void;
}[];
unavailable: string;
isLast?: boolean;
}
function Row({ icon, label, formats, unavailable, isLast }: RowProps) {
return (
<div
className={`flex flex-wrap items-center gap-x-6 gap-y-3 px-6 py-5 ${isLast ? "" : "border-b border-[#0a0d12]/8"}`}
>
<div className="flex min-w-[220px] items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#0a0d12]/5">
{icon}
</span>
<span className="text-[14.5px] font-medium">{label}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{formats.map((f) =>
f.href ? (
<a
key={f.label}
href={f.href}
onClick={f.onClick}
className="inline-flex items-center gap-1.5 rounded-lg border border-[#0a0d12]/12 bg-white px-3 py-1.5 text-[13px] font-medium transition-colors hover:border-[#0a0d12]/30 hover:bg-[#0a0d12]/5"
>
{f.label}
</a>
) : (
<span
key={f.label}
aria-disabled="true"
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-lg border border-[#0a0d12]/8 bg-[#0a0d12]/5 px-3 py-1.5 text-[13px] text-[#0a0d12]/40"
title={unavailable}
>
{f.label}
</span>
),
)}
</div>
</div>
);
}
// Ten desktop artifacts are expected per release (two Mac,
// two Windows, six Linux). If any are missing, surface the GitHub
// fallback link so users on an orphaned row have a way out.
const EXPECTED_ASSET_COUNT = 10;
function isFallbackNeeded(assets: DownloadAssets): boolean {
return Object.values(assets).filter(Boolean).length < EXPECTED_ASSET_COUNT;
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { Check, Copy, Terminal } from "lucide-react";
import { useLocale } from "../../i18n";
const INSTALL_CMD =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
const SETUP_CMD = "multica setup";
/**
* Scenario-first CLI section. Copy leans into servers / remote dev
* boxes / headless setups rather than positioning CLI as a
* lightweight Desktop. Two copy-and-paste command blocks.
*/
export function CliSection() {
const { t } = useLocale();
const d = t.download.cli;
return (
<section id="cli" className="bg-[#f7f7f5] py-20 text-[#0a0d12] sm:py-24">
<div className="mx-auto max-w-[820px] px-4 sm:px-6 lg:px-8">
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
{d.title}
</h2>
<p className="mt-4 max-w-[620px] text-[15px] leading-7 text-[#0a0d12]/72">
{d.sub}
</p>
<div className="mt-10 flex flex-col gap-5">
<CommandBlock
label={d.installLabel}
cmd={INSTALL_CMD}
copyLabel={d.copyLabel}
copiedLabel={d.copiedLabel}
/>
<CommandBlock
label={d.startLabel}
cmd={SETUP_CMD}
copyLabel={d.copyLabel}
copiedLabel={d.copiedLabel}
/>
</div>
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.sshNote}</p>
</div>
</section>
);
}
function CommandBlock({
label,
cmd,
copyLabel,
copiedLabel,
}: {
label: string;
cmd: string;
copyLabel: string;
copiedLabel: string;
}) {
const [copied, setCopied] = useState(false);
const onCopy = async () => {
try {
await navigator.clipboard.writeText(cmd);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
// clipboard may be unavailable (insecure context) — silent no-op
}
};
return (
<div>
<p className="mb-2 text-[12px] font-medium uppercase tracking-[0.08em] text-[#0a0d12]/55">
{label}
</p>
<div className="flex items-start gap-3 rounded-xl border border-[#0a0d12]/10 bg-white px-4 py-3 font-mono text-[13.5px]">
<Terminal
className="mt-0.5 size-4 shrink-0 text-[#0a0d12]/55"
aria-hidden
/>
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
{cmd}
</code>
<button
type="button"
onClick={onCopy}
aria-label={copied ? copiedLabel : copyLabel}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium text-[#0a0d12]/70 transition-colors hover:bg-[#0a0d12]/5 hover:text-[#0a0d12]"
>
{copied ? (
<>
<Check className="size-3.5" />
{copiedLabel}
</>
) : (
<>
<Copy className="size-3.5" />
{copyLabel}
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import { CloudWaitlistExpand } from "@multica/views/onboarding";
import { useLocale } from "../../i18n";
/**
* Cloud runtime waitlist — thin wrapper around the shared
* CloudWaitlistExpand form with a download-page-appropriate title
* and subtitle. Submission persists via `joinCloudWaitlist` inside
* the child; the submitted flag here only prevents double-submits
* for the lifetime of the page.
*/
export function CloudSection() {
const { t } = useLocale();
const d = t.download.cloud;
const [submitted, setSubmitted] = useState(false);
return (
<section className="bg-white py-20 text-[#0a0d12] sm:py-24">
<div className="mx-auto max-w-[720px] px-4 sm:px-6 lg:px-8">
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
{d.title}
</h2>
<p className="mt-4 max-w-[560px] text-[15px] leading-7 text-[#0a0d12]/72">
{d.sub}
</p>
<div className="mt-10">
<CloudWaitlistExpand
submitted={submitted}
onSubmitted={() => setSubmitted(true)}
/>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,285 @@
import Link from "next/link";
import { ArrowRight, Download } from "lucide-react";
import {
captureDownloadInitiated,
type DownloadInitiatedPayload,
} from "@multica/core/analytics";
import { useLocale } from "../../i18n";
import type { DetectResult } from "../../utils/os-detect";
import type { DownloadAssets } from "../../utils/parse-release-assets";
import { heroButtonClassName } from "../shared";
interface Props {
detected: DetectResult | null;
assets: DownloadAssets;
/** True when the GitHub API fetch failed; disables all CTAs and
* surfaces a "version unavailable" line. */
versionUnavailable: boolean;
/** Release tag (e.g. "v0.2.13"). Null when version lookup failed —
* in that case CTAs are already disabled, no tracking fires. */
version: string | null;
}
/**
* Top CTA section. Server-renders a generic "Choose your platform"
* placeholder (SEO + flash-before-hydration), then swaps to a
* platform-specific CTA once the client detection resolves.
*/
export function DownloadHero({
detected,
assets,
versionUnavailable,
version,
}: Props) {
const { t } = useLocale();
const d = t.download.hero;
const content = resolveContent(detected, assets, versionUnavailable, d);
// Fires download_initiated on primary CTA click. `primary_cta: true`
// identifies the hero-recommended path; `matched_detect: true` is
// always true here by construction (the primary is computed from
// the detect result). All Platforms rows below emit with
// matched_detect=false when the user overrides.
const onPrimaryClick = (tracking: HeroTracking | undefined) => {
if (!tracking || !version) return;
captureDownloadInitiated({
...tracking,
version,
primary_cta: true,
matched_detect: true,
});
};
return (
<section className="relative overflow-hidden bg-[#05070b] text-white">
<BackdropGradient />
<div className="relative z-10 mx-auto max-w-[1120px] px-4 pb-24 pt-32 text-center sm:px-6 sm:pt-40 lg:px-8 lg:pb-28">
<h1 className="mx-auto max-w-[880px] font-[family-name:var(--font-serif)] text-[3rem] leading-[1.02] tracking-[-0.035em] drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4rem] lg:text-[5rem]">
{content.title}
</h1>
<p className="mx-auto mt-6 max-w-[620px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
{content.sub}
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
{content.primary ? (
<PrimaryCta
href={content.primary.href}
disabled={content.primary.disabled}
onClick={() => onPrimaryClick(content.primary?.tracking)}
>
<Download className="size-4" aria-hidden />
{content.primary.label}
{!content.primary.disabled && (
<ArrowRight className="size-4" aria-hidden />
)}
</PrimaryCta>
) : null}
{content.alt ? (
<Link
href={content.alt.href}
className={heroButtonClassName("ghost")}
onClick={() => onPrimaryClick(content.alt?.tracking)}
>
{content.alt.label}
</Link>
) : null}
</div>
{content.hint ? (
<p className="mx-auto mt-5 max-w-[520px] text-[13px] text-white/64">
{content.hint}
</p>
) : null}
{versionUnavailable ? (
<p className="mx-auto mt-6 max-w-[520px] text-[12px] uppercase tracking-[0.14em] text-white/50">
{t.download.footer.versionUnavailable}
</p>
) : null}
</div>
</section>
);
}
// ------------------------------------------------------------
// Content resolver — maps (detect, assets) → CTA props
// ------------------------------------------------------------
type HeroTracking = Pick<
DownloadInitiatedPayload,
"platform" | "arch" | "format"
>;
interface HeroContent {
title: string;
sub: string;
primary?: {
href: string;
label: string;
disabled: boolean;
tracking?: HeroTracking;
};
alt?: { href: string; label: string; tracking?: HeroTracking };
hint?: string;
}
type HeroDict = ReturnType<typeof useLocale>["t"]["download"]["hero"];
function resolveContent(
detected: DetectResult | null,
assets: DownloadAssets,
versionUnavailable: boolean,
d: HeroDict,
): HeroContent {
// Before hydration resolves, render a neutral prompt. Same copy
// also catches `os === "unknown"`.
if (!detected || detected.os === "unknown") {
return { title: d.unknown.title, sub: d.unknown.sub };
}
if (detected.os === "mac") {
// Only Chromium high-entropy returns arch confidently. Safari
// always reports Intel even on Apple Silicon, so we treat
// "non-confident" as arm64 + add a small Intel disclaimer.
if (detected.arch === "x64" && detected.archConfident) {
return {
title: d.macIntel.title,
sub: d.macIntel.sub,
primary: {
href: "#cli",
label: d.macIntel.disabledCta,
disabled: true,
},
hint: d.macIntel.intelHint,
};
}
const dmg = assets.macArm64Dmg;
const zip = assets.macArm64Zip;
return {
title: d.macArm64.title,
sub: d.macArm64.sub,
primary: dmg
? {
href: dmg,
label: d.macArm64.primary,
disabled: false,
tracking: { platform: "mac", arch: "arm64", format: "dmg" },
}
: versionUnavailable
? { href: "#", label: d.macArm64.primary, disabled: true }
: undefined,
alt: zip
? {
href: zip,
label: d.macArm64.altZip,
tracking: { platform: "mac", arch: "arm64", format: "zip" },
}
: undefined,
hint: detected.archConfident ? undefined : d.safariMacHint,
};
}
if (detected.os === "windows") {
// Trust arch whenever the UA hints at it (even non-confident);
// Windows-on-ARM can still run x64 via emulation so this is low
// risk either way. Surface the arch-fallback hint when we're
// guessing so users on uncommon setups know to scroll down.
const isArm = detected.arch === "arm64";
const copy = isArm ? d.winArm64 : d.winX64;
const url = isArm ? assets.winArm64Exe : assets.winX64Exe;
return {
title: copy.title,
sub: copy.sub,
primary: url
? {
href: url,
label: copy.primary,
disabled: false,
tracking: {
platform: "windows",
arch: isArm ? "arm64" : "x64",
format: "exe",
},
}
: versionUnavailable
? { href: "#", label: copy.primary, disabled: true }
: undefined,
hint: detected.archConfident ? undefined : d.archFallbackHint,
};
}
// Linux — same principle: trust the arm64 signal, surface a hint
// when we're not confident. Linux ARM has no binary emulation so
// the hint matters more here than on Windows.
const isArmLinux = detected.arch === "arm64";
const primaryUrl = isArmLinux
? assets.linuxArm64AppImage
: assets.linuxAmd64AppImage;
return {
title: d.linux.title,
sub: d.linux.sub,
primary: primaryUrl
? {
href: primaryUrl,
label: d.linux.primary,
disabled: false,
tracking: {
platform: "linux",
arch: isArmLinux ? "arm64" : "x64",
format: "appimage",
},
}
: versionUnavailable
? { href: "#", label: d.linux.primary, disabled: true }
: undefined,
alt: { href: "#all-platforms", label: d.linux.altFormats },
hint: detected.archConfident ? undefined : d.archFallbackHint,
};
}
// ------------------------------------------------------------
// Pieces
// ------------------------------------------------------------
function PrimaryCta({
href,
disabled,
onClick,
children,
}: {
href: string;
disabled: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
if (disabled) {
return (
<span
aria-disabled="true"
className="inline-flex cursor-not-allowed items-center justify-center gap-2 rounded-[12px] border border-white/15 bg-white/8 px-5 py-3 text-[14px] font-semibold text-white/60"
>
{children}
</span>
);
}
return (
<a href={href} onClick={onClick} className={heroButtonClassName("solid")}>
{children}
</a>
);
}
function BackdropGradient() {
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 70% 50% at 50% 0%, rgba(80,120,255,0.18), transparent 60%), radial-gradient(ellipse 50% 40% at 50% 80%, rgba(255,90,90,0.08), transparent 60%)",
}}
/>
);
}

View File

@@ -0,0 +1,54 @@
/**
* Inline SVG marks for macOS / Windows / Linux.
* Lucide lacks real Apple / Tux marks, and the download page needs
* the recognizable brand glyphs next to platform rows. Kept as
* minimal monochrome outlines so they inherit currentColor.
*/
type IconProps = React.SVGProps<SVGSVGElement> & { size?: number };
export function AppleIcon({ size = 18, ...props }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
aria-hidden
{...props}
>
<path d="M16.37 12.8c.02-1.9 1.56-2.83 1.63-2.87-.89-1.3-2.28-1.48-2.77-1.5-1.18-.12-2.3.69-2.9.69-.6 0-1.52-.68-2.5-.66-1.28.02-2.47.74-3.13 1.88-1.33 2.3-.34 5.7.96 7.57.63.92 1.38 1.94 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.14 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.1 1.04-2.15-.02-.01-2-.77-2.02-3.05-.02-1.9 1.55-2.81 1.63-2.87zm-2.05-5.24c.52-.63.88-1.52.78-2.4-.75.03-1.66.5-2.2 1.12-.48.55-.9 1.44-.79 2.32.84.06 1.69-.42 2.21-1.04z" />
</svg>
);
}
export function WindowsIcon({ size = 18, ...props }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
aria-hidden
{...props}
>
<path d="M3 5.5 10.5 4.5v6.75H3V5.5Zm0 7.25h7.5v6.75L3 18.5v-5.75Zm8.75-8.4L21 3v9H11.75V4.35ZM11.75 12h9.25v9L11.75 19.65V12Z" />
</svg>
);
}
export function LinuxIcon({ size = 18, ...props }: IconProps) {
// Simplified Tux silhouette — round head + body.
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
aria-hidden
{...props}
>
<path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.3 2.3.8 3.2-.7.7-1.3 1.8-1.6 3-.4 1.4-.7 3.3-1.8 4.4-.6.6-1 .9-1 1.6 0 .9.8 1.3 2 1.6 1.5.3 2.6.1 3.6-.3.6-.2 1.3-.4 2-.4s1.4.2 2 .4c1 .4 2.1.6 3.6.3 1.2-.3 2-.7 2-1.6 0-.7-.4-1-1-1.6-1.1-1.1-1.4-3-1.8-4.4-.3-1.2-.9-2.3-1.6-3 .5-.9.8-2 .8-3.2 0-2.7-1.6-4.6-4-4.6Zm-1.5 5.2c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm3 0c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm-3 2.6c.7.5 1.5.8 1.5.8s.8-.3 1.5-.8c0 .6-.7 1-1.5 1s-1.5-.4-1.5-1Z" />
</svg>
);
}

View File

@@ -701,14 +701,9 @@ const mockUsageData = USAGE_SEEDS.map((s, i) => ({
/* Heatmap color helper — same as real ActivityHeatmap */
function getHeatmapColor(level: number): string {
const colors = [
"var(--color-muted, hsl(var(--muted)))",
"hsl(var(--chart-3) / 0.3)",
"hsl(var(--chart-3) / 0.5)",
"hsl(var(--chart-3) / 0.75)",
"hsl(var(--chart-3) / 1)",
];
return colors[level] ?? colors[0]!;
if (level === 0) return "var(--color-muted)";
const opacities = ["25%", "45%", "68%", "90%"];
return `color-mix(in oklch, var(--color-foreground) ${opacities[level - 1]}, transparent)`;
}
/* Generate heatmap cells — simplified version of real ActivityHeatmap */
@@ -766,7 +761,7 @@ function DailyCostBars({ data }: { data: typeof mockUsageData }) {
width={8}
height={Math.max(h, 2)}
rx={1}
fill="hsl(var(--chart-1))"
fill="var(--color-chart-1)"
/>
);
})}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
import { useLocale, locales, localeLabels } from "../i18n";
@@ -71,6 +72,11 @@ export function LandingFooter() {
{...(link.href.startsWith("http")
? { target: "_blank", rel: "noreferrer" }
: {})}
onClick={
link.href === "/download"
? () => captureDownloadIntent("landing_footer")
: undefined
}
className="text-[14px] text-white/50 transition-colors hover:text-white"
>
{link.label}

View File

@@ -2,7 +2,9 @@
import Image from "next/image";
import Link from "next/link";
import { Download } from "lucide-react";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
@@ -42,25 +44,11 @@ export function LandingHero() {
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href="https://github.com/multica-ai/multica/releases/latest"
target="_blank"
rel="noreferrer"
href="/download"
className={heroButtonClassName("ghost")}
onClick={() => captureDownloadIntent("landing_hero")}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<Download className="size-4" aria-hidden />
{t.hero.downloadDesktop}
</Link>
</div>

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { workspaceListOptions } from "@multica/core/workspace";
import { paths } from "@multica/core/paths";
import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths";
/**
* Client-side fallback redirect for authenticated visitors on the landing page.
@@ -16,7 +16,7 @@ import { paths } from "@multica/core/paths";
* login* — before the user has ever visited a workspace — the cookie is
* absent, so the proxy falls through to the landing page. This component
* covers that gap: once auth is resolved and the workspace list has loaded,
* push the user into their workspace (or /workspaces/new if they have none).
* push the user into their workspace (or /onboarding if they have none).
*
* Renders nothing. Uses `router.replace` so the landing page never enters
* browser history for authenticated users.
@@ -25,21 +25,17 @@ export function RedirectIfAuthenticated() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hasOnboarded = useHasOnboarded();
const { data: list } = useQuery({
const { data: list = [], isFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (isLoading || !user || !list) return;
const [first] = list;
if (!first) {
router.replace(paths.newWorkspace());
return;
}
router.replace(paths.workspace(first.slug).issues());
}, [isLoading, user, list, router]);
if (isLoading || !user || !isFetched) return;
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, isFetched, list, hasOnboarded, router]);
return null;
}

View File

@@ -1,11 +1,15 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import { en } from "./en";
import { zh } from "./zh";
import { createContext, useContext, useState, useCallback, useMemo } from "react";
import { useConfigStore } from "@multica/core/config";
import { createEnDict } from "./en";
import { createZhDict } from "./zh";
import type { LandingDict, Locale } from "./types";
const dictionaries: Record<Locale, LandingDict> = { en, zh };
const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict> = {
en: createEnDict,
zh: createZhDict,
};
const COOKIE_NAME = "multica-locale";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
@@ -26,6 +30,11 @@ export function LocaleProvider({
initialLocale?: Locale;
}) {
const [locale, setLocaleState] = useState<Locale>(initialLocale);
const allowSignup = useConfigStore((state) => state.allowSignup);
const t = useMemo(
() => dictionaryFactories[locale](allowSignup),
[allowSignup, locale],
);
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
@@ -34,7 +43,7 @@ export function LocaleProvider({
return (
<LocaleContext.Provider
value={{ locale, t: dictionaries[locale], setLocale }}
value={{ locale, t, setLocale }}
>
{children}
</LocaleContext.Provider>

View File

@@ -1,7 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const en: LandingDict = {
export function createEnDict(allowSignup: boolean): LandingDict {
return {
header: {
github: "GitHub",
login: "Log in",
@@ -120,9 +121,10 @@ export const en: LandingDict = {
headlineFaded: "in the next hour.",
steps: [
{
title: "Sign up & create your workspace",
description:
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
title: allowSignup ? "Sign up & create your workspace" : "Login to your workspace",
description: allowSignup
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
},
{
title: "Install the CLI & connect your machine",
@@ -224,7 +226,7 @@ export const en: LandingDict = {
{ label: "Features", href: "#features" },
{ label: "How it Works", href: "#how-it-works" },
{ label: "Changelog", href: "/changelog" },
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
{ label: "Download", href: "/download" },
],
},
resources: {
@@ -273,12 +275,114 @@ export const en: LandingDict = {
changelog: {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
toc: "All releases",
categories: {
features: "New Features",
improvements: "Improvements",
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.15",
date: "2026-04-22",
title: "Local Skills, LaTeX, Focus Mode & Orphan-Task Recovery",
changes: [],
features: [
"Import runtime local Skills into the workspace as first-class artifacts",
"Orphan-task recovery — abandoned agent runs auto-retry, with manual rerun as fallback",
"LaTeX rendering in issues, comments and chat",
"Chat Focus mode — share the page you're on as conversation context",
],
improvements: [
"Sub-issue `status_changed` events no longer spam parent-issue subscribers",
"Multi-arch Docker release images built natively per-arch (no QEMU)",
"Pin sidebar derives fields client-side for snappier reorders",
"Expanded reserved-slug list so new slugs can't collide with product routes",
],
fixes: [
"Gemini runtime model list now includes Gemini 3 and CLI aliases",
"Chat focus button disabled on pages without an anchor",
"Onboarding pin sync, welcome layout and runtime bootstrap state",
"`install.ps1` OS architecture detection hardened for more Windows setups",
"`/download` falls back to the previous release within a 1h freshness window",
],
},
{
version: "0.2.11",
date: "2026-04-21",
title: "Desktop Cross-Platform Packaging, CLI Self-Update & Board Pagination",
changes: [],
features: [
"Desktop app cross-platform packaging — macOS, Windows, and Linux artifacts from a single release pipeline",
"`multica update` self-update command — upgrade the CLI and local daemon without reinstalling",
"Issue board paginates every status column, not only Done — large backlogs stay responsive",
],
fixes: [
"Workspace isolation enforced end-to-end for agent execution on the local daemon (security)",
"Windows daemon stays alive after the terminal closes, so background agents keep running",
"Board cards render their description preview again — list queries no longer strip the description field",
"OpenClaw agent runtime now reads the real model from agent metadata instead of falling back to a default",
"Comment Markdown preserved end-to-end — the HTML sanitizer that was stripping formatting has been removed",
],
},
{
version: "0.2.8",
date: "2026-04-20",
title: "Per-Agent Models, Kimi Runtime & Self-Host Auth",
changes: [],
features: [
"Per-agent `model` field with a provider-aware dropdown — pick the LLM model for each agent from the UI or via `multica agent create/update --model`, with live discovery from each runtime's CLI",
"Kimi CLI as a new agent runtime (Moonshot AI's `kimi-cli` over ACP), with model selection, auto-approved tool permissions, and streaming tool-call rendering",
"Expand toggle on inline comment and reply editors for composing long text",
],
fixes: [
"Posting the result comment is now an explicit, numbered step in agent workflows so final replies reach the issue instead of terminal output",
"Agent live status card no longer leaks across issues when switching via Cmd+K",
"Self-hosted session cookies honor the `FRONTEND_ORIGIN` scheme — plain-HTTP deployments stop silently dropping cookies, and `COOKIE_DOMAIN=<ip>` now falls back to host-only with a warning instead of breaking login",
],
},
{
version: "0.2.7",
date: "2026-04-18",
title: "Sub-Issues from Editor, Self-Host Gating & MCP",
changes: [],
features: [
"Create sub-issue directly from selected text in the editor bubble menu",
"Self-hosted instance gating — `ALLOW_SIGNUP` and `ALLOWED_EMAIL_*` env vars to restrict account creation",
"Per-agent `mcp_config` field to restore MCP access",
"Desktop app hourly update poll with manual check button in settings",
],
fixes: [
"Session hand-off to desktop when already logged in on web",
"Open redirect vulnerability on `?next=` validated",
"OpenClaw stops passing unsupported flags and properly delivers AgentInstructions",
],
},
{
version: "0.2.5",
date: "2026-04-17",
title: "CLI Autopilot, Cmd+K & Daemon Identity",
changes: [],
features: [
"CLI `autopilot` commands for managing scheduled and triggered automations",
"CLI `issue subscriber` commands for subscription management",
"Cmd+K palette extended — theme toggle, quick new issue/project, copy link, switch workspace",
"Project and sub-issue progress as optional card properties on the issue list",
"Persistent daemon UUID identity — CLI and desktop share one daemon across restarts and machine moves",
"Sole-owner workspace leave preflight check",
"Persist comment collapse state across sessions",
],
fixes: [
"Agents now triggered on comments regardless of issue status",
"Codex sandbox config fixed for macOS network access",
"Editor bubble menu rewritten with @floating-ui/dom for reliable scroll hiding",
"Autopilot creator automatically subscribed to autopilot-created issues",
"Autopilot workspace ID correctly resolved for run-only tasks",
"Desktop restricts `shell.openExternal` to http/https schemes (security)",
"Duplicate agent names return 409 instead of silently failing",
"New tabs in desktop inherit current workspace",
],
},
{
version: "0.2.1",
date: "2026-04-16",
@@ -647,4 +751,80 @@ export const en: LandingDict = {
},
],
},
};
download: {
hero: {
macArm64: {
title: "Multica for macOS",
sub: "Apple Silicon · bundled daemon, zero setup",
primary: "Download (.dmg)",
altZip: "or download .zip",
},
macIntel: {
title: "Multica for macOS",
sub: "Apple Silicon required — Intel Macs not yet supported.",
disabledCta: "Apple Silicon required",
intelHint:
"On an Intel Mac? Use the CLI below — it runs the same daemon.",
},
winX64: {
title: "Multica for Windows",
sub: "Bundled daemon, zero setup",
primary: "Download (.exe)",
},
winArm64: {
title: "Multica for Windows",
sub: "ARM · bundled daemon, zero setup",
primary: "Download (.exe)",
},
linux: {
title: "Multica for Linux",
sub: "Bundled daemon, zero setup",
primary: "Download AppImage",
altFormats: "or .deb / .rpm",
},
unknown: {
title: "Choose your platform",
sub: "All installers are listed below.",
},
safariMacHint: "On an Intel Mac? Use the CLI below.",
archFallbackHint: "Wrong architecture? See all formats below.",
},
allPlatforms: {
title: "All platforms",
macLabel: "macOS · Apple Silicon",
winX64Label: "Windows · x64",
winArm64Label: "Windows · ARM64",
linuxX64Label: "Linux · x64",
linuxArm64Label: "Linux · ARM64",
formatDmg: ".dmg",
formatZip: ".zip",
formatExe: ".exe",
formatAppImage: ".AppImage",
formatDeb: ".deb",
formatRpm: ".rpm",
intelNote:
"Apple Silicon only — Intel Macs not supported in this release.",
unavailable: "Not available",
},
cli: {
title: "Prefer the CLI?",
sub: "For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal.",
installLabel: "Install",
startLabel: "Start daemon",
sshNote: "Already on a server? Same commands work over SSH.",
copyLabel: "Copy",
copiedLabel: "Copied",
},
cloud: {
title: "Cloud runtime (waitlist)",
sub: "Well host the runtime for you. Not live yet — leave your email to be notified.",
},
footer: {
releaseNotes: "Whats new in {version}",
allReleases: "View all releases",
currentVersion: "Current version: {version}",
versionUnavailable: "Version unavailable — check GitHub",
},
},
};
}

View File

@@ -86,6 +86,7 @@ export type LandingDict = {
changelog: {
title: string;
subtitle: string;
toc: string;
categories: {
features: string;
improvements: string;
@@ -101,4 +102,63 @@ export type LandingDict = {
fixes?: string[];
}[];
};
download: {
hero: {
macArm64: {
title: string;
sub: string;
primary: string;
altZip: string;
};
macIntel: {
title: string;
sub: string;
disabledCta: string;
intelHint: string;
};
winX64: { title: string; sub: string; primary: string };
winArm64: { title: string; sub: string; primary: string };
linux: {
title: string;
sub: string;
primary: string;
altFormats: string;
};
unknown: { title: string; sub: string };
safariMacHint: string;
archFallbackHint: string;
};
allPlatforms: {
title: string;
macLabel: string;
winX64Label: string;
winArm64Label: string;
linuxX64Label: string;
linuxArm64Label: string;
formatDmg: string;
formatZip: string;
formatExe: string;
formatAppImage: string;
formatDeb: string;
formatRpm: string;
intelNote: string;
unavailable: string;
};
cli: {
title: string;
sub: string;
installLabel: string;
startLabel: string;
sshNote: string;
copyLabel: string;
copiedLabel: string;
};
cloud: { title: string; sub: string };
footer: {
releaseNotes: string;
allReleases: string;
currentVersion: string;
versionUnavailable: string;
};
};
};

View File

@@ -1,7 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const zh: LandingDict = {
export function createZhDict(allowSignup: boolean): LandingDict {
return {
header: {
github: "GitHub",
login: "\u767b\u5f55",
@@ -120,9 +121,10 @@ export const zh: LandingDict = {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
description:
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: allowSignup
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
},
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
@@ -224,7 +226,7 @@ export const zh: LandingDict = {
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
{ label: "更新日志", href: "/changelog" },
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
{ label: "下载", href: "/download" },
],
},
resources: {
@@ -273,12 +275,114 @@ export const zh: LandingDict = {
changelog: {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
toc: "\u5386\u53f2\u7248\u672c",
categories: {
features: "新功能",
improvements: "改进",
fixes: "问题修复",
},
entries: [
{
version: "0.2.15",
date: "2026-04-22",
title: "本地 Skills、LaTeX、Focus 模式与孤儿任务自恢复",
changes: [],
features: [
"支持将 Runtime 本地 Skills 导入工作区,成为一等工作区资产",
"孤儿任务自动恢复——意外中断的 Agent 执行会自动重试,必要时可手动重跑",
"Issue、评论与 Chat 支持 LaTeX 渲染",
"Chat Focus 模式——将当前页面作为上下文分享给对话",
],
improvements: [
"子 Issue 的 `status_changed` 事件不再向父 Issue 订阅者刷屏",
"Docker 发布镜像改为按架构原生构建,免 QEMU",
"侧边栏 Pin 字段在客户端派生,排序更跟手",
"扩充保留 slug 列表,新工作区 slug 不会再和产品路由冲突",
],
fixes: [
"Gemini Runtime 模型列表补上 Gemini 3 及若干 CLI 别名",
"没有锚点的页面上 Chat focus 按钮改为禁用",
"修复 Onboarding 中 Pin 同步、欢迎页布局与 Runtime bootstrap 状态",
"`install.ps1` 的系统架构探测更稳健,覆盖更多 Windows 环境",
"`/download` 在 1 小时新鲜度窗口内可回退到上一版本,避免撞上半发布状态",
],
},
{
version: "0.2.11",
date: "2026-04-21",
title: "桌面应用跨平台打包、CLI 自更新与看板分页",
changes: [],
features: [
"桌面应用跨平台打包——同一条发布流水线产出 macOS、Windows 和 Linux 安装包",
"新增 `multica update` 自更新命令——无需重装即可升级 CLI 和本地 Daemon",
"Issue 看板所有状态列都支持分页(不再只是 Done 列),大积压下依然流畅",
],
fixes: [
"本地 Daemon 对 Agent 执行强制端到端工作区隔离(安全)",
"Windows 下 Daemon 终端关闭后继续常驻,后台 Agent 不再被意外终止",
"看板卡片重新显示描述预览——列表查询不再丢掉 description 字段",
"OpenClaw Agent 改为从 Agent 元数据读取真实模型,不再回退到默认值",
"评论 Markdown 全链路保留——移除会误伤格式的 HTML sanitizer",
],
},
{
version: "0.2.8",
date: "2026-04-20",
title: "Agent 模型选择、Kimi Runtime 与自部署登录",
changes: [],
features: [
"Agent 新增 `model` 字段及按 Provider 聚合的模型下拉框——可在界面或通过 `multica agent create/update --model` 为每个 Agent 选择 LLM 模型,并从各 Runtime CLI 实时发现可用模型",
"新增 Kimi CLI Agent RuntimeMoonshot AI 的 `kimi-cli`,基于 ACP支持模型选择、自动授权工具权限以及流式工具调用渲染",
"评论和回复编辑器新增放大按钮,便于撰写长文本",
],
fixes: [
"Agent 工作流将“发布结果评论”提升为独立的显式步骤,确保最终回复送达 Issue 而不是只留在终端输出",
"通过 Cmd+K 切换 Issue 时不再出现其他 Issue 的 Agent 实时状态残留",
"自部署会话 Cookie 的 Secure 标志改由 `FRONTEND_ORIGIN` 协议决定——HTTP 部署不再因浏览器丢弃 Cookie 导致登录失败;`COOKIE_DOMAIN=<ip>` 会自动回退到 host-only 并输出警告",
],
},
{
version: "0.2.7",
date: "2026-04-18",
title: "编辑器创建子 Issue、自部署门禁与 MCP",
changes: [],
features: [
"直接从编辑器气泡菜单将选中文本创建为子 Issue",
"自部署实例账户门禁——`ALLOW_SIGNUP` 和 `ALLOWED_EMAIL_*` 环境变量限制注册",
"Agent 新增 `mcp_config` 字段恢复 MCP 支持",
"桌面应用每小时检查更新,设置中新增手动检查按钮",
],
fixes: [
"网页已登录时将会话交接给桌面应用",
"修复 `?next=` 开放重定向漏洞",
"OpenClaw 停止传递不支持的参数,正确传递 AgentInstructions",
],
},
{
version: "0.2.5",
date: "2026-04-17",
title: "CLI Autopilot、Cmd+K 与 Daemon 身份",
changes: [],
features: [
"CLI `autopilot` 命令,管理定时和触发式自动化",
"CLI `issue subscriber` 订阅管理命令",
"Cmd+K 命令面板扩展——主题切换、快速创建 Issue/项目、复制链接、切换工作区",
"Issue 列表卡片可选显示项目和子 Issue 进度",
"Daemon 持久化 UUID 身份——CLI 和桌面应用共用同一个 daemon跨重启和机器迁移保持一致",
"唯一所有者退出工作区的前置检查",
"评论折叠状态跨会话持久化",
],
fixes: [
"Agent 现在在任意 Issue 状态下都会响应评论触发",
"修复 Codex 沙箱在 macOS 上的网络访问配置",
"编辑器气泡菜单改用 @floating-ui/dom 重写,滚动时正确隐藏",
"Autopilot 创建者自动订阅其生成的 Issue",
"Autopilot run-only 任务正确解析工作区 ID",
"桌面应用 `shell.openExternal` 限制仅允许 http/https 协议(安全)",
"重名 Agent 创建返回 409 而非静默失败",
"桌面应用新建标签页继承当前工作区",
],
},
{
version: "0.2.1",
date: "2026-04-16",
@@ -647,4 +751,78 @@ export const zh: LandingDict = {
},
],
},
};
download: {
hero: {
macArm64: {
title: "Multica for macOS",
sub: "Apple Silicon · 内置 daemon无需配置",
primary: "下载 (.dmg)",
altZip: "或下载 .zip",
},
macIntel: {
title: "Multica for macOS",
sub: "需要 Apple Silicon——暂不支持 Intel Mac。",
disabledCta: "需要 Apple Silicon",
intelHint: "在 Intel Mac 上?请使用下方 CLI——底层跑的是同一个 daemon。",
},
winX64: {
title: "Multica for Windows",
sub: "内置 daemon无需配置",
primary: "下载 (.exe)",
},
winArm64: {
title: "Multica for Windows",
sub: "ARM · 内置 daemon无需配置",
primary: "下载 (.exe)",
},
linux: {
title: "Multica for Linux",
sub: "内置 daemon无需配置",
primary: "下载 AppImage",
altFormats: "或 .deb / .rpm",
},
unknown: {
title: "选择你的平台",
sub: "下方是所有支持的安装包。",
},
safariMacHint: "在 Intel Mac 上?请使用下方 CLI。",
archFallbackHint: "架构不对?下方是所有可选格式。",
},
allPlatforms: {
title: "所有平台",
macLabel: "macOS · Apple Silicon",
winX64Label: "Windows · x64",
winArm64Label: "Windows · ARM64",
linuxX64Label: "Linux · x64",
linuxArm64Label: "Linux · ARM64",
formatDmg: ".dmg",
formatZip: ".zip",
formatExe: ".exe",
formatAppImage: ".AppImage",
formatDeb: ".deb",
formatRpm: ".rpm",
intelNote: "仅支持 Apple Silicon——Intel Mac 目前暂不支持。",
unavailable: "暂不可用",
},
cli: {
title: "想用 CLI",
sub: "适合服务器、远程开发机、无图形界面环境。底层 daemon 与 Desktop 相同,通过终端安装。",
installLabel: "安装",
startLabel: "启动 daemon",
sshNote: "已经在服务器上?通过 SSH 执行同样的命令即可。",
copyLabel: "复制",
copiedLabel: "已复制",
},
cloud: {
title: "Cloud runtime等待名单",
sub: "我们将为你托管 runtime目前尚未上线——留下邮箱上线后通知你。",
},
footer: {
releaseNotes: "v{version} 更新内容",
allReleases: "查看所有版本",
currentVersion: "当前版本:{version}",
versionUnavailable: "版本获取失败——请前往 GitHub 查看",
},
},
};
}

View File

@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchLatestRelease } from "./github-release";
const SAMPLE_LATEST_ASSET = {
name: "multica-desktop-0.2.14-mac-arm64.dmg",
browser_download_url:
"https://github.com/multica-ai/multica/releases/download/v0.2.14/multica-desktop-0.2.14-mac-arm64.dmg",
};
const SAMPLE_PREV_ASSET = {
name: "multica-desktop-0.2.13-mac-arm64.dmg",
browser_download_url:
"https://github.com/multica-ai/multica/releases/download/v0.2.13/multica-desktop-0.2.13-mac-arm64.dmg",
};
function releasePayload(overrides: {
tag: string;
publishedMinutesAgo?: number;
asset?: { name: string; browser_download_url: string };
prerelease?: boolean;
draft?: boolean;
}) {
const published = new Date(
Date.now() - (overrides.publishedMinutesAgo ?? 0) * 60_000,
).toISOString();
return {
tag_name: overrides.tag,
published_at: published,
html_url: `https://github.com/multica-ai/multica/releases/tag/${overrides.tag}`,
prerelease: overrides.prerelease ?? false,
draft: overrides.draft ?? false,
assets: overrides.asset ? [overrides.asset] : [],
};
}
function mockFetchWithReleases(releases: unknown[]) {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(releases), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchLatestRelease", () => {
it("uses previous release when latest was published within the fresh window", async () => {
mockFetchWithReleases([
releasePayload({
tag: "v0.2.14",
publishedMinutesAgo: 10,
asset: SAMPLE_LATEST_ASSET,
}),
releasePayload({
tag: "v0.2.13",
publishedMinutesAgo: 60 * 24,
asset: SAMPLE_PREV_ASSET,
}),
]);
const result = await fetchLatestRelease();
expect(result.version).toBe("v0.2.13");
expect(result.assets.macArm64Dmg).toBe(SAMPLE_PREV_ASSET.browser_download_url);
});
it("uses latest release once it is older than the fresh window", async () => {
mockFetchWithReleases([
releasePayload({
tag: "v0.2.14",
publishedMinutesAgo: 120,
asset: SAMPLE_LATEST_ASSET,
}),
releasePayload({
tag: "v0.2.13",
publishedMinutesAgo: 60 * 24,
asset: SAMPLE_PREV_ASSET,
}),
]);
const result = await fetchLatestRelease();
expect(result.version).toBe("v0.2.14");
expect(result.assets.macArm64Dmg).toBe(SAMPLE_LATEST_ASSET.browser_download_url);
});
it("falls back to latest when there is no previous release", async () => {
mockFetchWithReleases([
releasePayload({
tag: "v0.0.1",
publishedMinutesAgo: 5,
asset: SAMPLE_LATEST_ASSET,
}),
]);
const result = await fetchLatestRelease();
expect(result.version).toBe("v0.0.1");
});
it("skips prereleases and drafts in the candidate list", async () => {
mockFetchWithReleases([
releasePayload({
tag: "v0.2.15-rc.1",
publishedMinutesAgo: 30,
prerelease: true,
}),
releasePayload({
tag: "v0.2.14",
publishedMinutesAgo: 120,
asset: SAMPLE_LATEST_ASSET,
}),
]);
const result = await fetchLatestRelease();
expect(result.version).toBe("v0.2.14");
});
it("returns an empty release shape when the API errors", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response("rate limited", { status: 403 }),
);
vi.stubGlobal("fetch", fetchMock);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = await fetchLatestRelease();
expect(result).toEqual({
version: null,
publishedAt: null,
htmlUrl: null,
assets: {},
});
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("returns an empty release shape when all candidates are filtered out", async () => {
mockFetchWithReleases([
releasePayload({ tag: "v0.2.15-rc.1", prerelease: true }),
releasePayload({ tag: "v0.2.14-draft", draft: true }),
]);
const result = await fetchLatestRelease();
expect(result.version).toBeNull();
expect(result.assets).toEqual({});
});
});

View File

@@ -0,0 +1,114 @@
import {
parseReleaseAssets,
type DownloadAssets,
} from "./parse-release-assets";
/**
* Server-side fetcher for the latest Multica release, designed to
* run inside a Next.js server component. Response is cached by the
* Next.js fetch cache for 5 minutes (Vercel ISR) so hitting /download
* costs at most one GitHub API call per region per 5 minutes.
*
* Desktop assets don't all land at the same time: CI uploads Linux
* and Windows within a minute of each other, but macOS is packaged
* manually (notarization credentials aren't wired into CI yet) and
* lands tens of minutes later. To avoid showing the half-filled
* mid-flight state on /download, the fetcher pulls the two most
* recent releases and falls back to the previous one for the first
* hour after publish. Empirically full desktop uploads complete in
* ~20 min; 1 h gives 3x buffer for commonly-variable manual steps.
*
* On any failure (network, rate limit, malformed payload) returns a
* `null`-shaped result and logs — the page degrades to a "version
* unavailable" view rather than 500ing.
*/
export interface LatestRelease {
version: string | null;
publishedAt: string | null;
htmlUrl: string | null;
assets: DownloadAssets;
}
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases?per_page=2";
const REVALIDATE_SECONDS = 300;
const FRESH_RELEASE_WINDOW_MS = 60 * 60 * 1000;
interface GitHubReleasePayload {
tag_name?: string;
published_at?: string;
html_url?: string;
prerelease?: boolean;
draft?: boolean;
assets?: Array<{ name: string; browser_download_url: string }>;
}
export async function fetchLatestRelease(): Promise<LatestRelease> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
// Optional PAT for local development and self-hosted deploys where
// the shared outbound IP keeps hitting the 60-requests/hour
// unauthenticated limit. Vercel's fetch cache is shared across all
// regions so production rarely needs this — but the env var lets
// anyone running the site locally avoid the rate-limit dance. Never
// prefix this with `NEXT_PUBLIC_`; the token must stay server-side.
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
try {
const res = await fetch(GITHUB_RELEASES_URL, {
next: { revalidate: REVALIDATE_SECONDS },
headers,
});
if (!res.ok) {
throw new Error(`GitHub API responded ${res.status}`);
}
const data = (await res.json()) as GitHubReleasePayload[];
// Defensive filter — Multica doesn't publish prereleases or drafts
// today, but the endpoint returns them if that ever changes. A
// prerelease shadowing a stable version on /download would be a
// regression.
const stable = data.filter((r) => !r.prerelease && !r.draft);
const latest = stable[0];
if (!latest) {
return emptyRelease();
}
const previous = stable[1];
const chosen =
previous && isWithinFreshWindow(latest) ? previous : latest;
return {
version: chosen.tag_name ?? null,
publishedAt: chosen.published_at ?? null,
htmlUrl: chosen.html_url ?? null,
assets: parseReleaseAssets(chosen.assets ?? []),
};
} catch (err) {
console.warn("[download] fetchLatestRelease failed:", err);
return emptyRelease();
}
}
function isWithinFreshWindow(release: GitHubReleasePayload): boolean {
if (!release.published_at) return false;
const publishedAt = Date.parse(release.published_at);
if (Number.isNaN(publishedAt)) return false;
return Date.now() - publishedAt < FRESH_RELEASE_WINDOW_MS;
}
function emptyRelease(): LatestRelease {
return {
version: null,
publishedAt: null,
htmlUrl: null,
assets: {},
};
}

View File

@@ -0,0 +1,97 @@
/**
* Client-side OS + architecture detection for the /download page.
*
* Prefers the modern `navigator.userAgentData.getHighEntropyValues`
* API (Chromium), falling back to the UA string.
*
* Known limitation: Safari on macOS always reports `Intel Mac OS X`
* in the UA string even on Apple Silicon, and Safari does not
* implement userAgentData. This function therefore returns `arm64`
* as the best default for any Mac — UI surfaces a small "On Intel
* Mac? Use CLI." hint to cover the Intel minority.
*/
export type OSName = "mac" | "windows" | "linux" | "unknown";
export type Arch = "arm64" | "x64" | "unknown";
export interface DetectResult {
os: OSName;
arch: Arch;
/** True when arch came from userAgentData high-entropy values
* (i.e. we can trust the Intel vs arm distinction). False when
* we defaulted — UI should show the Intel Mac disclaimer. */
archConfident: boolean;
}
interface UADataRecord {
platform: string;
architecture: string;
}
interface UserAgentDataLike {
getHighEntropyValues?: (hints: string[]) => Promise<UADataRecord>;
}
function normalizePlatform(raw: string): OSName {
const p = raw.toLowerCase();
if (p.includes("mac") || p === "darwin") return "mac";
if (p.includes("win")) return "windows";
if (p.includes("linux")) return "linux";
return "unknown";
}
function normalizeArch(raw: string): Arch {
const a = raw.toLowerCase();
if (a === "arm" || a === "arm64" || a === "aarch64") return "arm64";
if (a === "x86" || a === "x86_64" || a === "amd64" || a === "x64") return "x64";
return "unknown";
}
export async function detectOS(): Promise<DetectResult> {
if (typeof navigator === "undefined") {
return { os: "unknown", arch: "unknown", archConfident: false };
}
// Modern Chromium: userAgentData with high-entropy values gives
// both the platform name and CPU architecture unambiguously.
const uaData = (navigator as unknown as { userAgentData?: UserAgentDataLike })
.userAgentData;
if (uaData?.getHighEntropyValues) {
try {
const data = await uaData.getHighEntropyValues([
"platform",
"architecture",
]);
const os = normalizePlatform(data.platform);
const arch = normalizeArch(data.architecture);
return { os, arch, archConfident: arch !== "unknown" };
} catch {
// Some browsers expose the API but reject high-entropy requests.
}
}
// Fallback: UA + navigator.platform. Safari on Mac lands here and
// cannot distinguish Apple Silicon from Intel.
const ua = navigator.userAgent;
const platform = navigator.platform || "";
const os: OSName = /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X/i.test(ua)
? "mac"
: /Win/i.test(platform) || /Windows/i.test(ua)
? "windows"
: /Linux/i.test(platform) || /Linux/i.test(ua)
? "linux"
: "unknown";
let arch: Arch = "unknown";
if (os === "mac") {
// Best default. Real Intel Mac users will see the disclaimer.
arch = "arm64";
} else if (/arm|aarch/i.test(ua)) {
arch = "arm64";
} else if (os !== "unknown") {
arch = "x64";
}
return { os, arch, archConfident: false };
}

View File

@@ -0,0 +1,94 @@
/**
* Parses the GitHub Releases API asset array into a structured
* download asset map. Skips auxiliary files (blockmaps, update
* manifests, checksums) and the CLI tarballs — only desktop
* installer artifacts are relevant on the /download page.
*
* Desktop artifact naming (see apps/desktop/electron-builder.yml):
* multica-desktop-{version}-mac-{arch}.{dmg|zip}
* multica-desktop-{version}-windows-{arch}.exe
* multica-desktop-{version}-linux-{arch}.{AppImage|deb|rpm}
*
* Linux arch appears as amd64 / x86_64 / arm64 / aarch64 depending
* on the format; we normalize to amd64 and arm64.
*/
export interface GitHubAsset {
name: string;
browser_download_url: string;
}
export interface DownloadAssets {
macArm64Dmg?: string;
macArm64Zip?: string;
winX64Exe?: string;
winArm64Exe?: string;
linuxAmd64AppImage?: string;
linuxAmd64Deb?: string;
linuxAmd64Rpm?: string;
linuxArm64AppImage?: string;
linuxArm64Deb?: string;
linuxArm64Rpm?: string;
}
const DESKTOP_ARTIFACT_RE =
/^multica-desktop-[^-]+-(mac|windows|linux)-([a-z0-9_]+)\.(dmg|zip|exe|AppImage|deb|rpm)$/i;
function normalizeLinuxArch(arch: string): "amd64" | "arm64" | null {
const a = arch.toLowerCase();
if (a === "amd64" || a === "x86_64") return "amd64";
if (a === "arm64" || a === "aarch64") return "arm64";
return null;
}
export function parseReleaseAssets(raw: GitHubAsset[]): DownloadAssets {
const out: DownloadAssets = {};
for (const asset of raw) {
const name = asset.name;
// Skip auxiliary files that share the release (update manifests,
// blockmaps, checksums). CLI tarballs and other non-desktop
// artifacts are excluded automatically because they don't match
// DESKTOP_ARTIFACT_RE below.
if (name.endsWith(".blockmap") || name.endsWith(".yml")) continue;
if (name.startsWith("checksums")) continue;
const match = DESKTOP_ARTIFACT_RE.exec(name);
if (!match) continue;
const platform = match[1];
const arch = match[2];
const ext = match[3];
if (!platform || !arch || !ext) continue;
const archLower = arch.toLowerCase();
const extLower = ext.toLowerCase();
const url = asset.browser_download_url;
if (platform === "mac") {
if (archLower !== "arm64") continue; // we only ship arm64 today
if (extLower === "dmg") out.macArm64Dmg = url;
else if (extLower === "zip") out.macArm64Zip = url;
} else if (platform === "windows") {
if (extLower !== "exe") continue;
if (archLower === "x64") out.winX64Exe = url;
else if (archLower === "arm64") out.winArm64Exe = url;
} else if (platform === "linux") {
const normalized = normalizeLinuxArch(arch);
if (!normalized) continue;
const e = extLower;
if (normalized === "amd64") {
if (e === "appimage") out.linuxAmd64AppImage = url;
else if (e === "deb") out.linuxAmd64Deb = url;
else if (e === "rpm") out.linuxAmd64Rpm = url;
} else {
if (e === "appimage") out.linuxArm64AppImage = url;
else if (e === "deb") out.linuxArm64Deb = url;
else if (e === "rpm") out.linuxArm64Rpm = url;
}
}
}
return out;
}
/** Whether any desktop asset was parsed out. Used for UI degradation. */
export function hasAnyAsset(assets: DownloadAssets): boolean {
return Object.values(assets).some((v) => typeof v === "string");
}

View File

@@ -9,6 +9,11 @@ export const mockUser: User = {
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: {},
// Matches real server behavior for anyone who onboarded before this
// field shipped — migration 054 backfills 'skipped_legacy'.
starter_content_state: "skipped_legacy",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -59,6 +64,7 @@ export const mockAgents: Agent[] = [
custom_env_redacted: false,
visibility: "workspace",
max_concurrent_tasks: 3,
model: "",
owner_id: null,
skills: [],
created_at: "2026-01-01T00:00:00Z",

View File

@@ -0,0 +1,19 @@
# Development override: build the backend/web images from the current checkout
# instead of pulling the official GHCR images.
services:
backend:
image: multica-backend:dev
build:
context: .
dockerfile: Dockerfile
frontend:
image: multica-web:dev
build:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
NEXT_PUBLIC_APP_VERSION: dev

View File

@@ -21,6 +21,7 @@ services:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
interval: 5s
@@ -28,9 +29,7 @@ services:
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
depends_on:
postgres:
condition: service_healthy
@@ -55,22 +54,19 @@ services:
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped
volumes:
pgdata:

429
docs/analytics.md Normal file
View File

@@ -0,0 +1,429 @@
# Product Analytics
This document is the source of truth for the analytics events Multica ships
to PostHog. Events feed the acquisition → activation → expansion funnel that
drives our weekly Active Workspaces (WAW) north-star metric.
See [MUL-1122](https://github.com/multica-ai/multica) for the design context.
## Configuration
All analytics shipping is toggled by environment variables (see `.env.example`):
| Variable | Meaning | Default |
|---|---|---|
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
events leave the process unless the operator explicitly opts in**.
### Self-hosted instances
Self-hosters should **never inherit a Multica-issued `POSTHOG_API_KEY`**
that would route their users' behavior to our analytics project. The
defaults guarantee this:
- `.env.example` ships `POSTHOG_API_KEY=` empty. The Docker self-host
compose does not set a default either.
- With the key unset, `NewFromEnv` returns `NoopClient` and logs
`analytics: POSTHOG_API_KEY not set, using noop client` at startup — a
visible confirmation that nothing is shipped.
- Operators who want their own analytics can set `POSTHOG_API_KEY` and
`POSTHOG_HOST` to point at their own PostHog project (Cloud or
self-hosted PostHog).
- The frontend receives the key via `/api/config` (planned for PR 2), so
self-hosts' blank server config also disables frontend event shipping
automatically — no separate frontend opt-out plumbing required.
## Architecture
```
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
bounded queue (1024 events)
background worker: batch + POST /batch/
PostHog
```
- `analytics.Capture` is **never allowed to block a request handler**. A
broken backend must not degrade the product — when the queue is full,
events are dropped and counted (visible via `slog` + the `dropped` counter
on shutdown).
- Batches flush either when `BatchSize` is reached or every `FlushEvery`
(default 10 s), whichever comes first.
- `Close()` drains remaining events during graceful shutdown. Called from
`server/cmd/server/main.go` via `defer`.
## Identity model
- **`distinct_id`** — always the user's UUID for logged-in events. The
frontend's `posthog.identify(user.id)` merges any prior anonymous events
under the same identity, so acquisition attribution (UTM / referrer) stays
intact across signup.
- **`workspace_id`** — added to every event as a property when present. v1
uses event property filtering (free tier) rather than PostHog Groups
Analytics (paid) to compute workspace-level metrics.
- **PII** — events carry `email_domain` (e.g. `gmail.com`), not the full
email. Full email is stored once in person properties via `$set_once` so
it's available for individual debugging but not broadcast with every
event.
- **Person properties (`$set`)** — use for mutable cohort signals
(role, use_case, team_size, platform_preference) that a user can
legitimately change during onboarding. `Event.Set` on the backend
maps to `$set`; the frontend helper is
`setPersonProperties()` in `@multica/core/analytics`. Use
`$set_once` only for values that must never be overwritten (email,
initial attribution, first-completion timestamp).
## Event contract
### `signup`
Fires when a new user is created. Covers both verification-code and Google
OAuth entry points (`findOrCreateUser` is the single emission site).
| Property | Type | Description |
|---|---|---|
| `email_domain` | string | Lower-cased domain portion of the user's email. |
| `signup_source` | string | Opaque attribution bundle from the frontend cookie `multica_signup_source` (UTM + referrer). Empty when the cookie is absent. |
| `auth_method` | string | Optional. `"google"` for Google OAuth signups. Absent for verification-code signups. |
Person properties set with `$set_once`:
| Property | Type | Description |
|---|---|---|
| `email` | string | Full email. Never broadcast per-event. |
| `signup_source` | string | Same as above; kept on the person for later segmentation. |
### `workspace_created`
Fires after a `CreateWorkspace` transaction commits successfully.
| Property | Type | Description |
|---|---|---|
| `workspace_id` | string (UUID) | Added globally; present here for clarity. |
**Note on "first workspace" segmentation** — we deliberately do *not* stamp
an `is_first_workspace` boolean at emit time. Computing it correctly would
require an extra column or transaction-scoped logic that still races under
concurrent creates. Instead, PostHog answers the same question exactly by
looking at whether the user has a prior `workspace_created` event (use a
funnel with "first time user does X" or a cohort on
`person_properties.$initial_event`). No information is lost.
### `runtime_registered`
Fires the first time a `(workspace_id, daemon_id, provider)` tuple is
upserted. Heartbeats and repeat registrations never re-emit. First-time
detection uses Postgres `xmax = 0` on the upsert RETURNING clause — no
extra query, no race.
| Property | Type | Description |
|---|---|---|
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
| `provider` | string | e.g. `"codex"`, `"claude"`. |
| `runtime_version` | string | Version of the agent runtime binary. |
| `cli_version` | string | Version of the `multica` CLI that registered it. |
`distinct_id` is the authenticated owner's user id when the daemon was
registered via a member's JWT/PAT; daemon-token registrations fall back to
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
under a single "anonymous" person.
### `issue_executed`
Fires **at most once per issue** — when the first task on that issue
reaches terminal `done` state. Backed by an atomic
`UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *`;
retries, re-assignments, and comment-triggered follow-up tasks all hit the
WHERE clause and no-op, so the `≥1 / ≥2 / ≥5 / ≥10` funnel buckets count
distinct issues, not tasks.
| Property | Type | Description |
|---|---|---|
| `issue_id` | string (UUID) | |
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
`distinct_id` prefers the issue's human creator so agent-executed events
flow into the issue-author's person profile (same place `signup` and
`workspace_created` land). Agent-created issues prefix with `agent:` to
keep PostHog from merging the agent into a user record.
**Note on workspace-Nth ordinals** — we deliberately do *not* stamp
`nth_issue_for_workspace` at emit time. Computing it correctly would
require either a serialised transaction or an advisory lock per workspace;
two concurrent first-completions could otherwise both read `count=1` and
emit `n=1`. PostHog answers the same question at query time via
`row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp)`,
and funnel steps of the form "workspace has had ≥2 `issue_executed`
events" are expressible without the property. No information is lost.
### `team_invite_sent`
Fires from `CreateInvitation` after the DB row is written.
| Property | Type | Description |
|---|---|---|
| `invited_email_domain` | string | Lower-cased domain; full email lives in the invitation row, not the event. |
| `invite_method` | string | Currently always `"email"`. Future non-email invite flows (share link, SCIM) should pass their own value. |
`distinct_id` is the inviter's user id.
### `team_invite_accepted`
Fires from `AcceptInvitation` after both the invitation row is marked
accepted and the member row is inserted in the same transaction.
| Property | Type | Description |
|---|---|---|
| `days_since_invite` | int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
`distinct_id` is the invitee's user id — this is the event that closes the
expansion funnel.
### `onboarding_questionnaire_submitted`
Fires on the first PatchOnboarding that transitions the user's
questionnaire JSONB from "at least one slot empty" to "all three
filled" (team_size, role, use_case). Revisions past that point don't
re-emit — the funnel counts users, not edits.
| Property | Type | Description |
|---|---|---|
| `team_size` | string | `solo` / `team` / `other`. |
| `role` | string | `developer` / `product_lead` / `writer` / `founder` / `other`. |
| `use_case` | string | `coding` / `planning` / `writing_research` / `explore` / `other`. |
| `team_size_has_other` | bool | `true` when the user filled the Q1 free-text escape. |
| `role_has_other` | bool | Ditto Q2. |
| `use_case_has_other` | bool | Ditto Q3. |
Person properties set with `$set` (not once — users can go back and
change answers before submitting again):
| Property | Type | Description |
|---|---|---|
| `team_size` | string | Mirrors the event property for cohort queries. |
| `role` | string | Same. |
| `use_case` | string | Same. |
`distinct_id` is the user's id. No workspace_id — the questionnaire is
per-user, not per-workspace.
### `agent_created`
Fires on every successful `POST /api/workspaces/:id/agents`. Not
onboarding-specific — the `is_first_agent_in_workspace` property
isolates the Step 4 signal from later agent additions.
| Property | Type | Description |
|---|---|---|
| `agent_id` | string (UUID) | |
| `provider` | string | Runtime provider the agent is bound to (`claude`, `codex`, etc). |
| `template` | string | Template slug used to seed the agent (`coding` / `planning` / `writing` / `assistant`). Empty when the caller didn't come from a template picker. |
| `is_first_agent_in_workspace` | bool | `true` when the workspace had zero agents before this insert. |
`distinct_id` is the authenticated owner's user id.
### `onboarding_completed`
Fires from CompleteOnboarding on the first call that actually flips
`user.onboarded_at` from NULL. Retries are idempotent server-side but
deliberately do NOT re-emit, so the funnel counts first-completions
only. The client sends `completion_path` in the POST body to label
which exit the user took.
| Property | Type | Description |
|---|---|---|
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
Person properties set with `$set_once`:
| Property | Type | Description |
|---|---|---|
| `onboarded_at` | string (RFC3339) | Timestamp the first completion landed. Enables cohort queries like "users onboarded before X" directly from person_properties. |
`completion_path` values:
- `full` — Reached Step 5 (first_issue) with a runtime connected.
- `runtime_skipped` — Completed without connecting a runtime (user hit Skip in Step 3).
- `cloud_waitlist` — Submitted the cloud waitlist form and skipped Step 3.
- `skip_existing` — "I've done this before" from Welcome. The user already had a workspace.
- `unknown` — Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
### `cloud_waitlist_joined`
Fires from JoinCloudWaitlist whenever a user submits the Step 3 cloud
waitlist form. Not a completion signal — it's orthogonal to the main
funnel and used to size hosted-runtime interest.
| Property | Type | Description |
|---|---|---|
| `has_reason` | bool | Presence flag for the free-text reason field. The free text stays in the DB; we don't broadcast it. |
`distinct_id` is the user's id.
### `feedback_submitted`
Fires from `CreateFeedback` after the `feedback` row is inserted and the
hourly per-user rate-limit check has passed. Retries within the same hour
that were rate-limited (429) don't emit. The free-text message is stored
in the DB and never broadcast.
| Property | Type | Description |
|---|---|---|
| `message_length_bucket` | string | `0-100` / `100-500` / `500-2000` / `2000+` — coarse bucket of `len(message)` so we can tell "quick note" from "bug report with repro steps" without leaking content. |
| `has_images` | bool | `true` when the markdown contains at least one `![...](url)` image reference — signals bug reports with visual evidence. |
| `platform` | string | Client platform from `X-Client-Platform` header (`web` / `desktop`). Omitted when the header is absent. |
| `app_version` | string | Client version from `X-Client-Version` header. Omitted when absent. |
`distinct_id` is the submitter's user id; `workspace_id` is attached from
the modal's current-workspace context and may be empty when feedback is
sent from a pre-workspace surface.
### `starter_content_decided`
Fires on the atomic NULL → terminal state transition in both
ImportStarterContent and DismissStarterContent. The `branch` property
mirrors what ImportStarterContent would emit for the same workspace,
so import-vs-dismiss rates split cleanly by branch.
| Property | Type | Description |
|---|---|---|
| `decision` | string | `imported` or `dismissed`. |
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
`distinct_id` is the user's id; `workspace_id` is attached from the
request payload.
### Frontend-only events
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
every Next.js App Router path or query-string change. The tracker
mounts once under `WebProviders` and drives the acquisition funnel's
`/ → signup` step. posthog-js's automatic pageview capture is
disabled in `initAnalytics` so we own the event shape.
- `onboarding_runtime_path_selected` — fired from
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
user clicks one of the three Step 3 fork cards (before any server
call happens, so it's frontend-only). Properties: `path`
(`download_desktop` / `cli` / `cloud_waitlist`), `source` (`step3`;
literal today but reserved for future surfaces reusing this event),
`is_mac`. Also writes `platform_preference` (`web` / `desktop`) to
person properties so every subsequent event on the user can be
broken down by chosen platform. **Note**: semantic "download
intent" is now better served by `download_intent_expressed` below —
`path: "download_desktop"` signals Step 3 path choice specifically,
not actual download start.
- `onboarding_runtime_detected` — fired from
`packages/views/onboarding/steps/step-runtime-connect.tsx` (desktop
Step 3) once per mount, when the scanning phase resolves — either
immediately on first runtime registration, or after the 5 s empty
timeout. Answers the question "did the user have any AI CLI
installed on this machine when they hit Step 3" — currently
unanswerable from the existing funnel because the bundled daemon
fails to register at all when zero CLIs are on PATH, so
`runtime_registered` is silent on that cohort. Splits
`completion_path=runtime_skipped` into "had CLIs, skipped anyway"
vs "no CLIs available, had no choice". Properties:
- `source`: `step3_desktop` (literal; reserved for a future web
emission under a different value).
- `outcome`: `found` (at least one runtime registered before the
5 s grace window expired) or `empty` (none registered by then).
- `runtime_count`: number of runtimes visible to this user at
resolution time.
- `online_count`: subset of `runtime_count` whose `status` is
`online`.
- `providers`: sorted array of distinct provider names (e.g.
`["claude", "codex"]`).
- `has_claude` / `has_codex` / `has_cursor`: convenience booleans
derived from `providers` for funnel breakdowns without array
filtering in HogQL.
- `detect_ms`: wall-clock ms from component mount to resolution.
Surfaces daemon boot latency — `found` events with a high
`detect_ms` approach the timeout threshold and inform whether
to lengthen the grace period.
Person properties set with `$set`:
- `has_any_cli`: boolean — cohort signal for "user has at least
one local AI CLI detected on this machine".
- `detected_cli_count`: number — granular cohort signal.
Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web
users don't run the bundled daemon, so their runtime list reflects
daemons from other machines and would corrupt the
"CLI installed locally" signal.
- `download_intent_expressed` — fired whenever a user clicks a CTA
that points at the `/download` page. Surfaces five sources across
the funnel, letting the top-of-funnel entry be split cleanly.
Wrapper lives in `packages/core/analytics/download.ts`
(`captureDownloadIntent`). Properties:
- `source`: `landing_hero` / `landing_footer` / `login` / `welcome`
/ `step3`
Also writes `platform_preference: "desktop"` to person properties.
- `download_page_viewed` — fired once per `/download` mount after OS
detect resolves (`apps/web/app/(landing)/download/download-client.tsx`).
Properties:
- `detected_os`: `mac` / `windows` / `linux` / `unknown`
- `detected_arch`: `arm64` / `x64` / `unknown`
- `detect_confident`: `true` when detect used
`userAgentData.getHighEntropyValues` (Chromium); `false` when it
fell back to the UA string (Safari on Mac always lands here —
lets us isolate the arm64-default-for-Intel risk cohort).
- `version_available`: `false` when the GitHub API fetch failed
and the page is in the "Version unavailable" degraded state.
Also writes `first_detected_os` / `first_detected_arch` via
`$set_once` so every downstream event gains a platform dimension
without re-emitting.
- `download_initiated` — fired when the user clicks a specific
installer link on `/download`. Both the hero CTA and the All
Platforms matrix rows emit this; split by `primary_cta`.
Properties:
- `platform`: `mac` / `windows` / `linux`
- `arch`: `arm64` / `x64`
- `format`: `dmg` / `zip` / `exe` / `appimage` / `deb` / `rpm`
- `version`: release tag (e.g. `v0.2.13`) — correlates adoption
with release cadence.
- `primary_cta`: `true` for the hero-recommended installer, `false`
for a manual pick from the All Platforms matrix.
- `matched_detect`: `true` when the chosen platform+arch matches
what the page detected. `false` lets us quantify detect misses
from the single event (no cross-join needed).
- `feedback_opened` — fired when the in-app Feedback modal mounts
(user clicked "Feedback" in the Help launcher). Paired with the
backend's `feedback_submitted` to give a completion rate for the
form. Wrapper lives in `packages/core/analytics/feedback.ts`
(`captureFeedbackOpened`). Properties:
- `source`: `help_menu` (reserved — future entry points like
keyboard shortcut or error-toast CTA will pass their own value)
- `workspace_id`: string (UUID) when the modal opens inside a
workspace. Omitted on pre-workspace surfaces.
- Attribution is NOT a separate event; UTM + referrer origin are written
to the `multica_signup_source` cookie on the first anonymous pageview
and read by the backend's `signup` emission. The cookie carries a JSON
payload URL-encoded at write time (`encodeURIComponent`) and
URL-decoded at read time (`url.QueryUnescape`) — the JSON is never
mid-truncated; individual values are capped at 96 chars before
`JSON.stringify`, and the entire payload is dropped if it still exceeds
512 chars. That way PostHog sees either intact JSON or nothing at all.
## Governance
Before adding, renaming, or removing any event:
1. Update this document first.
2. Update `server/internal/analytics/events.go` constants and helpers to
match.
3. PR description must state which existing funnel / insight is affected.

File diff suppressed because it is too large Load Diff

View File

@@ -1,511 +0,0 @@
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
---
## Current State (files to modify)
| File | Current Role | Change |
|------|-------------|--------|
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
---
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
**Files:**
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
This is the core task. The entire DnD orchestration logic changes.
### Data Model
```typescript
// Local state: maps status → ordered array of issue IDs
// This is the ONLY source of truth for card positions during drag
type Columns = Record<IssueStatus, string[]>;
```
### Step 1: Replace pendingMove with local columns state
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
```typescript
// Build columns from TQ issues + view sort settings
function buildColumns(
issues: Issue[],
visibleStatuses: IssueStatus[],
sortBy: SortField,
sortDirection: SortDirection,
): Columns {
const cols: Columns = {} as Columns;
for (const status of visibleStatuses) {
const sorted = sortIssues(
issues.filter((i) => i.status === status),
sortBy,
sortDirection,
);
cols[status] = sorted.map((i) => i.id);
}
return cols;
}
```
In the component:
```typescript
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
// Local columns state — follows TQ between drags, local during drag
const [columns, setColumns] = useState<Columns>(() =>
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
);
const isDragging = useRef(false);
// Sync from TQ when NOT dragging
useEffect(() => {
if (!isDragging.current) {
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
}
}, [issues, visibleStatuses, sortBy, sortDirection]);
```
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
```typescript
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
```
### Step 2: Implement findColumn helper
```typescript
/** Find which column (status) contains a given ID (issue or column). */
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
// Is it a column ID itself?
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
// Search columns for the item
for (const [status, ids] of Object.entries(columns)) {
if (ids.includes(id)) return status as IssueStatus;
}
return null;
}
```
### Step 3: Implement onDragStart
```typescript
const handleDragStart = useCallback((event: DragStartEvent) => {
isDragging.current = true;
const issue = issueMap.get(event.active.id as string) ?? null;
setActiveIssue(issue);
}, [issueMap]);
```
### Step 4: Implement onDragOver — the key missing piece
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
```typescript
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
const activeCol = findColumn(columns, activeId, visibleStatuses);
const overCol = findColumn(columns, overId, visibleStatuses);
if (!activeCol || !overCol || activeCol === overCol) return;
// Cross-column move: remove from old column, insert into new column
setColumns((prev) => {
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
// Insert position: if over a card, insert at that index; if over column, append
const overIndex = newIds.indexOf(overId);
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
newIds.splice(insertIndex, 0, activeId);
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
}, [columns, visibleStatuses]);
```
### Step 5: Implement onDragEnd — persist to server
```typescript
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
isDragging.current = false;
setActiveIssue(null);
if (!over) {
// Cancelled — reset to TQ state
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
return;
}
const activeId = active.id as string;
const overId = over.id as string;
const activeCol = findColumn(columns, activeId, visibleStatuses);
const overCol = findColumn(columns, overId, visibleStatuses);
if (!activeCol || !overCol) return;
// Same column reorder
if (activeCol === overCol) {
const ids = columns[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex !== newIndex) {
const reordered = arrayMove(ids, oldIndex, newIndex);
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
}
}
// Compute final position from the local column order
const finalCol = findColumn(columns, activeId, visibleStatuses);
if (!finalCol) return;
// After potential same-col reorder, re-read columns
// (for same-col we just did setColumns above, but it's async;
// however we can compute from the intended final order)
let finalIds: string[];
if (activeCol === overCol) {
const ids = columns[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
} else {
finalIds = columns[finalCol]!;
}
const newPosition = computePosition(finalIds, activeId, issues);
const currentIssue = issueMap.get(activeId);
// Skip if nothing changed
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
onMoveIssue(activeId, finalCol, newPosition);
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
```
### Step 6: Update computePosition to work with ID arrays
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
```typescript
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
if (ids.length === 1) return 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
```
### Step 7: Update DragOverlay styling
```typescript
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
</DragOverlay>
```
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
### Step 8: Wire it all together
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
```tsx
{visibleStatuses.map((status) => (
<BoardColumn
key={status}
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMap}
/>
))}
```
### Step 9: Run typecheck
Run: `pnpm typecheck`
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
### Step 10: Commit
```bash
git add apps/web/features/issues/components/board-view.tsx
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
```
---
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
**Files:**
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
```typescript
export function BoardColumn({
status,
issueIds,
issueMap,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const viewStoreApi = useViewStoreApi();
// Resolve IDs to Issue objects (IDs are already sorted by parent)
const resolvedIssues = useMemo(
() => issueIds.flatMap((id) => {
const issue = issueMap.get(id);
return issue ? [issue] : [];
}),
[issueIds, issueMap],
);
return (
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{issueIds.length}
</span>
</div>
{/* Right: add + menu — keep as-is */}
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
}
/>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</div>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/60" : ""
}`}
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</div>
</div>
);
}
```
Key changes:
- No more `useViewStore` for sort — parent handles sorting
- No more internal `sortIssues` call
- Uses `issueIds` for SortableContext (already in correct order)
- Count shows `issueIds.length` instead of `issues.length`
### Step 2: Run typecheck
Run: `pnpm typecheck`
Expected: PASS (or errors in issues-page.tsx — Task 4)
### Step 3: Commit
```bash
git add apps/web/features/issues/components/board-column.tsx
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
```
---
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
**Files:**
- Modify: `apps/web/features/issues/components/board-card.tsx`
### Step 1: Add custom animateLayoutChanges
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
```typescript
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args;
if (isSorting || wasDragging) return false;
return defaultAnimateLayoutChanges(args);
};
```
Update useSortable call:
```typescript
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
});
```
### Step 2: Run typecheck
Run: `pnpm typecheck`
Expected: PASS
### Step 3: Commit
```bash
git add apps/web/features/issues/components/board-card.tsx
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
```
---
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
**Files:**
- Modify: `apps/web/features/issues/components/issues-page.tsx`
### Step 1: Update handleMoveIssue
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
```tsx
<BoardView
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
```
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
### Step 2: Run full typecheck + test
Run: `pnpm typecheck && pnpm test`
Expected: PASS
### Step 3: Commit
```bash
git add apps/web/features/issues/components/issues-page.tsx
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
```
---
## Task 5: Manual QA Checklist
After all code changes, verify these scenarios in the browser:
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
3. **Drop on empty column**: Drag card to an empty column → card lands there
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
---
## Summary of Architecture Change
```
BEFORE (broken):
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
AFTER (correct):
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
onDragStart → isDragging=true, freeze local state
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
```

View File

@@ -1,227 +0,0 @@
# Drag & Drop Upload Enhancement — Revised Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Clean drag-and-drop upload with visual feedback. Images render inline, non-images show as file cards. No file type restrictions (match Linear). No separate attachment section (URLs live in markdown).
**Architecture:** Frontend-only. Images use existing `![](url)` markdown. Non-images use `[name](url)` markdown, rendered as a styled card via Tiptap NodeView when URL matches our CDN. Backend unchanged.
**Tech Stack:** Tiptap ProseMirror, React, Tailwind CSS, shadcn tokens
---
## What We Keep (from previous work)
- **Drag overlay** — `content-editor.tsx` drag handlers + `content-editor.css` overlay styles
- **Image upload flow** — blob preview → upload → replace with real URL (existing `file-upload.ts`)
- **Non-image upload placeholder** — `⏳ Uploading filename...` → replaced with link (existing `file-upload.ts`)
- **`MAX_FILE_SIZE`** — 100MB limit
## What We Remove (redundant)
| File | What to remove |
|------|----------------|
| `attachment-section.tsx` | **Delete entire file** |
| `issue-detail.tsx` | attachment query, delete mutation, handleImageRemoved, AttachmentSection JSX, onImageRemoved prop, all `["attachments"]` cache invalidation, onUploadSuccess on CommentInput, `api` import (if unused after) |
| `content-editor.tsx` | `onImageRemoved` prop, `onImageRemovedRef` |
| `extensions/index.ts` | `onImageRemovedRef` option |
| `extensions/file-upload.ts` | `collectImageSrcs`, `imageRemovalTracker` plugin, `isAllowedFileType` check + import, `toast` import |
| `shared/constants/upload.ts` | Everything except `MAX_FILE_SIZE` — remove `ALLOWED_MIME_PATTERNS`, `FILE_INPUT_ACCEPT`, `EXTENSION_MIME_MAP`, `isAllowedFileType`, `matchesMimePattern` |
| `shared/constants/__tests__/upload.test.ts` | All tests except MAX_FILE_SIZE |
| `shared/hooks/use-file-upload.ts` | `isAllowedFileType` import + check |
| `components/common/file-upload-button.tsx` | `FILE_INPUT_ACCEPT` import + `accept` attribute |
| `comment-input.tsx` | `onUploadSuccess` prop |
## What We Add (new)
**File Card Node** — a Tiptap custom node that renders `[name](url)` as a styled card when the URL matches our CDN (`multica-static.copilothub.ai` or S3 bucket domain).
```
Editor view: ┌──────────────────────────┐
│ 📄 report.pdf ⬇ │
└──────────────────────────┘
Markdown storage: [report.pdf](https://multica-static.copilothub.ai/xxx.pdf)
```
- Only for non-image CDN URLs (images stay as `![](url)`)
- Regular external links (github.com, etc.) stay as normal links
- Card shows: file type icon + filename + download button
- Readonly mode shows the same card
---
## Task 1: Remove Redundant Code
**Files to modify:**
- Delete: `apps/web/features/issues/components/attachment-section.tsx`
- Modify: `apps/web/features/issues/components/issue-detail.tsx`
- Modify: `apps/web/features/issues/components/comment-input.tsx`
- Modify: `apps/web/features/editor/content-editor.tsx`
- Modify: `apps/web/features/editor/extensions/index.ts`
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
- Modify: `apps/web/shared/constants/upload.ts`
- Modify: `apps/web/shared/constants/__tests__/upload.test.ts`
- Modify: `apps/web/shared/hooks/use-file-upload.ts`
- Modify: `apps/web/components/common/file-upload-button.tsx`
**What to do:**
1. Delete `attachment-section.tsx`
2. `issue-detail.tsx`: Remove AttachmentSection import, attachment useQuery, deleteAttachment useMutation, handleImageRemoved, onImageRemoved prop, all `["attachments"]` invalidation in handleDescriptionUpload (revert to simple `uploadWithToast` call), remove onUploadSuccess from CommentInput
3. `comment-input.tsx`: Remove `onUploadSuccess` prop
4. `content-editor.tsx`: Remove `onImageRemoved` prop + ref + wiring
5. `extensions/index.ts`: Remove `onImageRemovedRef` from interface + call
6. `extensions/file-upload.ts`: Remove `collectImageSrcs`, `imageRemovalTracker` plugin, `onImageRemovedRef` param, `isAllowedFileType` import + check, `toast` import (keep `toast` if still used — check)
7. `shared/constants/upload.ts`: Keep only `MAX_FILE_SIZE`. Delete everything else.
8. `shared/constants/__tests__/upload.test.ts`: Keep only `MAX_FILE_SIZE` test
9. `shared/hooks/use-file-upload.ts`: Remove `isAllowedFileType` import + check. Import `MAX_FILE_SIZE` stays.
10. `file-upload-button.tsx`: Remove `FILE_INPUT_ACCEPT` import + `accept` attribute
**Verification:**
```bash
pnpm typecheck && pnpm test
```
**Commit:** `refactor(upload): remove attachment section and file type whitelist`
---
## Task 2: File Card Tiptap Node
**Files:**
- Create: `apps/web/features/editor/extensions/file-card.ts`
- Create: `apps/web/features/editor/extensions/file-card-view.tsx`
- Modify: `apps/web/features/editor/extensions/index.ts`
- Modify: `apps/web/features/editor/content-editor.css`
**Design:**
The node intercepts markdown links `[name](url)` where URL matches our CDN, and renders them as a card NodeView.
```typescript
// Detection: URL starts with CDN domain or known S3 bucket pattern
function isCdnFileUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname.endsWith('.copilothub.ai') || u.hostname.endsWith('.amazonaws.com');
} catch {
return false;
}
}
// Only match non-image files (images stay as ![](url))
function isFileCardLink(url: string): boolean {
return isCdnFileUrl(url) && !isImageUrl(url);
}
```
**Node spec:**
- Node name: `fileCard`
- Attrs: `href`, `filename`
- Markdown serialize: `[filename](href)`
- Markdown parse: detect `[text](cdnUrl)` where cdnUrl is non-image CDN link
- NodeView: React component with file icon + name + download button
**Card UI (React NodeView):**
```tsx
<div className="file-card">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="truncate text-sm">{filename}</span>
<a href={href} download={filename} className="...">
<Download className="h-3.5 w-3.5" />
</a>
</div>
```
**CSS:**
```css
.file-card {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: hsl(var(--accent) / 0.1);
margin: 0.25rem 0;
max-width: 100%;
}
```
**Verification:**
```bash
pnpm typecheck && pnpm test
```
Manual:
1. Upload a PDF → card appears in editor (not plain link)
2. Upload a .go file → card appears
3. Upload an image → still renders inline (not as card)
4. Paste an external link → still renders as normal link (not card)
5. Save and reload → card still displays correctly
6. Switch to readonly mode → card still displays
**Commit:** `feat(editor): render CDN file links as styled cards`
---
## Task 3: Update Non-Image Upload to Use File Card
**Files:**
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
Currently the non-image upload path inserts a markdown string `[name](url)`. After Task 2 adds the fileCard node, this should insert a `fileCard` node directly instead:
```typescript
// Instead of:
const linkText = `[${result.filename}](${result.link})`;
replacePlaceholder(editor, placeholder, linkText);
// Insert fileCard node:
replacePlaceholder(editor, placeholder, "");
editor.chain().focus().insertContent({
type: "fileCard",
attrs: { href: result.link, filename: result.filename },
}).run();
```
**Verification:**
```bash
pnpm typecheck && pnpm test
```
Manual: Upload a PDF → placeholder appears → replaced with file card (not plain text link)
**Commit:** `feat(upload): insert file card node for non-image uploads`
---
## Task 4: Full Verification
```bash
pnpm typecheck && pnpm test
```
Manual test all upload flows:
1. Drag image → overlay → drop → inline image with pulse → real image
2. Drag PDF → overlay → drop → placeholder → file card
3. Drag .mp4 → uploads normally (no type restriction) → file card
4. Paste image → inline image
5. Click 📎 → file picker shows all types → upload works
6. Readonly mode → cards and images display correctly
7. Save → reload → everything persists
**Commit:** fix any issues found
---
## Expected Outcome
| Before (current) | After |
|-------------------|-------|
| File type whitelist blocks .mp4/.zip/etc | All files accepted (like Linear) |
| Attachment Section below description | Gone — files live in markdown |
| Non-image files as plain `[name](url)` text | Styled file card with icon + download |
| Image removal tracker + attachment cache | Gone — simpler code |
| ~300 lines of attachment UI code | Deleted |
| ~100 lines of whitelist code | Replaced by 1 line: `MAX_FILE_SIZE` |

View File

@@ -1,452 +0,0 @@
# Image View Enhancement Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add image hover toolbar (view/download/copy image/copy link/delete), lightbox preview, and smart sizing (centered, max-width capped) — matching Linear's image UX.
**Architecture:** Convert the Image extension from default `<img>` rendering to a React NodeView (`image-view.tsx`). The NodeView wraps `<img>` in a `<figure>` with a hover toolbar and lightbox portal. CSS handles centering and size cap. No new npm dependencies.
**Tech Stack:** Tiptap `ReactNodeViewRenderer`, lucide-react, sonner (toast), CSS, `createPortal` for lightbox
---
## Task 1: Create Image NodeView Component
**Files:**
- Create: `apps/web/features/editor/extensions/image-view.tsx`
**Step 1: Create the ImageView component**
```tsx
// apps/web/features/editor/extensions/image-view.tsx
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import {
Maximize2,
Download,
Copy,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Lightbox — full-screen image preview (ESC or click backdrop to close)
// ---------------------------------------------------------------------------
function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
onClick={onClose}
>
<img
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
/>
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Image NodeView — renders <img> with hover toolbar + lightbox
// ---------------------------------------------------------------------------
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
const src = node.attrs.src as string;
const alt = (node.attrs.alt as string) || "";
const title = node.attrs.title as string | undefined;
const uploading = node.attrs.uploading as boolean;
const [lightbox, setLightbox] = useState(false);
const isEditable = editor.isEditable;
const handleView = () => setLightbox(true);
const handleDownload = async () => {
try {
const res = await fetch(src);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = alt || "image";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
window.open(src, "_blank", "noopener,noreferrer");
}
};
const handleCopyImage = async () => {
try {
const res = await fetch(src);
const blob = await res.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob }),
]);
toast.success("Image copied");
} catch {
// Fallback: copy link (Safari doesn't support async clipboard image)
await navigator.clipboard.writeText(src);
toast.success("Link copied");
}
};
const handleCopyLink = async () => {
await navigator.clipboard.writeText(src);
toast.success("Link copied");
};
return (
<NodeViewWrapper className="image-node">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<figure
className={cn(
"image-figure",
selected && isEditable && "image-selected",
)}
contentEditable={false}
onClick={!isEditable && !uploading ? handleView : undefined}
>
<img
src={src}
alt={alt}
title={title || undefined}
className={cn("image-content", uploading && "image-uploading")}
draggable={false}
/>
{!uploading && (
<div
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title="View image">
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} title="Download">
<Download className="size-3.5" />
</button>
<button
type="button"
onClick={handleCopyImage}
title="Copy image"
>
<Copy className="size-3.5" />
</button>
<button
type="button"
onClick={handleCopyLink}
title="Copy link"
>
<LinkIcon className="size-3.5" />
</button>
{isEditable && (
<button
type="button"
onClick={() => deleteNode()}
title="Delete"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)}
</figure>
{lightbox && (
<ImageLightbox
src={src}
alt={alt}
onClose={() => setLightbox(false)}
/>
)}
</NodeViewWrapper>
);
}
export { ImageView };
```
**Step 2: Verify file created**
Run: `ls apps/web/features/editor/extensions/image-view.tsx`
Expected: file exists
---
## Task 2: Wire Up NodeView in Image Extension
**Files:**
- Modify: `apps/web/features/editor/extensions/index.ts:59-75`
**Step 1: Add import**
At the top of `index.ts`, after the existing imports, add:
```typescript
import { ImageView } from "./image-view";
```
**Step 2: Update ImageExtension — add NodeView, remove inline style**
Replace the `ImageExtension` definition (lines 59-75) with:
```typescript
const ImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
uploading: {
default: false,
renderHTML: (attrs: Record<string, unknown>) =>
attrs.uploading ? { "data-uploading": "" } : {},
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageView);
},
}).configure({
inline: false,
allowBase64: false,
});
```
Key changes:
- Added `addNodeView()` — images now render via React component
- Removed `HTMLAttributes: { style: "max-width: 100%; height: auto;" }` — sizing is now in CSS
**Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
**Step 4: Commit**
```bash
git add apps/web/features/editor/extensions/image-view.tsx apps/web/features/editor/extensions/index.ts
git commit -m "feat(editor): add Image NodeView with toolbar and lightbox
- React NodeView renders images with hover toolbar (view/download/copy/link/delete)
- Lightbox portal for full-screen preview (ESC or click to close)
- Copy image with clipboard API (fallback to copy link on Safari)
- Delete button in edit mode only
- Readonly: click image opens lightbox"
```
---
## Task 3: Update Image CSS — Centering, sizing, toolbar, lightbox
**Files:**
- Modify: `apps/web/features/editor/content-editor.css:379-395`
**Step 1: Replace image CSS rules**
Replace lines 379-395 (from `/* Images — shared styling */` through the `@keyframes` block) with:
```css
/* Images — generic fallback (non-NodeView contexts) */
.rich-text-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* Image NodeView — centered block with max-width cap */
.rich-text-editor .image-node {
display: block !important;
text-align: center;
}
.rich-text-editor .image-figure {
position: relative;
display: inline-block;
max-width: min(100%, 640px);
margin: 0.75rem 0;
}
.rich-text-editor .image-figure.image-selected .image-content {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.rich-text-editor .image-content {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.rich-text-editor .image-uploading {
opacity: 0.5;
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes rte-upload-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.3; }
}
/* Readonly — zoom cursor on clickable images */
.rich-text-editor.readonly .image-figure {
cursor: zoom-in;
}
/* Image toolbar — dark pill, top-right corner, appears on hover */
.image-toolbar {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 1px;
padding: 0.25rem;
background: color-mix(in srgb, black 75%, transparent);
backdrop-filter: blur(8px);
border-radius: var(--radius);
opacity: 0;
transition: opacity 0.15s;
z-index: 1;
}
.image-figure:hover .image-toolbar {
opacity: 1;
}
.image-toolbar button {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: calc(var(--radius) - 2px);
color: white;
transition: background 0.15s;
}
.image-toolbar button:hover {
background: color-mix(in srgb, white 15%, transparent);
}
```
**Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
**Step 3: Commit**
```bash
git add apps/web/features/editor/content-editor.css
git commit -m "style(editor): add image centering, sizing cap, and toolbar styles
- Images centered with max-width 640px cap (smart sizing)
- Dark hover toolbar with blur backdrop
- Selection outline for edit mode
- Zoom cursor for readonly mode
- Upload pulse animation preserved"
```
---
## Task 4: Full Verification
**Step 1: Run all checks**
Run: `pnpm typecheck && pnpm test`
Expected: all pass
**Step 2: Manual verification checklist**
Test in browser:
| # | Test | Expected |
|---|------|----------|
| 1 | Upload large screenshot | Centered, max 640px wide |
| 2 | Upload small image (< 300px) | Natural size, centered |
| 3 | Drag image into editor | Blob preview with pulse → real image |
| 4 | Hover image | Dark toolbar appears top-right (5 buttons edit, 4 readonly) |
| 5 | Toolbar → View image | Full-screen lightbox opens |
| 6 | Lightbox → ESC | Closes |
| 7 | Lightbox → click backdrop | Closes |
| 8 | Toolbar → Download | Browser downloads the image |
| 9 | Toolbar → Copy image | Toast "Image copied", image in clipboard |
| 10 | Toolbar → Copy link | Toast "Link copied", URL in clipboard |
| 11 | Toolbar → Delete | Image removed from editor |
| 12 | Click image (edit mode) | Blue selection outline appears |
| 13 | Select image → Backspace | Image deleted |
| 14 | Click image (readonly mode) | Opens lightbox directly |
| 15 | Readonly toolbar | No Delete button, other 4 buttons work |
| 16 | Save → reload | Images persist with correct styling |
**Step 3: Fix any issues, re-run checks**
Run: `pnpm typecheck && pnpm test`
**Step 4: Commit fixes (if any)**
---
## Architecture Notes
### Why NodeView instead of CSS-only?
The toolbar buttons (view/download/copy/delete) require interactive React components overlaid on the image. CSS-only can handle sizing/centering but cannot add click handlers. A NodeView is the standard Tiptap pattern for this — same as `CodeBlockView` (copy button) and `FileCardView` (download button) already in the codebase.
### Upload flow compatibility
The existing upload flow in `file-upload.ts` uses `tr.setNodeMarkup()` to update image attributes after upload. This works with NodeView because ProseMirror attribute changes trigger React re-renders via `ReactNodeViewRenderer`. Same mechanism used by `FileCardView`'s `finalizeFileCard()`.
### Markdown serialization
No changes needed. Images serialize as `![alt](url)` — standard markdown. The NodeView only affects editor rendering, not serialization. No width/height stored in markdown (sizing is purely CSS).
### Lightbox implementation
Uses `createPortal` to render outside the editor DOM tree, with a keyboard listener for ESC. Intentionally NOT using shadcn Dialog to keep it minimal — no focus trapping or complex accessibility needed for a simple image preview overlay.
### Browser compatibility: Copy image
`navigator.clipboard.write()` with `ClipboardItem` works in Chrome/Edge. Safari requires the clipboard write to be in the same user gesture (no async fetch before write), so it falls back to copying the link URL with a toast notification.
---
## Expected Outcome
| Before | After |
|--------|-------|
| Images stretch to 100% width, left-aligned | Centered, capped at 640px |
| No hover actions on images | 5-button toolbar: View, Download, Copy, Link, Delete |
| No image preview | Click-to-zoom lightbox (ESC/click to close) |
| Readonly images are static | Click to zoom, hover for toolbar (minus Delete) |
| Delete image: select + backspace only | Toolbar Delete button + keyboard |
| No visual selection feedback | Blue outline on selected image |

View File

@@ -1,489 +0,0 @@
# Monorepo Extraction Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Extract shared code into monorepo packages (`packages/core/`, `packages/ui/`, `packages/views/`), set up Turborepo, ensure `apps/web/` runs identically.
**Architecture:** Three packages, single-direction dependencies: `views/ → core/ + ui/`. Core is headless (zero react-dom). UI is atomic (zero business logic). Views is shared pages/components.
**Tech Stack:** pnpm workspaces + catalog, Turborepo, TypeScript internal packages (export TS source, no build), Tailwind CSS v4, shadcn/ui.
**Scope:** Monorepo extraction only. Desktop app is a separate future plan.
**Branch:** `feat/monorepo-extraction` (from latest `main` at f57cf44e)
---
## Work Breakdown
| Category | Files | Nature |
|---|---|---|
| Pure file moves | ~170 | Copy + fix relative imports |
| Code changes needed | ~17 | ApiClient callback, store factories, props refactor, nav adapter |
| Bulk import updates | ~140 consumer files | Mechanical find-and-replace |
| New files to create | ~15 | package.json, tsconfig, turbo.json, platform layer, nav adapter |
---
## Phase 1: Infrastructure (Tasks 1-3)
### Task 1: Turborepo + workspace
**Files:**
- Modify: `pnpm-workspace.yaml` — add `"packages/*"` to packages list, add `@tanstack/react-query` to catalog
- Create: `turbo.json`
- Modify: `package.json` (root) — add turbo devDep, update scripts to use turbo
- Modify: `.gitignore` — add `.turbo`
**turbo.json:**
```json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": { "cache": false, "persistent": true },
"typecheck": { "dependsOn": ["^typecheck"] },
"test": { "dependsOn": ["^typecheck"] },
"lint": { "dependsOn": ["^typecheck"] }
}
}
```
**Verify:** `pnpm typecheck` passes through turbo.
**Commit:** `chore: add Turborepo and configure workspace for packages/*`
---
### Task 2: Shared TypeScript config
**Files:**
- Create: `packages/tsconfig/package.json`
- Create: `packages/tsconfig/base.json`
- Create: `packages/tsconfig/react-library.json`
**base.json** — strict, ESNext, bundler resolution, declaration maps.
**react-library.json** — extends base, adds jsx: react-jsx and DOM lib.
All other packages will `"extends": "@multica/tsconfig/react-library.json"`.
**Commit:** `chore: add shared TypeScript config package`
---
### Task 3: Clean up empty package dirs
**Action:** `rm -rf packages/sdk packages/types packages/utils packages/ui`
These are leftover empty dirs (only contain node_modules).
---
## Phase 2: packages/core/ (Tasks 4-10)
### Task 4: Scaffold + move types/utils/logger
**Files:**
- Create: `packages/core/package.json` (name: @multica/core, deps: react, zustand, @tanstack/react-query, sonner)
- Create: `packages/core/tsconfig.json` (extends @multica/tsconfig/react-library.json)
- Move: `apps/web/shared/types/``packages/core/types/` (11 files, no changes needed)
- Move: `apps/web/shared/logger.ts``packages/core/logger.ts` (no changes)
- Move: `apps/web/shared/utils.ts``packages/core/utils.ts` (no changes)
**Verify:** `cd packages/core && npx tsc --noEmit`
---
### Task 5: Move API client (with onUnauthorized abstraction)
**Files:**
- Move: `apps/web/shared/api/ws-client.ts``packages/core/api/ws-client.ts` (no changes)
- Move: `apps/web/shared/api/client.ts``packages/core/api/client.ts` (**3 changes**)
- Create: `packages/core/api/index.ts`
**Code changes in client.ts:**
1. `import type { ... } from "@/shared/types"``from "../types"`
2. `import { ... } from "@/shared/logger"``from "../logger"`
3. Add `onUnauthorized?: () => void` to options, replace `handleUnauthorized()` body:
```typescript
// Before: localStorage.removeItem + window.location.href = "/"
// After: this.token = null; this.workspaceId = null; this.options.onUnauthorized?.();
```
**NOT moved:** `apps/web/shared/api/index.ts` (the singleton) — replaced by `apps/web/platform/api.ts` in Task 9.
---
### Task 6: Move stores
**Pure moves (fix imports only):**
- `features/issues/store.ts` → `packages/core/issues/store.ts`
- `features/issues/config/*.ts` → `packages/core/issues/config/` — fix `@/shared/types` → `../../types`
- `features/issues/stores/view-store.ts` → `packages/core/issues/stores/view-store.ts` — fix imports
- `features/issues/stores/view-store-context.tsx` → `packages/core/issues/stores/view-store-context.tsx`
- `features/issues/stores/draft-store.ts` → `packages/core/issues/stores/draft-store.ts`
- `features/issues/stores/issues-scope-store.ts` → `packages/core/issues/stores/issues-scope-store.ts`
- `features/issues/stores/selection-store.ts` → `packages/core/issues/stores/selection-store.ts`
- `features/navigation/store.ts` → `packages/core/navigation/store.ts` (no changes)
- `features/modals/store.ts` → `packages/core/modals/store.ts` (no changes)
**Factory refactor (code changes):**
- `features/auth/store.ts` → `packages/core/auth/store.ts` — change to `createAuthStore({ api, onLogin?, onLogout? })` factory
- `features/workspace/store.ts` → `packages/core/workspace/store.ts` — change to `createWorkspaceStore(api)` factory
**Also move:**
- `features/workspace/hooks.ts` → `packages/core/workspace/hooks.ts` — fix imports to relative
**view-store.ts special handling:** The dynamic `import("@/features/workspace")` for workspace sync — change to accept workspace store instance via `registerViewStoreForWorkspaceSync(viewStore, workspaceStore)`.
---
### Task 7: Move TanStack Query modules
**Pure moves (fix import paths only):**
- `apps/web/core/issues/{queries,mutations,ws-updaters}.ts` → `packages/core/issues/`
- `apps/web/core/inbox/{queries,mutations,ws-updaters}.ts` → `packages/core/inbox/`
- `apps/web/core/workspace/{queries,mutations}.ts` → `packages/core/workspace/`
- `apps/web/core/runtimes/queries.ts` → `packages/core/runtimes/`
- `apps/web/core/query-client.ts` → `packages/core/query-client.ts`
- `apps/web/core/provider.tsx` → `packages/core/provider.tsx`
All changes: `@/shared/api` → `../api`, `@/shared/types` → `../types`, `@core/xxx` → `./xxx` or `../xxx`
**Code change:**
- `apps/web/core/hooks.ts` → `packages/core/hooks.ts` — refactor `useWorkspaceId()` to use React Context instead of importing workspace store directly:
```typescript
const WorkspaceIdContext = createContext<string | null>(null);
export function WorkspaceIdProvider({ wsId, children }) { ... }
export function useWorkspaceId() { return useContext(WorkspaceIdContext); }
```
---
### Task 8: Move realtime + shared hooks
**Pure moves (fix imports):**
- `features/realtime/hooks.ts` → `packages/core/realtime/hooks.ts`
- `features/realtime/use-realtime-sync.ts` → `packages/core/realtime/use-realtime-sync.ts`
- `shared/hooks/use-file-upload.ts` → `packages/core/hooks/use-file-upload.ts`
**Code change:**
- `features/realtime/provider.tsx` → `packages/core/realtime/provider.tsx` — accept `wsUrl` prop instead of reading `process.env.NEXT_PUBLIC_WS_URL`
**Note:** `use-realtime-sync.ts` needs auth/workspace store access. Since these are now factories, the realtime provider should receive the store instances. Simplest: WSProvider accepts `authStore` and `workspaceStore` props, passes them to `useRealtimeSync`.
---
### Task 9: Create platform bridge in apps/web/
**New files (all new code):**
- `apps/web/platform/api.ts` — creates api singleton with `NEXT_PUBLIC_API_URL`, `onUnauthorized` with `window.location.href`
- `apps/web/platform/auth.ts` — `export const useAuthStore = createAuthStore({ api, onLogin: setLoggedInCookie, onLogout: clearLoggedInCookie })`
- `apps/web/platform/workspace.ts` — `export const useWorkspaceStore = createWorkspaceStore(api)`
- `apps/web/platform/index.ts` — re-exports
---
### Task 10: Update imports in apps/web/ + delete old files
**Bulk find-and-replace across ~94 files:**
| Pattern | Replacement |
|---|---|
| `@/shared/types` | `@multica/core/types` |
| `@/shared/api"` (singleton usage) | `@/platform/api"` |
| `@/shared/logger` | `@multica/core/logger` |
| `@/shared/utils` | `@multica/core/utils` |
| `@/shared/hooks/` | `@multica/core/hooks/` |
| `@core/` | `@multica/core/` |
| `@/features/auth"` (useAuthStore) | `@/platform/auth"` |
| `@/features/workspace"` (useWorkspaceStore) | `@/platform/workspace"` |
| `@/features/workspace"` (useActorName) | `@multica/core/workspace/hooks"` |
| `@/features/realtime` | `@multica/core/realtime` |
| `@/features/navigation` | `@multica/core/navigation` |
| `@/features/modals"` (store) | `@multica/core/modals"` |
| `@/features/issues/store` | `@multica/core/issues` |
| `@/features/issues/stores/` | `@multica/core/issues/stores/` |
| `@/features/issues/config` | `@multica/core/issues/config` |
**Also:**
- Add `"@multica/core": "workspace:*"` to `apps/web/package.json`
- Add `transpilePackages: ["@multica/core"]` to `next.config.ts`
- Remove `"@core/*"` alias from `apps/web/tsconfig.json`
**Delete old files:**
```
apps/web/shared/types/, apps/web/shared/api/, apps/web/shared/logger.ts,
apps/web/shared/utils.ts, apps/web/shared/hooks/, apps/web/core/,
features/auth/store.ts, features/workspace/store.ts, features/workspace/hooks.ts,
features/realtime/, features/navigation/store.ts, features/modals/store.ts,
features/issues/store.ts, features/issues/stores/, features/issues/config/
```
**Keep:** `features/auth/auth-cookie.ts`, `features/auth/initializer.tsx`, `features/landing/`
**Verify:** `pnpm typecheck && pnpm test`
**Commit:** `feat(core): extract packages/core — headless business logic layer`
---
## Phase 3: packages/ui/ (Tasks 11-16)
### Task 11: Scaffold packages/ui/
**Files:**
- Create: `packages/ui/package.json` (name: @multica/ui, deps: all @radix-ui/*, clsx, tailwind-merge, lucide-react, emoji-mart, react-markdown, shiki, etc.)
- Create: `packages/ui/tsconfig.json` (extends shared config, with `@/lib/utils`, `@/hooks/*`, `@/components/ui/*` path aliases for internal shadcn imports)
- Create: `packages/ui/components.json` (shadcn config for this package)
---
### Task 12: Move shadcn + lib + hooks
**Pure moves (no code changes):**
- `apps/web/components/ui/*.tsx` (56 files) → `packages/ui/components/ui/`
- `apps/web/lib/utils.ts` → `packages/ui/lib/utils.ts`
- `apps/web/hooks/{use-auto-scroll,use-mobile,use-scroll-fade}.ts` → `packages/ui/hooks/`
---
### Task 13: Extract CSS tokens
- Copy `@theme inline { ... }` + `:root` + `.dark` blocks from `globals.css` → `packages/ui/styles/tokens.css`
- Update `globals.css`: replace inline tokens with `@import "@multica/ui/styles/tokens.css"` + add `@source` directives for packages
---
### Task 14: Refactor + move common components
**Code changes (3 files):**
- `actor-avatar.tsx` — remove `useActorName()`, accept `name/initials/avatarUrl/isAgent` props
- `mention-hover-card.tsx` — remove `useQuery`, accept resolved data props
- `reaction-bar.tsx` — remove `useActorName()`, add `getActorName` prop
**Pure moves (3 files):**
- `file-upload-button.tsx`, `emoji-picker.tsx`, `quick-emoji-picker.tsx` → direct copy
All go to `packages/ui/components/common/`.
---
### Task 15: Move markdown components
**Code change (1 file):**
- `Markdown.tsx` — add `renderMention?: (props: { type: string; id: string }) => ReactNode` prop, remove hardcoded `IssueMentionCard` import
**Pure moves (5 files):**
- `CodeBlock.tsx`, `StreamingMarkdown.tsx`, `linkify.ts`, `mentions.ts`, `index.ts`
All go to `packages/ui/markdown/`.
---
### Task 16: Update imports + delete old files
**Bulk find-and-replace across ~118 files:**
| Pattern | Replacement |
|---|---|
| `@/components/ui/` | `@multica/ui/components/ui/` |
| `@/components/common/` | `@multica/ui/components/common/` |
| `@/components/markdown` | `@multica/ui/markdown` |
| `@/lib/utils` | `@multica/ui/lib/utils` |
| `@/hooks/use-mobile` | `@multica/ui/hooks/use-mobile` |
| `@/hooks/use-auto-scroll` | `@multica/ui/hooks/use-auto-scroll` |
| `@/hooks/use-scroll-fade` | `@multica/ui/hooks/use-scroll-fade` |
**Also:**
- Add `"@multica/ui": "workspace:*"` to `apps/web/package.json`
- Add `"@multica/ui"` to `transpilePackages` in `next.config.ts`
- Update `apps/web/components.json` aliases to point to `@multica/ui`
**Delete:** `components/ui/`, `components/common/`, `components/markdown/`, `hooks/`, `lib/utils.ts`
**Keep:** `components/{theme-provider,theme-toggle,multica-icon,loading-indicator,spinner,locale-sync}.tsx`
**Verify:** `pnpm typecheck && pnpm test`
**Commit:** `feat(ui): extract packages/ui — shared atomic UI layer`
---
## Phase 4: packages/views/ + navigation (Tasks 17-22)
### Task 17: Create navigation adapter
**New files (all new code, ~60 lines total):**
- `packages/views/package.json` (deps: @multica/core, @multica/ui, @dnd-kit/*, @tiptap/*, sonner, recharts)
- `packages/views/tsconfig.json`
- `packages/views/navigation/types.ts` — `NavigationAdapter` interface (push, replace, back, pathname, searchParams)
- `packages/views/navigation/context.tsx` — `NavigationProvider` + `useNavigation()` hook
- `packages/views/navigation/app-link.tsx` — `<AppLink>` component (replaces `next/link`)
- `packages/views/navigation/index.ts`
---
### Task 18: Create WebNavigationProvider
**New file:**
- `apps/web/platform/navigation.tsx` — wraps `useRouter`/`usePathname`/`useSearchParams` into `NavigationAdapter`
Wire into dashboard layout.
---
### Task 19: Move feature UI components
**Next.js decouple (7 files, ~2 lines each):**
| File | Import change | JSX change |
|---|---|---|
| `issue-mention-card.tsx` | `next/link` → `../navigation` | `<Link` → `<AppLink` |
| `board-card.tsx` | same | same |
| `list-row.tsx` | same | same |
| `issue-detail.tsx` | `next/link` + `next/navigation` → `../navigation` | `<Link` → `<AppLink`, `router.push` → `nav.push` |
| `create-issue.tsx` | `next/navigation` → `../navigation` | `router.push` → `nav.push` |
| `create-workspace.tsx` | same | same |
**Pure moves (~85 files, fix import paths only):**
- `features/issues/components/` (24 files) → `packages/views/issues/components/`
- `features/issues/hooks/` (3 files) → `packages/views/issues/hooks/`
- `features/issues/utils/` (5 files) → `packages/views/issues/utils/`
- `features/editor/` (16 files incl CSS) → `packages/views/editor/`
- `features/modals/{create-issue,create-workspace,registry}.tsx` → `packages/views/modals/`
- `features/my-issues/` (4 files) → `packages/views/my-issues/`
- `features/skills/` (5 files) → `packages/views/skills/`
- `features/runtimes/` (16 files) → `packages/views/runtimes/`
- `features/workspace/components/workspace-avatar.tsx` → `packages/views/workspace/`
---
### Task 20: Extract fat pages
Move logic from page.tsx files into packages/views/:
| Page | Lines | Target |
|---|---|---|
| `(dashboard)/agents/page.tsx` | 1,280 | `packages/views/agents/agents-page.tsx` |
| `(dashboard)/inbox/page.tsx` | 468 | `packages/views/inbox/inbox-page.tsx` |
| `(auth)/login/page.tsx` | 389 | `packages/views/auth/login-page.tsx` |
Each original page.tsx becomes a 3-line thin shell:
```typescript
"use client";
import { AgentsPage } from "@multica/views/agents";
export default function Page() { return <AgentsPage />; }
```
Login page: pass `googleClientId` as prop instead of reading env var.
---
### Task 21: Update imports + delete old files
**Bulk find-and-replace across ~18 files:**
| Pattern | Replacement |
|---|---|
| `@/features/issues/components` | `@multica/views/issues/components` |
| `@/features/issues/hooks/` | `@multica/views/issues/hooks/` |
| `@/features/editor` | `@multica/views/editor` |
| `@/features/modals/` (components) | `@multica/views/modals/` |
| `@/features/my-issues` | `@multica/views/my-issues` |
| `@/features/skills` | `@multica/views/skills` |
| `@/features/runtimes` | `@multica/views/runtimes` |
**Also:**
- Add `"@multica/views": "workspace:*"` to `apps/web/package.json`
- Add `"@multica/views"` to `transpilePackages`
- Add `@source "../../packages/views/**/*.tsx"` to `globals.css`
**Delete old feature files.**
**Verify:** `pnpm typecheck && pnpm test`
**Commit:** `feat(views): extract packages/views — shared business UI + navigation adapter`
---
### Task 22: Final verification
```bash
make check # typecheck + unit tests + Go tests + E2E
cd apps/web && npx shadcn@latest add --dry-run badge # shadcn CLI works
# Package constraints
grep -r "@multica/core" packages/ui/ || echo "PASS: ui/ has zero core imports"
grep -r "react-dom" packages/core/ || echo "PASS: core/ has zero react-dom"
grep -r "from \"next/" packages/views/ || echo "PASS: views/ has zero next/* imports"
```
**Commit:** `chore: monorepo extraction complete — all checks pass`
---
## Final Directory Structure
```
multica/
├── packages/
│ ├── tsconfig/ # Shared TS config
│ ├── core/ # @multica/core — 三端共用 (零 react-dom)
│ │ ├── api/ # ApiClient class + WSClient
│ │ ├── types/ # 所有领域类型
│ │ ├── auth/ # createAuthStore factory
│ │ ├── workspace/ # createWorkspaceStore factory + useActorName
│ │ ├── issues/ # stores, config, queries, mutations, ws-updaters
│ │ ├── inbox/ # queries, mutations, ws-updaters
│ │ ├── runtimes/ # queries
│ │ ├── realtime/ # WSProvider, hooks, sync
│ │ ├── navigation/ # useNavigationStore
│ │ ├── modals/ # useModalStore
│ │ └── hooks.ts # useWorkspaceId (Context-based)
│ ├── ui/ # @multica/ui — Web+Desktop 共用 (零业务逻辑)
│ │ ├── components/ui/ # 56 shadcn 组件
│ │ ├── components/common/ # actor-avatar, emoji-picker... (纯 props)
│ │ ├── markdown/ # Markdown, StreamingMarkdown (renderMention slot)
│ │ ├── hooks/ # use-auto-scroll, use-mobile, use-scroll-fade
│ │ ├── lib/utils.ts # cn()
│ │ └── styles/tokens.css
│ └── views/ # @multica/views — Web+Desktop 共用页面
│ ├── navigation/ # NavigationAdapter + AppLink
│ ├── issues/ # IssuesPage, IssueDetail, BoardView...
│ ├── editor/ # ContentEditor, TitleEditor
│ ├── modals/ # CreateIssue, CreateWorkspace
│ ├── agents/ # AgentsPage (从 1280 行 page.tsx 提取)
│ ├── inbox/ # InboxPage (从 468 行 page.tsx 提取)
│ ├── auth/ # LoginPage (从 389 行 page.tsx 提取)
│ ├── my-issues/ # MyIssuesPage
│ ├── skills/ # SkillsPage
│ └── runtimes/ # RuntimesPage
├── apps/
│ └── web/
│ ├── app/ # Next.js 路由薄壳 (每个 page < 15 行)
│ ├── platform/ # Web 平台适配 (api 单例, auth store, nav provider)
│ ├── features/
│ │ ├── auth/ # auth-cookie.ts (Web 独有) + initializer.tsx
│ │ └── landing/ # Landing 页面 (Web 独有, 用 next/image)
│ └── components/ # theme-provider, multica-icon 等 app 级组件
├── turbo.json
└── pnpm-workspace.yaml
```
---
## Execution Order & Commits
| # | Commit | 影响范围 | 风险 |
|---|---|---|---|
| 1 | `chore: Turborepo + workspace` | 配置文件 | 低 |
| 2 | `chore: shared TypeScript config` | 新文件 | 低 |
| 3 | `feat(core): extract packages/core` | 94 文件 import 变更 | 中 — 最大批量替换 |
| 4 | `feat(ui): extract packages/ui` | 118 文件 import 变更 | 中 — 最多文件 |
| 5 | `feat(views): extract packages/views` | 18 文件 + 3 胖壳 | 中 |
| 6 | `chore: final verification` | 0 | 低 |

File diff suppressed because it is too large Load Diff

View File

@@ -1,868 +0,0 @@
# Monorepo Full Extraction Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 让每个 app 只剩路由定义 + NavigationAdapter + 真正独有的功能landing page、title bar、cookie。所有业务逻辑、UI、状态管理、API、WS 全部在共享包里,零重复。
**核心洞察:** Electron renderer 就是浏览器。localStorage、fetch、WebSocket 和 Next.js 客户端页面完全一样。URL 是环境配置不是 app 差异。所以除了 NavigationAdapter路由框架不同没有任何东西需要在每个 app 里单独写。
**Architecture:** `@multica/core` 自带完整初始化API、stores、WS不需要每个 app 调用 factory。`@multica/views` 包含所有页面和 layout。每个 app 只提供路由壳子。
**Tech Stack:** React 19, TanStack Query, Zustand, Tailwind CSS v4, shadcn/ui, TypeScript strict mode.
**Branch:** `feat/monorepo-extraction` (from latest `feat/desktop-app`)
---
## Work Breakdown
| Phase | Tasks | What it achieves |
|---|---|---|
| Phase 1: Core 自包含初始化 | 1-2 | core 自己初始化 API/stores/WSapp 不需要写任何 platform 代码 |
| Phase 2: Sidebar & Layout | 3-5 | 共享 AppSidebar + DashboardLayout删除两端重复 |
| Phase 3: Login | 6-7 | 共享 LoginPage + AuthInitializer |
| Phase 4: Agents | 8-10 | 1,279 行 → 共享模块 |
| Phase 5: Inbox | 11-13 | 468 行 → 共享模块 |
| Phase 6: Settings | 14-16 | 1,277 行 → 共享模块 |
| Phase 7: 清理 | 17-18 | 删除所有 platform 目录、placeholder、死代码 |
---
## Phase 1: Core 自包含初始化
### 设计思路
现在每个 app 都要手动调用 `new ApiClient()``createAuthStore()``createWorkspaceStore()`、包 `<WSProvider>`。但这些逻辑在两个 app 里完全一样。
方案:`@multica/core` 导出一个 `<CoreProvider>` 包裹整个应用。它内部自动完成所有初始化。配置通过环境变量(`VITE_API_URL` / `NEXT_PUBLIC_API_URL`)或 prop 注入。SSR-safe 的 localStorage wrapper 内置到 core 里作为默认 storage`typeof window` 守卫对 Electron 无害)。
```tsx
// 任何 app 的根组件,只需要这样:
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL ?? ""}
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
onLogin={setLoggedInCookie} // 可选Web 独有
onLogout={clearLoggedInCookie} // 可选Web 独有
>
{children}
</CoreProvider>
```
Desktop 更简单(没有可选回调):
```tsx
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL ?? "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
>
{children}
</CoreProvider>
```
### Task 1: 在 `@multica/core` 里创建 CoreProvider
**Files:**
- Create: `packages/core/platform/storage.ts` — 内置 SSR-safe localStorage
- Create: `packages/core/platform/core-provider.tsx` — CoreProvider 组件
- Create: `packages/core/platform/auth-initializer.tsx` — 共享 AuthInitializer
- Create: `packages/core/platform/types.ts` — CoreProviderProps
- Create: `packages/core/platform/index.ts` — barrel export
- Modify: `packages/core/package.json` — add `"./platform"` export
**Step 1: Create built-in SSR-safe storage**
```typescript
// packages/core/platform/storage.ts
import type { StorageAdapter } from "../types/storage";
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
export const defaultStorage: StorageAdapter = {
getItem: (k) => (typeof window !== "undefined" ? localStorage.getItem(k) : null),
setItem: (k, v) => { if (typeof window !== "undefined") localStorage.setItem(k, v); },
removeItem: (k) => { if (typeof window !== "undefined") localStorage.removeItem(k); },
};
```
**Step 2: Create types**
```typescript
// packages/core/platform/types.ts
export interface CoreProviderProps {
children: React.ReactNode;
/** API base URL. Default: "" (same-origin). */
apiBaseUrl?: string;
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
wsUrl?: string;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */
onLogout?: () => void;
}
```
**Step 3: Create AuthInitializer**
Merge the identical logic from both apps. Uses `defaultStorage`, reads from existing singletons.
```typescript
// packages/core/platform/auth-initializer.tsx
import { useEffect, type ReactNode } from "react";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
const logger = createLogger("auth");
export function AuthInitializer({
children,
onLogin,
onLogout,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
}) {
useEffect(() => {
const token = defaultStorage.getItem("multica_token");
if (!token) {
onLogout?.();
useAuthStore.setState({ isLoading: false });
return;
}
const api = getApi();
api.setToken(token);
const wsId = defaultStorage.getItem("multica_workspace_id");
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
api.setWorkspaceId(null);
defaultStorage.removeItem("multica_token");
defaultStorage.removeItem("multica_workspace_id");
onLogout?.();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);
return <>{children}</>;
}
```
**Step 4: Create CoreProvider**
This is the one component that wires everything together. Each app wraps its root with this.
```typescript
// packages/core/platform/core-provider.tsx
"use client";
import { type ReactNode, useMemo } from "react";
import { ApiClient } from "../api/client";
import { setApiInstance } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
import { WSProvider } from "../realtime";
import { QueryProvider } from "../provider";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import { AuthInitializer } from "./auth-initializer";
import type { CoreProviderProps } from "./types";
// Module-level singletons — created once, shared across renders.
let initialized = false;
let authStore: ReturnType<typeof createAuthStore>;
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
function initCore(apiBaseUrl: string) {
if (initialized) return;
const api = new ApiClient(apiBaseUrl, {
logger: createLogger("api"),
onUnauthorized: () => {
defaultStorage.removeItem("multica_token");
defaultStorage.removeItem("multica_workspace_id");
},
});
setApiInstance(api);
// Hydrate token from storage
const token = defaultStorage.getItem("multica_token");
if (token) api.setToken(token);
const wsId = defaultStorage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage: defaultStorage });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, {
storage: defaultStorage,
});
registerWorkspaceStore(workspaceStore);
initialized = true;
}
export function CoreProvider({
children,
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render
useMemo(() => initCore(apiBaseUrl), [apiBaseUrl]);
return (
<QueryProvider>
<AuthInitializer onLogin={onLogin} onLogout={onLogout}>
<WSProvider
wsUrl={wsUrl}
authStore={authStore}
workspaceStore={workspaceStore}
storage={defaultStorage}
>
{children}
</WSProvider>
</AuthInitializer>
</QueryProvider>
);
}
```
**Step 5: Barrel export + package.json**
```typescript
// packages/core/platform/index.ts
export { CoreProvider } from "./core-provider";
export type { CoreProviderProps } from "./types";
export { AuthInitializer } from "./auth-initializer";
export { defaultStorage } from "./storage";
```
Add to `packages/core/package.json` exports:
```json
"./platform": "./platform/index.ts"
```
**Step 6: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
**Step 7: Commit**
```bash
git add packages/core/platform/ packages/core/package.json
git commit -m "feat(core): add CoreProvider — single component for full app initialization"
```
---
### Task 2: Migrate both apps to CoreProvider
**Files:**
- Modify: `apps/web/app/layout.tsx` — replace all providers with `<CoreProvider>`
- Modify: `apps/desktop/src/renderer/src/App.tsx` — replace all providers with `<CoreProvider>`
- Delete: `apps/web/platform/api.ts`
- Delete: `apps/web/platform/auth.ts`
- Delete: `apps/web/platform/workspace.ts`
- Delete: `apps/web/platform/storage.ts`
- Delete: `apps/web/platform/ws-provider.tsx`
- Delete: `apps/web/features/auth/initializer.tsx`
- Delete: `apps/desktop/src/renderer/src/platform/api.ts`
- Delete: `apps/desktop/src/renderer/src/platform/auth.ts`
- Delete: `apps/desktop/src/renderer/src/platform/workspace.ts`
- Delete: `apps/desktop/src/renderer/src/platform/storage.ts`
- Delete: `apps/desktop/src/renderer/src/platform/ws-provider.tsx`
- Delete: `apps/desktop/src/renderer/src/platform/auth-initializer.tsx`
- Keep: `apps/web/platform/navigation.tsx` — NavigationAdapter (唯一不可共享)
- Keep: `apps/desktop/src/renderer/src/platform/navigation.tsx` — NavigationAdapter
- Keep: `apps/web/features/auth/auth-cookie.ts` — Web 独有
**Step 1: Update web root layout**
```typescript
// apps/web/app/layout.tsx
import { CoreProvider } from "@multica/core/platform";
import { WebNavigationProvider } from "@/platform/navigation";
import { setLoggedInCookie, clearLoggedInCookie } from "@/features/auth/auth-cookie";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "sonner";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>
<WebNavigationProvider>
{children}
</WebNavigationProvider>
</CoreProvider>
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
```
**Step 2: Update desktop App.tsx**
```typescript
// apps/desktop/src/renderer/src/App.tsx
import { RouterProvider } from "react-router-dom";
import { CoreProvider } from "@multica/core/platform";
import { ThemeProvider } from "./components/theme-provider";
import { Toaster } from "sonner";
import { router } from "./router";
export function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL}
wsUrl={import.meta.env.VITE_WS_URL}
>
<RouterProvider router={router} />
</CoreProvider>
<Toaster />
</ThemeProvider>
);
}
```
**Step 3: Fix all `@/platform/*` imports across both apps**
Search all files for:
- `from "@/platform/api"``from "@multica/core/api"` (use singleton proxy `api`)
- `from "@/platform/auth"``from "@multica/core/auth"` (use singleton `useAuthStore`)
- `from "@/platform/workspace"``from "@multica/core/workspace"` (use singleton `useWorkspaceStore`)
These singletons already exist and are registered by CoreProvider on init. Every component can import them directly from core.
**Step 4: Delete all platform files except navigation**
Web — delete entire `apps/web/platform/` except `navigation.tsx`. Flatten:
```
apps/web/platform/navigation.tsx → keep (only file left)
```
Desktop — delete entire `apps/desktop/.../platform/` except `navigation.tsx`. Flatten:
```
apps/desktop/.../platform/navigation.tsx → keep (only file left)
```
**Step 5: Run typecheck + tests**
Run: `pnpm typecheck && pnpm test`
Expected: PASS
**Step 6: Commit**
```bash
git commit -m "refactor: migrate both apps to CoreProvider — delete all platform duplication"
```
---
## Phase 2: Sidebar & Layout
### Task 3: Extract `AppSidebar` to `@multica/views/layout`
**Why:** Web and Desktop sidebars are 99% identical (239 vs 236 lines). Only difference: `Link`/`usePathname`/`useRouter` (web) vs `AppLink`/`useNavigation` (desktop). Since `useNavigation` + `AppLink` is the abstraction in views, the desktop version is already the correct shared version.
**Files:**
- Create: `packages/views/layout/app-sidebar.tsx` — copy from desktop version
- Create: `packages/views/layout/index.ts`
- Modify: `packages/views/package.json` (add `"./layout"` export)
- Modify: `apps/web/app/(dashboard)/layout.tsx` — import from views
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` — import from views
- Delete: `apps/web/app/(dashboard)/_components/app-sidebar.tsx`
- Delete: `apps/desktop/src/renderer/src/components/app-sidebar.tsx`
**Step 1: Create shared AppSidebar**
Copy desktop `app-sidebar.tsx` into `packages/views/layout/app-sidebar.tsx`. Key changes:
- `import { useAuthStore } from "@multica/core/auth"` (singleton)
- `import { useWorkspaceStore } from "@multica/core/workspace"` (singleton)
- `import { api } from "@multica/core/api"` (singleton proxy)
- `import { useNavigation, AppLink } from "../navigation"` (relative within views)
- `import { useModalStore } from "@multica/core/modals"`
- All `@multica/ui` imports unchanged
**Step 2: Barrel export + package.json**
```typescript
// packages/views/layout/index.ts
export { AppSidebar } from "./app-sidebar";
```
Add to `packages/views/package.json`:
```json
"./layout": "./layout/index.ts"
```
**Step 3: Update both apps, delete old files**
**Step 4: Run typecheck**
Run: `pnpm typecheck`
**Step 5: Commit**
```bash
git commit -m "refactor(views): extract shared AppSidebar to @multica/views/layout"
```
---
### Task 4: Extract `DashboardLayout` to `@multica/views/layout`
**Why:** Both apps have identical dashboard shell: auth guard → loading → sidebar + workspace provider + content. Only differences: web has `SearchCommand`, desktop has `TitleBar`. These are slots.
**Files:**
- Create: `packages/views/layout/dashboard-layout.tsx`
- Modify: `packages/views/layout/index.ts` (add export)
- Modify: `apps/web/app/(dashboard)/layout.tsx` (~10 lines after)
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` (~10 lines after)
**Step 1: Create shared DashboardLayout**
```typescript
// packages/views/layout/dashboard-layout.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import { useNavigationStore } from "@multica/core/navigation";
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
import { WorkspaceIdProvider } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { ModalRegistry } from "../modals/registry";
import { useNavigation } from "../navigation";
import { AppSidebar } from "./app-sidebar";
interface DashboardLayoutProps {
children: ReactNode;
/** Above sidebar (e.g. desktop TitleBar) */
header?: ReactNode;
/** Sibling of SidebarInset (e.g. web SearchCommand) */
extra?: ReactNode;
/** Loading indicator */
loadingIndicator?: ReactNode;
/** Redirect path when not authenticated. Default: "/" */
loginPath?: string;
}
export function DashboardLayout({
children, header, extra, loadingIndicator, loginPath = "/",
}: DashboardLayoutProps) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) push(loginPath);
}, [user, isLoading, push, loginPath]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);
}, [pathname]);
if (isLoading) {
return (
<div className="flex h-screen flex-col">
{header}
<div className="flex flex-1 items-center justify-center">
{loadingIndicator}
</div>
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen flex-col">
{header}
<div className="flex flex-1 min-h-0">
<SidebarProvider className="flex-1">
<AppSidebar />
<SidebarInset className="overflow-hidden">
{workspace ? (
<WorkspaceIdProvider wsId={workspace.id}>
{children}
<ModalRegistry />
</WorkspaceIdProvider>
) : (
<div className="flex flex-1 items-center justify-center">
{loadingIndicator}
</div>
)}
</SidebarInset>
{extra}
</SidebarProvider>
</div>
</div>
);
}
```
**Step 2: Slim down web layout**
```typescript
// apps/web/app/(dashboard)/layout.tsx
"use client";
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@/components/multica-icon";
import { SearchCommand } from "@/features/search";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
extra={<SearchCommand />}
>
{children}
</DashboardLayout>
);
}
```
**Step 3: Slim down desktop shell**
```typescript
// apps/desktop/src/renderer/src/components/dashboard-shell.tsx
import { Outlet } from "react-router-dom";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { DashboardLayout } from "@multica/views/layout";
import { TitleBar } from "./title-bar";
import { MulticaIcon } from "./multica-icon";
export function DashboardShell() {
return (
<DesktopNavigationProvider>
<DashboardLayout
header={<TitleBar />}
loginPath="/login"
loadingIndicator={<MulticaIcon className="size-6" />}
>
<Outlet />
</DashboardLayout>
</DesktopNavigationProvider>
);
}
```
**Step 4: Run typecheck**
Run: `pnpm typecheck`
**Step 5: Commit**
```bash
git commit -m "refactor(views): extract shared DashboardLayout to @multica/views/layout"
```
---
### Task 5: Build + smoke test
Run: `pnpm build && make check`
Fix any issues, commit:
```bash
git commit -m "fix: fixups from layout extraction"
```
---
## Phase 3: Shared Login Page
### Task 6: Extract `LoginPage` to `@multica/views/auth`
**Why:** Desktop login (139 lines) is a simple email/code form. Web login (393 lines) has extra: CLI callback, Google OAuth, OTP component. Strategy: extract the core email/code form to views. Desktop uses it directly. Web keeps its own richer version (too different to merge).
**Files:**
- Create: `packages/views/auth/login-page.tsx`
- Create: `packages/views/auth/index.ts`
- Modify: `packages/views/package.json` (add `"./auth"` export)
- Modify: `apps/desktop/src/renderer/src/pages/login.tsx` (~10 lines after)
**Step 1: Create shared LoginPage**
Props: `logo?: ReactNode`, `onSuccess: () => void`. Internally uses `useAuthStore`/`useWorkspaceStore`/`api` from core singletons.
**Step 2: Update desktop login**
```typescript
import { useNavigate } from "react-router-dom";
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "../components/multica-icon";
import { TitleBar } from "../components/title-bar";
export function DesktopLoginPage() {
const navigate = useNavigate();
return (
<div className="flex h-screen flex-col">
<TitleBar />
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
onSuccess={() => navigate("/issues", { replace: true })}
/>
</div>
);
}
```
Web login stays as-is (CLI callback + Google OAuth = web-only features).
**Step 3: Run typecheck**
**Step 4: Commit**
```bash
git commit -m "feat(views): extract shared LoginPage to @multica/views/auth"
```
---
### Task 7: Verify login flow in both apps
Run: `pnpm typecheck && pnpm test`
---
## Phase 4: Extract Agents Page (1,279 lines → shared module)
### Task 8: Create `@multica/views/agents`
**Files:**
- Create: `packages/views/agents/config.ts` — statusConfig, taskStatusConfig
- Create: `packages/views/agents/components/agents-page.tsx` — main page
- Create: `packages/views/agents/components/create-agent-dialog.tsx`
- Create: `packages/views/agents/components/agent-list-item.tsx`
- Create: `packages/views/agents/components/agent-detail.tsx`
- Create: `packages/views/agents/components/tabs/instructions-tab.tsx`
- Create: `packages/views/agents/components/tabs/skills-tab.tsx`
- Create: `packages/views/agents/components/tabs/tasks-tab.tsx`
- Create: `packages/views/agents/components/tabs/settings-tab.tsx`
- Create: `packages/views/agents/components/index.ts`
- Create: `packages/views/agents/index.ts`
- Modify: `packages/views/package.json` (add `"./agents"` export)
**Key migration:** All `@/platform/*` imports → `@multica/core/*` singletons. All `@multica/ui` and `@multica/core` imports stay as-is. `@multica/views` imports become relative.
**Step 1:** Extract config → components → barrel
**Step 2:** Run `pnpm typecheck`
**Step 3:** Commit
```bash
git commit -m "feat(views): extract agents page to @multica/views/agents"
```
---
### Task 9: Wire web agents route
```typescript
// apps/web/app/(dashboard)/agents/page.tsx — 1 line replaces 1,279
export { AgentsPage as default } from "@multica/views/agents";
```
Commit: `refactor(web): replace agents page with @multica/views/agents import`
---
### Task 10: Wire desktop agents route
```typescript
// router.tsx
import { AgentsPage } from "@multica/views/agents";
{ path: "agents", element: <AgentsPage /> },
```
Commit: `feat(desktop): wire agents page from @multica/views`
---
## Phase 5: Extract Inbox Page (468 lines → shared module)
### Task 11: Create `@multica/views/inbox`
**Files:**
- Create: `packages/views/inbox/components/inbox-page.tsx`
- Create: `packages/views/inbox/components/inbox-list-item.tsx`
- Create: `packages/views/inbox/components/inbox-detail-label.tsx`
- Create: `packages/views/inbox/components/index.ts`
- Create: `packages/views/inbox/index.ts`
- Modify: `packages/views/package.json` (add `"./inbox"` export)
**Key migration:**
- `import { useSearchParams } from "next/navigation"``import { useNavigation } from "../navigation"` — use `searchParams` from adapter
- `window.history.replaceState(null, "", url)``replace(url)` from `useNavigation()`
- `@/platform/*``@multica/core/*` singletons
Commit: `feat(views): extract inbox page to @multica/views/inbox`
---
### Task 12: Wire web inbox route
```typescript
// apps/web/app/(dashboard)/inbox/page.tsx — 1 line replaces 468
export { InboxPage as default } from "@multica/views/inbox";
```
Commit: `refactor(web): replace inbox page with @multica/views/inbox import`
---
### Task 13: Wire desktop inbox route
```typescript
import { InboxPage } from "@multica/views/inbox";
{ path: "inbox", element: <InboxPage /> },
```
Commit: `feat(desktop): wire inbox page from @multica/views`
---
## Phase 6: Extract Settings Page (1,277 lines → shared module)
### Task 14: Create `@multica/views/settings`
**Files:**
- Create: `packages/views/settings/components/settings-page.tsx`
- Create: `packages/views/settings/components/account-tab.tsx`
- Create: `packages/views/settings/components/appearance-tab.tsx`
- Create: `packages/views/settings/components/tokens-tab.tsx`
- Create: `packages/views/settings/components/workspace-tab.tsx`
- Create: `packages/views/settings/components/members-tab.tsx`
- Create: `packages/views/settings/components/repositories-tab.tsx`
- Create: `packages/views/settings/components/index.ts`
- Create: `packages/views/settings/index.ts`
- Modify: `packages/views/package.json` (add `"./settings"` export)
**Key migration:** Same pattern — `@/platform/*``@multica/core/*` singletons.
Commit: `feat(views): extract settings page to @multica/views/settings`
---
### Task 15: Wire web settings route
```typescript
// apps/web/app/(dashboard)/settings/page.tsx — 1 line replaces 1,277 (page + 6 tabs)
export { SettingsPage as default } from "@multica/views/settings";
```
Delete `apps/web/app/(dashboard)/settings/_components/` (all 6 files).
Commit: `refactor(web): replace settings page with @multica/views/settings import`
---
### Task 16: Wire desktop settings route
```typescript
import { SettingsPage } from "@multica/views/settings";
{ path: "settings", element: <SettingsPage /> },
```
Commit: `feat(desktop): wire settings page from @multica/views`
---
## Phase 7: Cleanup
### Task 17: Delete dead code
- Delete `apps/desktop/src/renderer/src/pages/placeholder.tsx`
- Delete `apps/web/platform/` directory entirely (only `navigation.tsx` remains — move to `apps/web/app/` or `apps/web/lib/`)
- Delete `apps/desktop/src/renderer/src/platform/` directory (only `navigation.tsx` remains — move)
- Remove unused imports across both apps
- Clean up `apps/web/features/auth/` — only `auth-cookie.ts` should remain
Commit: `chore: delete dead platform code after monorepo extraction`
---
### Task 18: Full verification
Run: `make check`
Expected: ALL PASS
---
## Final Architecture
### Each app after extraction
```
apps/web/
├── app/
│ ├── layout.tsx # CoreProvider + WebNavigationProvider + ThemeProvider
│ ├── (auth)/login/page.tsx # Web 独有CLI callback, Google OAuth
│ ├── (dashboard)/
│ │ ├── layout.tsx # DashboardLayout + SearchCommand (10 行)
│ │ ├── issues/page.tsx # 1 行 re-export
│ │ ├── agents/page.tsx # 1 行 re-export
│ │ ├── inbox/page.tsx # 1 行 re-export
│ │ ├── settings/page.tsx # 1 行 re-export
│ │ └── ... (all 1-line)
│ └── (landing)/ # Web 独有
├── lib/
│ └── navigation.tsx # WebNavigationProvider唯一平台代码
├── features/
│ ├── auth/auth-cookie.ts # Web 独有
│ ├── landing/ # Web 独有
│ └── search/ # Web 独有
└── components/ # theme, icon, loading (少量)
apps/desktop/
├── src/main/ # Electron 主进程
├── src/preload/ # preload bridge
├── src/renderer/src/
│ ├── App.tsx # CoreProvider + RouterProvider + ThemeProvider
│ ├── router.tsx # 路由表(全部 @multica/views/*
│ ├── lib/
│ │ └── navigation.tsx # DesktopNavigationProvider唯一平台代码
│ ├── components/
│ │ ├── dashboard-shell.tsx # DashboardLayout + TitleBar (10 行)
│ │ ├── title-bar.tsx # Desktop 独有
│ │ └── multica-icon.tsx # Desktop 独有
│ └── pages/
│ └── login.tsx # LoginPage + TitleBar (10 行)
```
### 数字对比
| 指标 | 之前 | 之后 |
|------|------|------|
| Web platform 文件 | 6 个 | 1 个 (navigation.tsx) |
| Desktop platform 文件 | 7 个 | 1 个 (navigation.tsx) |
| Web agents/page.tsx | 1,279 行 | 1 行 |
| Web inbox/page.tsx | 468 行 | 1 行 |
| Web settings/ 总计 | 1,277 行 | 1 行 |
| Web sidebar | 239 行 | 0 (共享) |
| Desktop sidebar | 236 行 (重复) | 0 (共享) |
| Desktop placeholders | 3 个 | 0 |
| 共享 views 模块 | 7 个 | 12 个 |
| 两端重复代码 | ~1,500 行 | 0 行 |

View File

@@ -1,319 +0,0 @@
# Upload & Attachment Fixes Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix 5 interrelated upload/attachment issues discovered during drag-upload development (MUL-410).
**Architecture:** Three backend fixes (content-type sniffing, Content-Disposition, list API optimization) + one frontend fix (decouple description editor uploads from attachment records) + one no-code confirmation (agent file discovery paths). All changes follow existing patterns — no new abstractions.
**Tech Stack:** Go backend (Chi, sqlc, S3), Next.js frontend (TanStack Query, Tiptap editor), PostgreSQL.
---
## Overview
| # | Issue | Type | Files |
|---|-------|------|-------|
| 1 | SVG content-type sniffing | Backend bug | `server/internal/handler/file.go` |
| 2 | Content-Disposition inline vs attachment | Backend bug | `server/internal/storage/s3.go` |
| 3 | Attachment records / editor sync | Frontend fix | `packages/views/issues/components/issue-detail.tsx` |
| 4 | List Issues returns full description | Backend optimization | `server/pkg/db/queries/issue.sql`, `server/internal/handler/issue.go`, `server/pkg/db/generated/issue.sql.go` |
| 5 | Agent file discovery redundancy | No code change | Confirmed by #3 |
---
### Task 1: SVG Content-Type Extension Fallback
**Problem:** `http.DetectContentType()` returns `text/xml` for SVG files. CloudFront serves them with wrong content-type, `<img>` tags can't render.
**Files:**
- Modify: `server/internal/handler/file.go:1-16` (imports), `server/internal/handler/file.go:123` (after sniff)
**Step 1: Add extension-based content-type override map and import**
After line 16 (imports block), add `"strings"` import and a package-level `extContentTypes` map. After line 123 (`contentType := http.DetectContentType(buf[:n])`), add fallback lookup:
```go
// In imports, add "strings"
// After the imports block:
var extContentTypes = map[string]string{
".svg": "image/svg+xml",
".css": "text/css",
".js": "application/javascript",
".mjs": "application/javascript",
".json": "application/json",
".wasm": "application/wasm",
}
// After line 123 (contentType := http.DetectContentType(buf[:n])):
if ct, ok := extContentTypes[strings.ToLower(path.Ext(header.Filename))]; ok {
contentType = ct
}
```
**Step 2: Build and verify**
Run: `cd server && go build ./...`
Expected: Clean build, no errors.
**Step 3: Commit**
```bash
git add server/internal/handler/file.go
git commit -m "fix(upload): add extension-based content-type fallback for SVG and other sniff-misdetected types"
```
---
### Task 2: Content-Disposition Inline vs Attachment
**Problem:** All uploads set `Content-Disposition: inline`. Browsers display CSV/PDF inline instead of downloading.
**Files:**
- Modify: `server/internal/storage/s3.go:126-136` (Upload function)
**Step 1: Add disposition logic in Upload function**
Before the `PutObject` call (line 128), determine disposition based on content-type. Images, video, audio, and PDF stay `inline`; everything else becomes `attachment`:
```go
// Add before PutObject call:
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}
// In Upload(), after sanitizeFilename:
disposition := "attachment"
if isInlineContentType(contentType) {
disposition = "inline"
}
// Change line 133 from:
ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)),
// To:
ContentDisposition: aws.String(fmt.Sprintf(`%s; filename="%s"`, disposition, safe)),
```
**Step 2: Build and verify**
Run: `cd server && go build ./...`
Expected: Clean build.
**Step 3: Commit**
```bash
git add server/internal/storage/s3.go
git commit -m "fix(upload): use Content-Disposition attachment for non-media files"
```
---
### Task 3: Decouple Description Editor Uploads from Attachment Records
**Problem:** Description editor uploads create attachment records linked to the issue. When users delete images from the editor, attachment records become stale. The URL already lives in the markdown — attachment records are redundant for description content.
**Fix:** Description editor uploads should NOT pass `issueId`. Comment/reply uploads continue passing `issueId` (comments are not frequently edited, and agents need attachment records for comment file discovery).
**Files:**
- Modify: `packages/views/issues/components/issue-detail.tsx:339-341`
**Step 1: Remove issueId from description upload**
Change the `handleDescriptionUpload` callback (line 339-341) from:
```typescript
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file, { issueId: id }),
[uploadWithToast, id],
);
```
To:
```typescript
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file),
[uploadWithToast],
);
```
This means description image uploads will still go to S3 and return a URL (which gets embedded in the markdown), but no `attachment` DB record will be linked to the issue. The backend `UploadFile` handler already handles this — when no `issue_id` form field is sent, the attachment record is created without an issue link (or falls back to the no-workspace path for non-workspace uploads, but workspace context is still present via headers so a record IS still created, just without `issue_id` set).
**Step 2: Verify typecheck**
Run: `pnpm typecheck`
Expected: Clean.
**Step 3: Commit**
```bash
git add packages/views/issues/components/issue-detail.tsx
git commit -m "fix(editor): decouple description uploads from attachment records"
```
---
### Task 4: Omit Description from List Issues Response
**Problem:** `GET /api/issues` returns full `description` for every issue. With embedded images, descriptions contain CDN URLs making list payloads large. List pages only show titles.
**Approach:** Change `ListIssues` and `ListOpenIssues` SQL queries to select specific columns (excluding `description`, `acceptance_criteria`, `context_refs`). Regenerate sqlc. Add converter functions for the new row types. Frontend already handles `null` description gracefully.
**Files:**
- Modify: `server/pkg/db/queries/issue.sql` (lines 1-8, 60-66)
- Regenerate: `server/pkg/db/generated/issue.sql.go`
- Modify: `server/internal/handler/issue.go` (add converters, update ListIssues handler)
**Step 1: Update SQL queries**
Change `ListIssues` (lines 1-8) from `SELECT *` to explicit columns:
```sql
-- name: ListIssues :many
SELECT id, workspace_id, title, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, created_at, updated_at, number, project_id
FROM issue
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;
```
Change `ListOpenIssues` (lines 60-66) similarly:
```sql
-- name: ListOpenIssues :many
SELECT id, workspace_id, title, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, created_at, updated_at, number, project_id
FROM issue
WHERE workspace_id = $1
AND status NOT IN ('done', 'cancelled')
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
ORDER BY position ASC, created_at DESC;
```
**Step 2: Regenerate sqlc**
Run: `make sqlc`
This will generate `ListIssuesRow` and `ListOpenIssuesRow` types without `Description`, `AcceptanceCriteria`, `ContextRefs`.
**Step 3: Add converter functions in issue.go**
After `issueToResponse` (line 66), add two new converters for the list row types:
```go
func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueResponse {
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
return IssueResponse{
ID: uuidToString(i.ID),
WorkspaceID: uuidToString(i.WorkspaceID),
Number: i.Number,
Identifier: identifier,
Title: i.Title,
Status: i.Status,
Priority: i.Priority,
AssigneeType: textToPtr(i.AssigneeType),
AssigneeID: uuidToPtr(i.AssigneeID),
CreatorType: i.CreatorType,
CreatorID: uuidToString(i.CreatorID),
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
DueDate: timestampToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
}
}
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
return IssueResponse{
ID: uuidToString(i.ID),
WorkspaceID: uuidToString(i.WorkspaceID),
Number: i.Number,
Identifier: identifier,
Title: i.Title,
Status: i.Status,
Priority: i.Priority,
AssigneeType: textToPtr(i.AssigneeType),
AssigneeID: uuidToPtr(i.AssigneeID),
CreatorType: i.CreatorType,
CreatorID: uuidToString(i.CreatorID),
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
DueDate: timestampToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
}
}
```
**Step 4: Update ListIssues handler**
In `ListIssues` handler:
- Line 257: change `issueToResponse(issue, prefix)``openIssueRowToResponse(issue, prefix)`
- Line 312: change `issueToResponse(issue, prefix)``issueListRowToResponse(issue, prefix)`
**Step 5: Build and verify**
Run: `cd server && go build ./...`
Expected: Clean build.
**Frontend impact (no changes needed):**
- Board card (board-card.tsx:61): `storeProperties.description && issue.description` — short-circuits on `null`, won't render description. Correct behavior.
- Issue detail (issue-detail.tsx:210): `initialData: () => allIssues.find(...)` — the seeded issue will have `null` description, but the detail query fetches full issue with description. Brief loading state is acceptable.
**Step 6: Commit**
```bash
git add server/pkg/db/queries/issue.sql server/pkg/db/generated/issue.sql.go server/internal/handler/issue.go
git commit -m "perf(api): omit description from list issues response to reduce payload size"
```
---
### Task 5: Confirm Agent File Discovery (No Code Change)
**Confirmation:** With Task 3 implemented:
- **Description files:** Agent reads issue description markdown → finds CDN URLs directly. No attachment record needed.
- **Comment files:** Agent uses `GET /api/issues/{id}``attachments` array for issue-linked files, plus comment content markdown URLs.
- **CLI attachment download:** `multica attachment download <id>` works for files that DO have attachment records (comment uploads).
- **No redundancy:** Two paths serve different purposes — markdown URLs for inline content, attachment records for standalone files.
No code change required. This task is resolved by Task 3.
---
### Task 6: Run Full Verification
**Step 1: Run all checks**
```bash
make check
```
This runs: typecheck → TS tests → Go tests → E2E.
**Step 2: Fix any failures and re-run**
**Step 3: Final commit if any fixes needed**
---
## Execution Order
Tasks 1, 2, 3 are independent — can be parallelized.
Task 4 depends on sqlc regeneration.
Task 5 is confirmation only.
Task 6 runs after all code changes.

File diff suppressed because it is too large Load Diff

View File

@@ -1,357 +0,0 @@
# Unify Workspace Identity Resolver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix broken file uploads caused by the workspace slug refactor (v2, PR #1138/#1141), and eliminate the structural bug source that allowed it. File uploads from within a workspace on the desktop and web apps currently land in S3 without a corresponding DB attachment record — the file is orphaned and the UI never sees it.
**Architecture:** The server currently has **two independent implementations** of the same logic — extract the workspace UUID from an HTTP request. One lives in the workspace middleware (post-v2, accepts slug header → DB lookup → UUID). The other lives inside the handler package (pre-v2, only accepts UUID header/query). The v2 refactor updated the middleware one and forgot the handler one; routes that sit *outside* the workspace middleware group (notably `/api/upload-file`) still run through the stale resolver and can't translate the frontend's new `X-Workspace-Slug` header.
The root cause is duplication. The fix is to collapse both resolvers into a single shared function that middleware and handlers both delegate to, so any future change to "how do we read workspace identity" is impossible to forget. The existing middleware's resolver already has the full logic; we extract it into a package-level function and have the handler helper call it.
**Tech Stack:** Go (Chi router, sqlc, pgx).
**Non-goals:**
- No frontend changes. The frontend has been sending `X-Workspace-Slug` since v2; this plan makes the server finish accepting it everywhere.
- No route reshuffling. `/api/upload-file` stays outside `RequireWorkspaceMember` because it serves two distinct use cases (avatar upload + workspace attachment); the avatar path needs to work without a workspace context.
- No change to CLI / daemon clients. They still send `X-Workspace-ID` (UUID); the resolver keeps UUID as a fallback.
---
## Overview
| # | Change | Type | Files |
|---|--------|------|-------|
| 1 | Extract shared resolver into middleware package | Refactor | `server/internal/middleware/workspace.go` |
| 2 | Promote handler `resolveWorkspaceID` to `(h *Handler).resolveWorkspaceID` + delegate to shared | Refactor | `server/internal/handler/handler.go` |
| 3 | Rename 47 call sites from `resolveWorkspaceID(r)``h.resolveWorkspaceID(r)` | Mechanical | handler/*.go (see exhaustive list in task 3) |
| 4 | Add test for upload-file with slug header | Test | `server/internal/handler/file_test.go` |
| 5 | Add test for shared resolver | Test | `server/internal/middleware/workspace_test.go` |
| 6 | `make check` and commit | Verify | — |
---
## Background: what's broken and why
**Frontend (current, post-v2):** `ApiClient.authHeaders()` in `packages/core/api/client.ts:121` sends:
```
X-Workspace-Slug: <slug>
```
**Server middleware resolver** (`server/internal/middleware/workspace.go:53-86`, `resolveWorkspaceUUID`): accepts the slug header, looks up the slug via `queries.GetWorkspaceBySlug`, and writes the resolved UUID into the request context. Every handler behind `RequireWorkspaceMember` / `RequireWorkspaceRole` / `RequireWorkspaceMemberFromURL` sees the UUID in context and works correctly.
**Handler resolver** (`server/internal/handler/handler.go:155-165`, `resolveWorkspaceID`): a parallel implementation used by handlers that are NOT behind the workspace middleware. It only checks:
1. `middleware.WorkspaceIDFromContext(r.Context())`
2. `?workspace_id` query param
3. `X-Workspace-ID` header
Never touches slug, because it has no `*db.Queries` access (it's a package-level function, not a method).
**Impact:** `/api/upload-file` (registered at `server/cmd/server/router.go:166`, in the user-scoped group, outside workspace middleware) calls `resolveWorkspaceID(r)`, gets `""` because the frontend only sends slug, thinks "no workspace context", and silently skips the DB attachment record creation (`server/internal/handler/file.go:235-245`). The file reaches S3; the UI never sees it.
**Why `/api/upload-file` is outside workspace middleware:** it serves both "avatar upload (no workspace)" and "attachment upload (with workspace)", branching on the resolved workspace ID inside the handler. Moving it under `RequireWorkspaceMember` would break avatar uploads.
**Structural root cause:** two resolvers, same job, divergent capabilities. The duplication is what let v2 ship "mostly working" — most handlers live behind middleware, so the broken handler resolver had a low blast radius that wasn't caught in review.
---
### Task 1: Extract shared resolver into middleware package
**Problem:** The middleware's `resolveWorkspaceUUID` closure captures `*db.Queries` and can look up slugs. The handler's `resolveWorkspaceID` is a bare package-level function without queries access. We need a single implementation both sides can reuse. Putting it in the `middleware` package is fine — the `handler` package already imports `middleware`.
**Files:**
- Modify: `server/internal/middleware/workspace.go`
**Step 1: Add `ResolveWorkspaceIDFromRequest` export**
After `errWorkspaceNotFound` (around line 45), add a package-level exported function that takes `(r *http.Request, queries *db.Queries)` and returns the workspace UUID as a string (empty if none found or slug doesn't resolve).
Priority order (mirrors `resolveWorkspaceUUID`, plus a context lookup first so handlers behind middleware still get the fast path):
```go
// ResolveWorkspaceIDFromRequest returns the workspace UUID for an HTTP
// request, using the same priority order as the workspace middleware.
// Handlers behind workspace middleware get it from context (cheap); handlers
// outside middleware (e.g. /api/upload-file) still resolve slug → UUID via
// a DB lookup instead of silently falling through to "no workspace".
//
// Priority:
// 1. middleware-injected context (if the route is behind workspace middleware)
// 2. X-Workspace-Slug header → GetWorkspaceBySlug → UUID (post-refactor frontend)
// 3. ?workspace_slug query → GetWorkspaceBySlug → UUID
// 4. X-Workspace-ID header (CLI/daemon compat)
// 5. ?workspace_id query (CLI/daemon compat)
//
// Returns "" when no identifier was provided OR a slug was provided but doesn't
// resolve to any workspace. Callers that need the "slug provided but invalid"
// distinction should use the resolver inside the middleware directly.
func ResolveWorkspaceIDFromRequest(r *http.Request, queries *db.Queries) string {
if id := WorkspaceIDFromContext(r.Context()); id != "" {
return id
}
if slug := r.Header.Get("X-Workspace-Slug"); slug != "" {
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
return util.UUIDToString(ws.ID)
}
}
if slug := r.URL.Query().Get("workspace_slug"); slug != "" {
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
return util.UUIDToString(ws.ID)
}
}
if id := r.Header.Get("X-Workspace-ID"); id != "" {
return id
}
return r.URL.Query().Get("workspace_id")
}
```
**Step 2: Refactor `resolveWorkspaceUUID` to delegate**
The existing middleware closure has slightly different semantics (returns `errWorkspaceNotFound` when a slug was provided but doesn't resolve, so middleware can 404 instead of 400). Keep that, but share the resolution logic:
Leave `resolveWorkspaceUUID` as-is for now — it distinguishes "no identifier" (400) from "invalid slug" (404). `ResolveWorkspaceIDFromRequest` returns "" in both cases because handler-level callers don't need that distinction (they just check for empty).
Document in a comment near `resolveWorkspaceUUID` that it's an internal variant that preserves the error distinction for middleware gating, and point to `ResolveWorkspaceIDFromRequest` as the handler-facing API.
**Step 3: Build and verify**
```bash
cd server && go build ./...
```
Expected: clean build.
**Step 4: Commit**
```
refactor(server): extract ResolveWorkspaceIDFromRequest from middleware
Introduces a shared helper that consolidates the workspace-identity
resolution logic used by both the workspace middleware and the handler
package. No behavior change yet — callers still use the old functions.
Sets up the next commit to fix the /api/upload-file slug bug by routing
the handler-side resolver through this shared function.
```
---
### Task 2: Promote handler resolver to a method + delegate
**Problem:** The package-level `resolveWorkspaceID(r *http.Request)` in `handler.go` can't call `GetWorkspaceBySlug` because it has no queries access. Promoting it to a method on `*Handler` gives it access to `h.Queries` at no syntactic cost elsewhere.
**Files:**
- Modify: `server/internal/handler/handler.go:155-165`
**Step 1: Replace `resolveWorkspaceID` with a Handler method**
```go
// resolveWorkspaceID resolves the workspace UUID for this request.
// Delegates to middleware.ResolveWorkspaceIDFromRequest so routes inside
// and outside workspace middleware see identical resolution behavior.
//
// Returns "" when no workspace identifier was provided or a slug was
// provided but doesn't match any workspace.
func (h *Handler) resolveWorkspaceID(r *http.Request) string {
return middleware.ResolveWorkspaceIDFromRequest(r, h.Queries)
}
```
Delete the old package-level `resolveWorkspaceID` function.
**Step 2: Build — expect errors at 47 call sites**
```bash
cd server && go build ./... 2>&1 | head -60
```
Expected: `resolveWorkspaceID is not a value` or `undefined: resolveWorkspaceID` errors at each existing call site. That's the signal to run Task 3.
**Do not commit yet.** Task 2 and 3 are a single logical change; they commit together after Task 3 fixes the compile.
---
### Task 3: Rename 47 call sites to `h.resolveWorkspaceID(r)`
**Problem:** Every `resolveWorkspaceID(r)` call in the handler package now fails to compile because the function became a method. All 47 call sites are inside methods on `*Handler` (or similar receiver types that have access to `h`), so the rename is mechanical.
**Files affected** (verified via `grep -rn "resolveWorkspaceID" server/internal/handler/`):
- `server/internal/handler/handler.go:275, 365, 388` (3 sites)
- `server/internal/handler/issue.go:447, 559, 731, 783, 1294, 1476` (6 sites)
- `server/internal/handler/activity.go:133` (1 site)
- `server/internal/handler/autopilot.go:178, 203, 255, 306, 386, 414, 490, 578, 615, 662` (10 sites)
- `server/internal/handler/project.go:80, 127, 150, 192, 273, 430` (6 sites)
- `server/internal/handler/comment.go:443, 510` (2 sites)
- `server/internal/handler/runtime.go:207, 247, 296` (3 sites)
- `server/internal/handler/pin.go:59, 105, 175, 202` (4 sites)
- `server/internal/handler/reaction.go:43, 110` (2 sites)
- `server/internal/handler/skill.go:126, 146, 187, 384, 815` (5 sites)
- `server/internal/handler/agent.go:158, 254` (2 sites)
- `server/internal/handler/file.go:83, 115, 282, 306` (4 sites)
Total: 48 (the resolver declaration itself + 47 callers).
**Step 1: Mechanical rename**
For each file above, change every `resolveWorkspaceID(r)` to `h.resolveWorkspaceID(r)`. In the one case in `file.go:83` inside `groupAttachments`, the receiver is already `*Handler`, so the method is accessible.
**Semantic check:** all 47 call sites are on methods with an `h *Handler` receiver (verifiable by scrolling up a few lines from each grep match). If any call site is inside a non-method function, that site needs to either take `*Handler` as a parameter or be skipped from this rename. Spot-check three sites before doing the rename.
**Step 2: Build**
```bash
cd server && go build ./...
```
Expected: clean build.
**Step 3: Run Go tests**
```bash
cd server && go test ./...
```
Expected: all pass. The 46 call sites behind workspace middleware hit the context branch (identical behavior to before). Only `UploadFile` gains new capability (slug resolution); it wasn't tested before, will be covered in Task 4.
**Step 4: Commit**
```
fix(server): resolve X-Workspace-Slug in /api/upload-file and other middleware-less handlers
The v2 workspace URL refactor updated the workspace middleware to accept
X-Workspace-Slug but left the handler-package resolveWorkspaceID helper
(used by handlers outside the middleware group) stuck on X-Workspace-ID.
The frontend switched to the slug header, so /api/upload-file was
receiving a slug it couldn't translate to a UUID, silently falling
through to the avatar-upload branch and skipping DB attachment record
creation — files were landing in S3 with no database reference.
Promote resolveWorkspaceID to a Handler method and delegate to the new
middleware.ResolveWorkspaceIDFromRequest so middleware-behind and
middleware-outside handlers share the same resolution logic. The 46
call sites that live inside the workspace middleware group are
unaffected (context lookup still wins). /api/upload-file now correctly
recognizes slug requests and creates the attachment record.
Fixes: missing DB attachment rows for files uploaded since v2 (#1141)
```
---
### Task 4: Add handler test for upload-file with slug header
**Problem:** The bug manifested exactly because there was no test covering the "upload-file with only a slug header" code path. Prevent regression.
**Files:**
- Modify: `server/internal/handler/file_test.go` (or create if absent)
**Step 1: Locate existing upload-file test infrastructure**
```bash
grep -rn "UploadFile\|upload-file" server/internal/handler/*_test.go
```
If there's an existing upload-file test, add a new test case alongside it. If not, scaffold one using the same `handler_test.go` fixture pattern (`testWorkspaceID`, `testUserID`, seeded workspace).
**Step 2: Write the test**
Test name: `TestUploadFile_ResolvesWorkspaceViaSlugHeader`.
Flow:
1. Seed a workspace with a known slug and the default test user as a member.
2. POST a multipart form to `/api/upload-file` with an `issue_id` field referencing a seeded issue, with only `X-Workspace-Slug: <slug>` in headers (no `X-Workspace-ID`).
3. Assert response is 200.
4. Assert a DB row exists in `attachments` with the expected `workspace_id`, `uploader_id`, `issue_id`, and `filename`.
Anti-regression: also add `TestUploadFile_ResolvesWorkspaceViaIDHeaderStill` to confirm legacy `X-Workspace-ID` header still works (CLI / daemon compat).
**Step 3: Run the new test**
```bash
cd server && go test ./internal/handler/ -run UploadFile
```
Expected: both pass.
**Step 4: Commit**
```
test(server): cover upload-file slug and UUID header resolution
Regression test for the v2 refactor bug: uploads from the frontend
(which sends X-Workspace-Slug) now reach the workspace-aware branch
and create attachment records.
```
---
### Task 5: Add unit test for the shared resolver
**Problem:** The shared function will be the single point through which all workspace identity resolution flows. It deserves table-driven test coverage for each priority level.
**Files:**
- Create or modify: `server/internal/middleware/workspace_test.go`
**Step 1: Table test**
Cases to cover:
- Context UUID present → returns context UUID, ignores headers/query
- Only `X-Workspace-Slug` → DB lookup succeeds → returns UUID
- Only `X-Workspace-Slug` → DB lookup fails → returns ""
- Only `?workspace_slug` → DB lookup succeeds → returns UUID
- Only `X-Workspace-ID` → returns UUID
- Only `?workspace_id` → returns UUID
- Slug header + UUID header both present → slug wins (frontend priority)
- Nothing → returns ""
**Step 2: Run**
```bash
cd server && go test ./internal/middleware/ -run ResolveWorkspaceIDFromRequest
```
Expected: all cases pass.
**Step 3: Commit**
```
test(server): table-driven coverage for ResolveWorkspaceIDFromRequest
Pins down the priority order (context > slug header > slug query >
UUID header > UUID query) so future changes can't silently diverge.
```
---
### Task 6: Full verification
**Step 1: `make check`**
```bash
make check
```
Expected: typecheck, TS tests, Go tests, E2E (if backend+frontend up) all green.
**Step 2: Manual smoke test**
1. Start desktop dev environment.
2. Open an issue, attach a file via drag-and-drop or the file picker.
3. Refresh the issue. The attachment should appear in the attachments list.
Before this fix: attachment silently disappears on refresh (file is in S3, DB has no row).
**Step 3: Open PR**
Branch name: `fix/unify-workspace-identity-resolver`.
Title: `fix(server): resolve X-Workspace-Slug in middleware-less handlers`
Body should:
- Link to the symptom PR (v2 refactor #1141) and reference that it's a latent follow-up.
- Describe the structural change (two resolvers → one).
- Note that 46 of 47 call sites see zero behavior change (context branch wins); only `/api/upload-file` gains capability.
---
## Risk / blast radius
**Low risk.** The 46 middleware-protected callers hit the context branch in `ResolveWorkspaceIDFromRequest` identically to how they hit `WorkspaceIDFromContext` before — zero semantic change. The only new code path exercised in production is the slug-header branch for `/api/upload-file`, which is already exercised by every other slug-header-carrying request (just via the middleware's version of the same logic). Task 4 and 5 lock the behavior down with tests.
## Rollback plan
If a regression surfaces after deploy, revert the single commit from Task 3. `ResolveWorkspaceIDFromRequest` and the Handler method remain but are unused — harmless dead code until the next attempt.

983
docs/product-overview.md Normal file
View File

@@ -0,0 +1,983 @@
# Multica 产品全景文档
> **文档说明**
>
> 这份文档的目的是:**让任何没有写过代码的新同事,在 30 分钟内完全理解 Multica 这个产品到底有哪些功能、每个功能在整体中处于什么位置、一个功能和另一个功能如何协同**。
>
> 它的受众包括:
>
> - **新加入的工程师 / 产品 / 设计 / 运营**——用它做 onboarding 的第一份材料
> - **产品介绍工作**——需要对外讲解 Multica 时的事实基础
> - **文案工作者**——写交互文案、营销文案、帮助文档时,需要知道某个词(比如 "Skill"、"Runtime"、"Autopilot")在产品体系里代表什么
> - **任何需要在修改某个局部前,先理解它与整体关系的人**
>
> 它**不是**开发者文档、架构决策记录ADR、或者销售话术。它是**功能事实的汇总**——每一条描述都能在代码、schema 或 API 里找到对应。
>
> 文档基于对整个 monoreposerver、apps、packages、migrations、daemon、CLI的系统性调研生成数据截止日期 2026-04-21。
---
## 目录
1. [Multica 是什么](#1-multica-是什么)
2. [核心概念词典](#2-核心概念词典)
3. [功能全景(按模块)](#3-功能全景按模块)
- 3.1 [Workspace 工作区](#31-workspace-工作区)
- 3.2 [Issue 议题管理](#32-issue-议题管理)
- 3.3 [Project 项目](#33-project-项目)
- 3.4 [Agent 智能体](#34-agent-智能体)
- 3.5 [Runtime 运行时 & Daemon 守护进程](#35-runtime-运行时--daemon-守护进程)
- 3.6 [Skill 技能](#36-skill-技能)
- 3.7 [Autopilot 自动驾驶](#37-autopilot-自动驾驶)
- 3.8 [Chat 对话](#38-chat-对话)
- 3.9 [Inbox 收件箱与通知](#39-inbox-收件箱与通知)
- 3.10 [成员、邀请与权限](#310-成员邀请与权限)
- 3.11 [搜索与命令面板](#311-搜索与命令面板)
- 3.12 [认证、登录与 Onboarding](#312-认证登录与-onboarding)
- 3.13 [设置与个人资料](#313-设置与个人资料)
- 3.14 [CLI 命令行工具](#314-cli-命令行工具)
4. [系统架构全景](#4-系统架构全景)
5. [产品地图(全部路由)](#5-产品地图全部路由)
6. [跨平台差异Web vs 桌面](#6-跨平台差异web-vs-桌面)
7. [附录:关键数据表速查](#7-附录关键数据表速查)
---
## 1. Multica 是什么
### 一句话定位
**Multica 把编码智能体变成真正的团队成员。**
像给同事分配任务一样,把一个 issue 指派给一个 agent它会自己认领、写代码、汇报进度、更新状态——不需要你一直守着。
### 解决的问题
传统方式用 AI coding agent 的痛点:
- 每次都要复制粘贴 prompt
- 必须盯着终端,看它跑不跑得完
- 没有跨任务的记忆,每次都从零开始
- 多个 agent 同时工作时,没有一个"看板"能看到全局
Multica 做的事:
- Agent 和人**共用同一个任务看板**issue board
- Agent **有 profile**,会出现在 assignee 下拉里、会在评论区发言、会自己创建 issue
- 同一个 (agent, issue) 的多轮对话**自动恢复会话**——上一次的上下文、工作目录都保留
- **Skill 系统**让历史上解决过的问题沉淀成可复用的能力
- **Autopilot** 让 agent 按定时规则自动开工(比如每天早上 9 点做 bug triage
### 定位一句话版本
> Multica 不是一个 AI 工具,而是一个**人 + AI 协作的任务管理平台**。agent 是一等公民,和人在同一个工作流里。
### 部署形态
- **云版本Multica Cloud**官方托管服务agent 通过你本地跑的 daemon 执行
- **自托管Self-Host**:完整后端可以部署在自己的服务器
- **客户端**Next.js web 版 + Electron 桌面版(两端体验基本一致,桌面独有:多标签、原生托盘、自动更新)
### 支持的 Coding Agent
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
---
## 2. 核心概念词典
**理解这些名词是理解产品的前提。每个概念的定义都严格对应数据库表。**
| 概念 | 定义 | 映射的数据表 |
|------|------|-------------|
| **User 用户** | 一个人类账号,可以登录,属于多个 workspace | `user` |
| **Workspace 工作区** | 一切资源的容器。issue、agent、project、skill 全部隔离在 workspace 里。就是 Linear/Notion 里的 workspace/team 概念 | `workspace` |
| **Member 成员** | 用户在某个 workspace 里的身份。一个用户在不同 workspace 可以有不同角色owner/admin/member | `member` |
| **Agent 智能体** | 可被指派任务的 AI 工作者。有 profile名字、头像、说明、会指定 runtime 和 provider、可以配自定义 prompt 和技能 | `agent` |
| **Runtime 运行时** | Agent 实际跑在哪里的**执行环境**。可以是用户本地机器(通过 daemon或云端实例。**一个 runtime = 一台可以跑 agent 的机器** | `agent_runtime` |
| **Daemon 守护进程** | 用户本地运行的后台程序,自动发现已安装的 coding CLI 并注册为 runtime然后不停轮询 server 认领任务 | (进程,不是表) |
| **Issue 议题** | 一个工作单元——任务、bug、feature。最核心的产品对象。可以分配给人或 agent | `issue` |
| **Comment 评论** | Issue 下的讨论回复。人和 agent 都能发。在评论里 `@某个 agent` 会自动触发这个 agent 的新任务 | `comment` |
| **Task 任务** | Agent 执行一次 issue 所产生的一次运行。本质是"一次 agent 跑起来的会话"。队列化执行 | `agent_task_queue` |
| **Skill 技能** | 工作区级别的可复用说明文档。作用是给 agent 提供"怎么做某件事"的上下文。Agent 开跑时会把挂载的 skill 内容注入到工作目录让 CLI 能读到 | `skill`, `skill_file`, `agent_skill` |
| **Project 项目** | 议题的高层归属,类似"里程碑"或"版本"。issue 可以归属到 project | `project` |
| **Autopilot 自动驾驶** | 定时或被触发的自动化规则。按 cron 或 webhook 触发,自动创建 issue 并分配给 agent | `autopilot`, `autopilot_trigger`, `autopilot_run` |
| **Chat 对话** | 用户和 agent 的持久化多轮对话。不依附于 issue | `chat_session`, `chat_message` |
| **Inbox 收件箱** | 个人通知中心。被 @、被分配、订阅的 issue 有更新都会进这里 | `inbox_item` |
| **Subscriber 订阅者** | 谁关注某个 issue。被分配、被 @、评论过都会自动订阅。订阅者会收到 inbox 通知 | `issue_subscriber` |
| **Activity 活动 / Timeline 时间线** | 所有关键动作的审计记录。issue 详情页的"时间线"就是这个表的数据 | `activity_log` |
| **Pin 固定** | 个人侧边栏快捷方式,把常用的 issue/project 置顶 | `pinned_item` |
| **Reaction 反应** | Issue 或评论上的 emoji 反应,跟 GitHub/Slack 一样 | `issue_reaction`, `comment_reaction` |
| **Attachment 附件** | Issue 或评论的文件上传,支持 S3/CloudFront 或本地存储 | `attachment` |
| **Personal Access Token (PAT)** | 用户级 API tokenCLI 和自动化用。`mul_` 前缀 | `personal_access_token` |
| **Daemon Token** | 单 workspace 单 daemon 的 token。`mdt_` 前缀,比 PAT 权限范围更小 | `daemon_token` |
| **Session Resumption 会话恢复** | 同一对 (agent, issue) 的下一次任务会自动复用上次 Claude Code 的 `session_id` 和工作目录——历史对话、文件状态都保留 | `agent_task_queue.session_id`, `.work_dir` |
| **MCP (Model Context Protocol)** | Anthropic 提出的协议,让 agent 通过标准接口调用外部工具。每个 agent 可配自己的 MCP server 列表 | `agent.mcp_config` (JSONB) |
| **Workspace Context 工作区上下文** | 工作区级别的 agent 系统提示词。所有该工作区的 agent 都会感知到它 | `workspace.context` |
| **Polymorphic Actor 多态行动者** | 设计范式:几乎所有"谁做了什么"的字段都是 `actor_type` (`member`/`agent`) + `actor_id`。这就是为什么 agent 能像人一样创建 issue、发评论、被订阅 | 贯穿所有表 |
---
## 3. 功能全景(按模块)
### 3.1 Workspace 工作区
> **角色**一切的容器。Multica 的多租户边界。
#### 功能
- **多工作区**:一个用户可以属于多个 workspace每个 workspace 完全隔离issue、agent、skill、成员都独立
- **创建工作区**:只需要一个名字;自动生成 slugURL 中使用的短 ID
- **切换工作区**:侧边栏下拉;桌面端每个工作区有独立的标签组。
- **离开工作区**:非 owner 成员可自行离开。
- **删除工作区**:只有 owner 可以,硬删除+级联。
- **Workspace 设置**名称、slug、描述、**Workspace Context**(给该工作区所有 agent 的统一系统提示)、**仓库列表**workspace 允许 agent 访问的 Git 仓库 URL 白名单)。
- **Workspace 头像 / issue 前缀**:每个工作区可以有自己的 issue 编号前缀(如 `ACME-42`)。
#### 产品里的位置
Workspace 不是一个功能,而是**所有功能的坐标系**。URL 的形态永远是 `/{workspace-slug}/...`API 请求永远带 `X-Workspace-Slug` 头。一个 issue、一个 agent、一个 skill脱离了 workspace 就没有意义。
#### 对应表
`workspace`, `member`, `workspace_invitation`
---
### 3.2 Issue 议题管理
> **角色**Multica 的核心工作对象。
Issue 对应的概念在 Linear 叫 Issue、在 Jira 叫 Ticket、在 GitHub 叫 Issue——就是一个任务单元。Multica 的特色在于**issue 可以分配给 agent和分配给人完全对等**。
#### 核心字段
- 标题、描述Tiptap 富文本)、状态、优先级
- 编号(自动递增,带 workspace 前缀)
- **Assignee可以是 member 或 agent**
- **Creator可以是 member 或 agent**——agent 也能创建 issue
- Parent issue用来做子任务
- Project归属的项目
- Due date截止日期
- Labels多对多标签
- Dependencies依赖/阻塞关系)
- Acceptance criteria验收标准JSONB
- Origin如果是 autopilot 创建的,会记录来源 autopilot run
#### 视图
- **List 列表视图**:表格形式,可按 status/priority/assignee/creator/project 过滤、按名称/优先级/截止日/手动位置排序;支持开放和已完成分页。
- **Board 看板视图**Kanban按状态分列支持拖拽拖动会自动切到"手动排序"模式)。
- **My Issues 我的议题**:专属视图,三个 scope分配给我 / 我创建的 / 我的 agent 负责的。
#### 交互
- **快速创建**:侧边栏单行快速创建、或弹窗富文本创建(支持草稿本地持久化)
- **批量操作**:多选后批量改 status/priority/assignee/删除
- **子 issue**:父 issue 显示子任务完成比例圆环
- **订阅subscribe**:默认 creator、assignee、被 @ 的人会自动订阅
- **Reaction**issue 和评论都能加 emoji 反应
- **Pin 固定**:把 issue 置顶到侧边栏快捷栏
- **复制链接 / 快捷键跳转Cmd+K**
- **Timeline 时间线**:所有关键动作(状态变更、指派变更、评论)按时间顺序展示,混合 `activity_log` + `comment` 两类记录
#### 评论与讨论
- Tiptap 富文本编辑器,支持 `@` 提到 member 或 agent
- 嵌套回复(一层)
- emoji 反应
- **@agent 触发任务**:在评论里提到某个 agent会自动生成一个新的 agent task让它来回复/处理
#### 附件
- 拖拽上传或按钮上传
- 图片内联预览
- 存储后端S3/CloudFront 或本地磁盘(自托管)
#### 产品里的位置
Issue 是**所有工作流的载体**
- Agent 通过"被分配到 issue"获得任务
- Autopilot 通过"创建 issue"来触发 agent
- 评论通过"@agent" 追加任务
- Inbox 通知围绕 issue 生成
#### 对应表
`issue`, `comment`, `issue_label`, `issue_to_label`, `issue_dependency`, `issue_subscriber`, `issue_reaction`, `comment_reaction`, `attachment`, `activity_log`, `pinned_item`
---
### 3.3 Project 项目
> **角色**:多个 issue 的高层容器,类似 Linear 的 Project、Jira 的 Epic。
#### 功能
- 标题、描述、图标emoji 或标识符)
- 状态:`planned` / `in_progress` / `paused` / `completed` / `cancelled`
- 优先级urgent / high / medium / low / none
- **Lead 负责人**:可以是 member 或 agent跟 issue 的 assignee 一样是多态)
- 详情页展示项目内的所有 issue
- 支持搜索项目
#### 产品里的位置
Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于任何 project但如果属于会在列表页的筛选、侧边栏导航、面包屑里集中展示。
#### 对应表
`project`
---
### 3.4 Agent 智能体
> **角色**AI 工作者。Multica 最独特的对象。
一个 Agent 不是一个"AI 模型",而是一个**带配置的工作者身份**。它有名字、头像、个人描述、说明书(系统提示词)、绑定的运行时、挂载的技能。在 UI 上它和人一样会出现在 assignee 下拉、评论作者、订阅者列表里。
#### 配置字段
- **基本信息**:名字、描述、头像(自动生成)
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
- **Instructions 说明书**agent 的系统提示词("你是一个资深工程师..."
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY``ANTHROPIC_BASE_URL``CLAUDE_CODE_USE_BEDROCK`
- **Custom Args**:附加给 CLI 的启动参数(如 `--model`, `--thinking`
- **MCP Config**Model Context Protocol 服务器列表(让 agent 有额外工具能力)
- **Max Concurrent Tasks**:同时最多跑几个任务
- **Skills**:关联多个 skill见 3.6
- **Visibility**`workspace`(工作区可见)或 `private`(仅创建者可见)
#### 状态
- `idle` / `working` / `blocked` / `error` / `offline`——由 runtime heartbeat 决定
- 可以被 archive软删除
#### 交互
-**Settings → Agents** 页面创建、编辑、归档
- 在 issue 的 assignee 下拉里选择
- 在评论里 `@agent` 触发
- 在 chat 面板里直接聊
#### 产品里的位置
Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent 干活"展开:
- Issue 通过分配触发 agent
- Skill 通过挂载赋能 agent
- Runtime 提供 agent 的运行环境
- Autopilot 调度 agent 自动开工
- Chat 提供 agent 的对话界面
#### 对应表
`agent`, `agent_skill`
---
### 3.5 Runtime 运行时 & Daemon 守护进程
> **角色**Agent 真正跑起来的物理/虚拟机器。
这是 Multica **分布式执行架构**的核心设计:**agent 不在 server 上运行,而在用户自己的机器上运行**。Server 只做任务调度、状态同步、数据存储。
#### Daemon 是什么
`multica` CLI 在用户的机器上启动一个后台进程macOS launchd / Linux systemd / Windows 服务风格),它:
1. **自动探测** `$PATH` 上安装的 coding CLI`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`
2. 向 server **注册** 为一组 runtime一个 CLI = 一个 runtime
3. 每 3 秒 **轮询** 一次 server有任务就认领
4. 每 15 秒 **心跳**keepalive报告自己还活着
5. 认领任务后,在本机的隔离工作目录里**启动 agent CLI**,把 agent 的输出流**实时推回 server**
6. 任务完成后上报结果、token 用量、session id 和工作目录(用于下次恢复)
#### Runtime 展示
**Settings → Runtimes** 页面可以看到:
- 每个 runtime 的名字、提供方图标、owner谁的机器、状态指示在线/离线、last seen 时间
- Ping 诊断:手动戳一下看响应
- Usage 用量:近期的 token 消耗统计
- Activity任务活动情况
- CLI 安装指引(自托管模式下)
- 桌面端独有:**本地 daemon 卡片**,显示本机 daemon 状态、可一键重启
#### Runtime 的生命周期
- **注册**daemon 启动时 POST `/api/daemon/register` 得到 runtime ID
- **在线**15 秒一次心跳
- **离线**:如果 server 45 秒没收到心跳,把 runtime 标记为离线server 后台 sweeper 每 30 秒巡检)
- **孤儿任务回收**:超过 5 分钟还在 dispatched 或超过 2.5 小时还在 running 的任务sweeper 会把它标记为失败
- **长期离线 GC**7 天没心跳且没活跃 agent 的 runtime 会被回收
#### CLI 与 Daemon 的关系
| 命令 | 说明 |
|------|------|
| `multica setup` | 一键配置:填 URL + 登录 + 启动 daemon |
| `multica login` | 浏览器打开 OAuth 登录,保存 90 天 PAT 到 `~/.multica/config.json` |
| `multica login --token <pat>` | 无头登录SSH/CI |
| `multica daemon start` | 后台启动 daemon写 PID 到 `~/.multica/daemon.pid`,日志到 `~/.multica/daemon.log` |
| `multica daemon stop` | 发 SIGTERM优雅关闭等待进行中的任务完成超时 30s |
| `multica daemon status` | 打印 daemon 状态、探测到的 agent、watch 中的 workspace |
| `multica daemon logs -f` | 实时跟随日志 |
| `multica daemon start --profile <name>` | 启动独立配置的 daemon用于多环境比如同时连 staging 和生产) |
#### 安全边界
- 每个任务一个**独立工作目录** `~/multica_workspaces/{ws}/{task_short_id}/workdir/`
- 环境变量**过滤**:阻止 agent 覆盖 daemon 的认证变量(`MULTICA_TOKEN` 等)
- 仓库访问**白名单**agent 只能 checkout workspace 配置的仓库
- Codex 有**版本相关的 sandbox 策略**
#### 产品里的位置
Runtime 是让"给 agent 分配任务"这件事**能真正发生**的基础设施。没有 runtime所有 agent 就是空壳。用户第一次 onboarding 时必须至少有一个 runtime 在线,否则 agent 没法干活。
#### 对应表
`agent_runtime`, `daemon_token`, `daemon_pairing_session`(弃用中), `daemon_connection`(弃用中), `runtime_usage`
---
### 3.6 Skill 技能
> **角色**:让 agent "学会"某种工作方式的可复用说明文档。
Skill 是一组 Markdown 文档 + 配套文件。它**不是代码****不是 prompt 模板**,而是**给 agent CLI 读的说明**。
#### 数据形态
```
skill
├─ name: "react-patterns"
├─ description: "Common React patterns and best practices"
├─ content: "## Overview\n..." # 主要说明文档
└─ files:
├─ examples/hooks.md
└─ examples/useState.jsx
```
#### 它怎么工作
1. **创建**:在 **Settings → Skills** 页面创建或从 URL 导入(如 clawhub.ai、skills.sh
2. **挂载**:给某个 agent 勾选要用的 skill
3. **注入**:当 agent 认领任务时daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**
- Claude Code → `.claude/skills/{name}/SKILL.md`
- Codex → `CODEX_HOME/skills/{name}/`
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
- Pi → `.pi/agent/skills/{name}/SKILL.md`
- Cursor → `.cursor/skills/{name}/SKILL.md`
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
- 其他 → `.agent_context/skills/{name}/SKILL.md`
4. **使用**agent CLI 自己按照 provider 约定发现并读取这些文件
> 💡 **Skill 是静态的**——不是 AI 生成的,也不会随执行变化。它是人写的经验文档。未来可能扩展成"AI 从历史任务中沉淀技能",但当前版本不是。
#### CLI 对应命令
```bash
multica skill list
multica skill get <id>
multica skill create --title ...
multica skill import --url https://...
multica skill files upsert <skill-id> --path ...
```
#### 产品里的位置
Skill 是 Multica 区别于"每次都要写长 prompt"的关键机制。它让团队的专业知识**沉淀成可复用的组件**,绑在 agent 上就生效——就像给员工写的 SOP/playbook。
从架构角度skill 不参与执行逻辑,只参与**上下文注入**。它在整个任务生命周期里只出现一次——在 daemon 启动 CLI 之前的环境准备阶段。
#### 对应表
`skill`, `skill_file`, `agent_skill`
---
### 3.7 Autopilot 自动驾驶
> **角色**:让 agent 在没人触发的时候也能自己开工的调度器。
Autopilot 解决的问题:很多工作是**周期性**的——每天早上的 bug triage、每周的依赖审计、每月的安全扫描。人手动触发太烦Autopilot 是规则化自动触发。
#### 数据形态
```
autopilot
├─ title, description
├─ assignee: <agent_id> # 指定哪个 agent 跑
├─ execution_mode: create_issue | run_only
├─ issue_title_template: "Daily triage - {{date}}"
├─ concurrency_policy: skip | queue | replace
└─ triggers (多个):
├─ kind: schedule | webhook | api
├─ cron_expression
├─ timezone
└─ webhook_token
```
#### 两种执行模式
- **`create_issue`(默认)**:触发时先创建一个新 issue标题用 `issue_title_template` 渲染),再把 issue 分配给 agent走正常 agent 任务流程
- **`run_only`**:直接创建 task不关联 issue适合"只执行不留下 ticket"的场景,比如每小时检查某状态)
#### 三种触发方式
- **Schedulecron**server 后台每 30 秒扫一次 `autopilot_trigger`,到点的触发出去
- **Webhook**:给出一个带 `webhook_token` 的 URL外部 POST 即可触发
- **API / Manual**UI 上点"立即运行"按钮,或用 CLI `multica autopilot trigger <id>`
#### 并发策略
- `skip`:同一个 autopilot 上一次还没跑完,跳过这次(去重)
- `queue`:排队等上一次跑完
- `replace`:中止上一次,换成这次
#### 运行记录
每次触发都在 `autopilot_run` 里留一条记录:`pending → issue_created → running → completed/failed/skipped`。在 UI 的 autopilot 详情页可以看全部历史。
#### 内置模板
产品提供一些现成的 autopilot 模板,一键创建:
- Daily news digest每天 9:00
- PR review reminder工作日 10:00
- Bug triage工作日 9:00
- Weekly progress report每周 17:00
- Dependency audit每周 10:00
- Security scan每周 02:00
#### 产品里的位置
Autopilot 让 Multica 从"你分配 → agent 做"升级到"agent 自己发起工作"。配合 `run_only` 模式,甚至可以在没有 issue 的前提下跑定时任务。Issue 上的 `origin_type=autopilot` + `origin_id` 字段留下了"这个 issue 是哪个 autopilot run 创建的"的追溯链。
#### 对应表
`autopilot`, `autopilot_trigger`, `autopilot_run`
---
### 3.8 Chat 对话
> **角色**:用户和 agent 的持久多轮对话界面,不依附于 issue。
有时候你不想为了和 agent 说一句话就开一个 issue。Chat 就是为这种"轻量对话"准备的——像 ChatGPT 的对话界面,但是你在和你工作区的某个 agent 对话。
#### 功能
- **创建会话**:选一个 agent 开始
- **消息列表**:支持 Markdown 渲染、代码块高亮
- **发送消息**:消息会被 queue 成一个 taskagent 执行后把响应作为消息写回
- **流式响应**:通过 WebSocket 实时推送
- **未读跟踪**`unread_since` 字段记录第一条未读消息的时间戳
- **归档**:把旧会话移出活跃列表
- **Session 复用**:同一个 chat session 下的多轮消息会复用底层 CLI 的 `session_id`Claude Code 能保留对话上下文)
#### 和 Issue 评论的区别
| | Chat | Issue 评论 |
|---|---|---|
| 上下文载体 | 独立 sessionchat_session | 某个 issue |
| 是否公开 | 个人和 agent 对话(私有) | 工作区所有成员可见 |
| 触发 agent | 每条 user 消息都触发 | 需要 `@agent` |
| 用途 | 探索、提问、一次性任务 | 和 issue 强绑定的工作推进 |
#### 产品里的位置
Chat 填补了"不够正式到需要开 issue、但又需要持久化"的对话空白。同时也是体验上更像常规聊天软件的入口。
#### 对应表
`chat_session`, `chat_message`;底层执行仍走 `agent_task_queue``chat_session_id` 字段区分)
---
### 3.9 Inbox 收件箱与通知
> **角色**:每个人的个人通知中心。
#### 数据形态
`inbox_item` 是推给特定"recipient"的条目:
- recipient_type = `member``agent`agent 也能有 inbox
- typee.g. `issue_assigned`, `comment_mention`, `task_completed`, `invitation_created`
- severity`action_required` / `attention` / `info`
- 关联的 issue如果有
- read / archived 状态
#### 通知触发场景
- Issue 被分配给你
- 被 @ 提到
- 订阅的 issue 状态变化
- 订阅的 issue 有新评论
- 工作区邀请
- 你的 agent 任务完成/失败
#### 订阅机制(自动)
Server 的 subscriber listener 自动把以下人加入 `issue_subscriber`
- issue creator
- 当前 assignee变更会同步更新
- 评论里被 @ 的人
- 手动订阅的人
#### UI
- **Inbox 页面**:两栏布局,左边列表 + 右边 issue 详情
- **批量操作**:全部标记已读 / 仅归档已读 / 归档已完成 issue 的通知
- **徽标**:侧边栏导航上显示未读数
- **WebSocket 推送**:新 inbox 条目实时到达(`inbox:new` 事件只发给目标用户)
#### 产品里的位置
Inbox 是"主动注意力系统",让用户不必一直盯着看板也知道哪些事要自己处理。
#### 对应表
`inbox_item`, `issue_subscriber`
---
### 3.10 成员、邀请与权限
#### 角色体系
| 角色 | 权限 |
|------|------|
| **Owner** | 全部;唯一能删除工作区的角色 |
| **Admin** | 管理成员、管理设置;不能删工作区,不能移除其他 admin |
| **Member** | 创建 issue、评论、自我分配、使用 agent |
#### 邀请流程
- Admin 在 **Settings → Members** 输入邮箱邀请
- Server 生成 `workspace_invitation` 记录7 天过期)
- 发送邮件Resend 集成,未配置时打到 stderr
- 被邀请人收到邀请:如果已有账号,会出现在个人 Inbox如果没账号邮件里有注册链接
- 接受 / 拒绝 / 过期
#### UI
- 成员列表:头像、邮箱、角色徽章、操作菜单(改角色、移除)
- 待处理邀请列表:可 resend、revoke
- Invite 接受页面(`/invite/[id]`):展示工作区信息、接受/拒绝按钮
#### 邀请接受的桌面特殊处理
桌面端的 `multica://invite/{id}` 深链接**不是走路由**,而是触发 `WindowOverlay`——共享视图组件 `InvitePage` 装在原生窗口覆盖层里,保证拖拽移动窗口等原生体验。
#### 产品里的位置
成员管理是**一切协作的前提**。但在 Multica 里它有一个独特之处:成员系统也管 agent。之所以要有 `assignee_type` 区分 member 和 agent就是为了让两者在同一套 API 里表达"谁可以被分配"。
#### 对应表
`member`, `workspace_invitation`
---
### 3.11 搜索与命令面板
#### 命令面板Cmd+K
全局搜索入口,覆盖:
- **Issues**(按标题、编号匹配)
- **Projects**(按名称匹配)
- **Workspaces**(按名称匹配,用于快速切换)
- **Navigation**跳转到设置、runtimes、skills 等)
- **Actions**(新建 issue、新建 project、切换主题
- **Recent Issues**(最近访问过的,自动记录)
#### 列表过滤
Issue 列表、project 列表、inbox 等都有本地 filter chips 和 search input。
#### 全文搜索
`GET /api/issues/search` 支持对 issue 的标题、描述、评论内容做全文搜索,返回命中片段。
> **当前没有基于向量的语义搜索**——产品宣传是 AI-native但没有用 pgvector。Schema 里也没启用向量扩展。未来可能扩展。
#### 产品里的位置
Cmd+K 是 keyboard-first 用户Linear-style的主要导航方式比点击侧边栏更快。
---
### 3.12 认证、登录与 Onboarding
#### 登录方式
- **邮箱验证码Magic Link 风格)**:输入邮箱 → 收 6 位验证码 → 输入验证码登录
- **Google OAuth**:一键 Google 登录
- **PATCLI**:用户在 Settings → API Tokens 里生成的 tokenCLI/脚本场景
#### Onboarding 流程(正在重设计中)
位于 `packages/views/onboarding/``apps/web/app/(auth)/onboarding/`
经典 5 步:
1. **Welcome** — 欢迎页
2. **Workspace** — 创建工作区(或跳过,如果已有)
3. **Runtime** — 展示可用的 runtime 和 CLI 安装指引
4. **Agent** — 创建第一个 agent需要有 runtime
5. **Complete** — 展示创建好的 workspace 和 agent跳转到 dashboard
#### 邀请接受Zero-workspace
如果新用户是被邀请进来的(还没有自己的 workspace接受邀请后直接进入该工作区跳过 onboarding。
#### 认证后的跳转规则
- 已登录且有至少一个 workspace跳到 `/{slug}/issues`
- 已登录但没有 workspace进入 `/workspaces/new` 或 onboarding
- 未登录:跳到 `/login`
#### Signup 限流
Server 支持:
- `ALLOW_SIGNUP=false` 关闭注册
- `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 白名单
#### 产品里的位置
Onboarding 是新用户能不能成功把 agent 跑起来的关键漏斗。任何一步没完成(尤其是 runtime 没连上),后续功能都是空壳。
#### 对应表
`user`, `verification_code`, `personal_access_token`
---
### 3.13 设置与个人资料
#### My Account 标签
- **Profile**:名字、头像(不可上传,系统生成)、邮箱(只读)
- **Appearance**主题light / dark / system
- **API Tokens**:创建/查看/撤销 PAT创建时一次性展示完整 token
- **Daemon**(桌面独有):本机 daemon 状态、重启、开机自启开关
- **Updates**(桌面独有):当前版本、检查更新、自动更新开关
#### Workspace 标签
- **General**:名字、描述、**Workspace Context**agent 系统级提示)
- **Members**:见 3.10
- **Repositories**GitHub 集成连接仓库列表agent 白名单
- **Agents / Runtimes / Skills / Autopilots**各自独立页面实际上这些在侧边栏直接有入口settings 里也有对应管理 tab
#### 产品里的位置
Settings 是所有"配置即工作"动作的汇总agent 的 prompt、workspace 的 context、仓库白名单、skill 的内容——都在这里。**对运营和文案来说最重要的一句话**:用户在 Multica 的 settings 页面做的配置,每一项都会影响 agent 实际执行时读到的上下文。
---
### 3.14 CLI 命令行工具
`multica` 不只是启动 daemon 的工具,也是完整的命令行操作层。很多用户喜欢在终端里推进工作而不是开 UI。
#### 工作区 / 议题
```bash
multica workspace list | get | watch | unwatch
multica issue list | get | create | update | assign | status
multica issue comment list | add | delete
multica issue runs <id> # 查看任务执行历史
multica issue run-messages <task-id> # 查看某次执行的消息
```
#### Agent / Skill / Autopilot / Project / Repo
```bash
multica agent list | get | create | update | archive
multica skill list | get | create | update | delete | import | files upsert
multica autopilot list | get | create | update | trigger
multica autopilot trigger-add --cron "0 9 * * 1-5"
multica project list | get | create | update
multica repo list | add | update | delete
```
#### Runtime
```bash
multica runtime list | usage | activity | update
```
#### 配置 / 更新
```bash
multica config show | set server_url ...
multica auth status | logout
multica version | update
```
#### 产品里的位置
CLI 是 Multica 对开发者友好度的体现。对于 agent 自己来说,也同等重要——**agent 在执行任务时能调用 `multica` 命令读写 issue、评论、查文档**,这正是 CLI 在 "agent 作为一等公民"架构里的作用。
---
## 4. 系统架构全景
```
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ Next.js Web App │ │ Electron Desktop │ │ multica CLI │
│ apps/web │ │ apps/desktop │ │ server/cmd/ │
└──────────┬──────────┘ └──────────┬─────────┘ └────────┬─────────┘
│ HTTP + WebSocket │ │ HTTP
│ │ │
└──────────────┬────────────────┴───────────────┬───────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Go Backend (server/) │
│ • Chi HTTP router • gorilla/websocket hub │
│ • sqlc generated queries │
│ • In-process event bus │
│ • Background workers (sweeper / scheduler) │
└──────────────────┬──────────────────────────────┘
┌──────────────────────┐
│ PostgreSQL 17 │
│ + pgcrypto │
│ (28 tables) │
└──────────────────────┘
│ HTTPS poll + heartbeat
┌─────────────────────────────────────────────────┐
│ Local Daemon (用户机器上运行) │
│ • 每 3s 认领任务 • 每 15s 心跳 │
│ • 探测并启动 agent CLI 子进程 │
│ • 为任务准备隔离工作目录 │
└───────────────┬─────────────────────────────────┘
│ spawns
┌───────────────┼─────────────────────────────────┐
▼ ▼ ▼ ▼
Claude Code Codex OpenCode …其他 CLI
(子进程) (子进程) (子进程)
```
### 分层职责
| 层 | 负责什么 | 不负责什么 |
|---|---|---|
| **Web / Desktop 客户端** | UI、本地客户端状态Zustand、服务器状态缓存TanStack Query、WebSocket 订阅 | 业务规则、AI 调用 |
| **Server** | 持久化、权限、任务编排、事件广播、Autopilot 调度、Runtime 健康监测 | 不直接执行 agent、不调 LLM |
| **Daemon** | 探测并启动本地 CLI、管理任务工作目录、流式上报消息、session 恢复 | 不做业务决策、只认 server 给它的任务 |
| **Agent CLIClaude Code 等)** | 实际调用 LLM、执行工具调用、写文件、跑测试 | 不感知 Multica 的数据模型(所有上下文通过 `multica` CLI 命令读回) |
### 实时层WebSocket
Server 启动一个 WebSocket hub
- **鉴权**URL 参数里的 JWT 或 PAT + workspace_slug
- **房间模型**:按 workspace 分房间,一个 workspace 的事件只广播给该房间的连接
- **个人定向推送**`inbox:new`, `invitation:created` 等个人事件用 `SendToUser`
- **心跳**server 每 54 秒 ping客户端 60 秒内必须 pong
**全部事件类型(供文案参考,共约 60+ 个)**
- `issue:created` / `issue:updated` / `issue:deleted`
- `comment:created` / `comment:updated` / `comment:deleted` / `reaction:added` / `issue_reaction:added`
- `agent:created` / `agent:status` / `agent:archived`
- `task:dispatch` / `task:progress` / `task:message` / `task:completed` / `task:failed` / `task:cancelled`
- `inbox:new` / `inbox:read` / `inbox:archived` / `inbox:batch-*`
- `workspace:updated` / `workspace:deleted` / `member:added` / `member:updated` / `member:removed`
- `invitation:created` / `invitation:accepted` / `invitation:declined` / `invitation:revoked`
- `chat:message` / `chat:done` / `chat:session_read`
- `skill:created` / `skill:updated` / `skill:deleted`
- `project:created` / `project:updated` / `project:deleted`
- `autopilot:created` / `autopilot:updated` / `autopilot:run_start` / `autopilot:run_done`
- `subscriber:added` / `activity:created`
- `daemon:heartbeat` / `daemon:register`
客户端收到事件后的模式:要么直接 patch 本地缓存issue / comment / task 这类需要即时更新的),要么触发对应 query 的失效重拉less-critical 数据)。
### AI / LLM 在哪里
**Multica 本身不直接调 LLM API**。所有 LLM 调用都在 agent CLI 子进程里发生Claude Code 调 Anthropic API、Codex 调 OpenAI API 等)。
Server 和 daemon 做的事情是:
1. 准备 prompt`server/internal/daemon/prompt.go`
2. 准备环境变量agent.custom_env 注入)
3. 准备工作目录(注入 CLAUDE.md / AGENTS.md / skills / issue context
4. 启动 CLI 子进程
5. 流式读 CLI 的 stdout把消息分类并转发
**所以看不到大段的 prompt 工程代码**——prompt 只有几个模板task prompt、chat prompt、comment-triggered prompt核心内容是 agent instructions + issue context + skill files真正的 LLM 对话由 CLI 自己管理。
### 后台任务
Server 启动三个 goroutine
1. **Runtime Sweeper**(每 30s标记离线 runtime、回收孤儿任务、GC 长期离线 runtime
2. **Autopilot Scheduler**(每 30s扫 cron 触发器,到点就 dispatch
3. **DB Stats Logger**:周期性打印 pgxpool 连接池状态
---
## 5. 产品地图(全部路由)
### 公共 / 认证
- `/` — 首页
- `/login` — 登录
- `/auth/callback` — OAuth 回调
- `/workspaces/new` — 创建工作区
- `/invite/[id]` — 接受邀请
- `/onboarding` — 首次引导
### 工作区内(`/{slug}/...`
- `/issues` — Issue 列表board / list 视图)
- `/issues/[id]` — Issue 详情
- `/my-issues` — 我的 issue三 scope
- `/projects` — 项目列表
- `/projects/[id]` — 项目详情
- `/autopilots` — Autopilot 列表
- `/autopilots/[id]` — Autopilot 详情
- `/agents` — Agent 列表
- `/runtimes` — Runtime 列表
- `/skills` — Skill 库
- `/inbox` — 收件箱
- `/settings` — 设置(包含多个 tabprofile / appearance / tokens / workspace / members / repos / daemon / updates
### 桌面端特有(不是路由,是 WindowOverlay
- **Create workspace overlay**
- **Invite accept overlay**(来自 `multica://invite/{id}` 深链接)
- **Onboarding overlay**(首次或零工作区时)
---
## 6. 跨平台差异Web vs 桌面
### 共享(绝大部分功能)
所有业务页面issues / projects / autopilots / agents / runtimes / skills / inbox / settings / chat / login / onboarding的实际 UI 都在 `packages/views/`web 和桌面共用同一套组件。
### Web 特有
- 地址栏 + 浏览器前进后退
- 服务端渲染SSR
- `/login` 的 OAuth 回调处理 localhost 端口(方便 CLI 登录)
### 桌面特有
- **多标签**:每个 workspace 独立标签组,可以拖拽重排
- **WindowOverlay**邀请接受、创建工作区、onboarding 不走路由,而是原生窗口层
- **Daemon 集成**:设置里能直接重启本机 daemon、看状态
- **本地 daemon runtime 卡片**:在 Runtimes 页面自动显示本机 daemon
- **自动更新**`Settings → Updates` 检查/下载/安装新版本
- **Immersive mode**:全屏模式,隐藏侧边栏
- **深链接**`multica://auth/callback?token=...``multica://invite/{id}`
- **拖动区**macOS 的红绿灯 + 顶部 48px 拖拽条(`h-12`)用来移动窗口
- **Workspace 单例守护**`setCurrentWorkspace()` 管理当前活跃工作区的全局身份
### 为什么两端要做差异
Web 有 URL 栏——错误状态(比如"你没有访问这个 workspace 的权限")作为一个可分享的 URL 页面是有意义的。桌面没有 URL 栏——同样的状态只会把用户困住,所以桌面选择**静默自愈**:把失效的 tab 从 store 里移除即可。这个差异直接影响多个细节:
- Web 有 `NoAccessPage`,桌面没有
- Web 有 `/workspaces/new` 页面,桌面把它做成 overlay
- Web 的 deep link 直接路由,桌面的深链接转 WindowOverlay
---
## 7. 附录:关键数据表速查
**28 张表**,覆盖 10 个产品域。以下按域列出最重要的字段,供文案/产品查询"某个功能背后到底存了什么"。
### 身份 / 认证
- `user` — 基础账号id, email, name, avatar_url
- `verification_code` — 邮箱验证码code, expires_at, attempts
- `personal_access_token` — 用户 API tokentoken_hash, token_prefix, revoked
### 工作区 / 成员
- `workspace` — 容器name, slug, description, context, settings, repos, issue_prefix, issue_counter
- `member` — 成员身份role: owner/admin/member
- `workspace_invitation` — 邀请invitee_email, status: pending/accepted/declined/expired
### Agent / Runtime / Skill
- `agent` — Agent 主表instructions, custom_env, custom_args, mcp_config, runtime_mode, visibility, status
- `agent_runtime` — 运行时daemon_id, provider, status: online/offline, last_seen_at
- `agent_skill` — agent 挂载 skill 的 n-n 关联
- `skill` — 技能主文档name, description, content
- `skill_file` — 技能附带文件path, content
- `daemon_token` — 守护进程级 token
- `daemon_connection` / `daemon_pairing_session` — 早期设计(弃用中)
### Issue / 协作
- `issue` — 议题status, priority, assignee_type+assignee_id, creator_type+creator_id, parent_issue_id, project_id, origin_type, origin_id, acceptance_criteria, due_date, position
- `issue_label` / `issue_to_label` — 标签
- `issue_dependency` — 依赖关系blocks / blocked_by / related
- `issue_subscriber` — 订阅者reason: creator/assignee/commenter/mentioned/manual
- `issue_reaction` / `comment_reaction` — emoji 反应
- `comment` — 评论type: comment/status_change/progress_update/system, parent_id for threading
- `attachment` — 附件
### 任务执行
- `agent_task_queue` — 任务主表status: queued/dispatched/running/completed/failed/cancelled, context, result, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id
- `task_message` — 每次执行的消息流水seq, type, tool, input, output
- `task_usage` — Token 用量input/output/cache_read/cache_write tokens
### 对话
- `chat_session` — 聊天会话unread_since, session_id, work_dir
- `chat_message` — 消息role: user/assistant
### 项目与组织
- `project` — 项目status, priority, lead_type+lead_id, icon
- `pinned_item` — 侧边栏置顶item_type, item_id, position
### 自动化
- `autopilot` — 规则assignee_id, execution_mode: create_issue/run_only, issue_title_template, concurrency_policy
- `autopilot_trigger` — 触发器kind: schedule/webhook/api, cron_expression, timezone, next_run_at, webhook_token
- `autopilot_run` — 执行记录status: pending/issue_created/running/skipped/completed/failed
### 通知与审计
- `inbox_item` — 收件箱条目recipient_type, type, severity, read, archived
- `activity_log` — 审计日志actor_type: member/agent/system, action, details
- `runtime_usage` — 运行时按日聚合 token 用量(给计费/容量规划用)
---
## 尾声
Multica 的设计可以归结为一句话:**把"人在一个看板上协作"这件事,扩展到了"人 + AI agent 在同一个看板上协作"**。
所有功能都是围绕这个核心展开:
- 为了让 agent 能像人一样被分配任务 → polymorphic actor`assignee_type`
- 为了让 agent 能自己开工 → Autopilot
- 为了让 agent 的工作方式能沉淀复用 → Skill
- 为了让 agent 执行在用户控制的环境里 → Runtime + Daemon
- 为了让人不被通知淹没 → Inbox + 自动订阅
- 为了让一次会话有连续性 → Session Resumption
当你读到某段文案、某个 UI 模块、某张表时,请把它放回这个"人 + AI 协作"的坐标系里去理解它的位置。

View File

@@ -1,109 +0,0 @@
# Workspace URL 化重构 — 项目汇报
**日期**2026-04-15
**作者**Naiyuan
**状态**:调研完成,待评审
---
## 一、为什么要做
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里**不含任何 workspace 信息**。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
这个设计已经在产品里直接表现为 3 个已知问题:
1. **分享链接不可靠**MUL-43`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
2. **手机端无法切 workspace**MUL-509切换只靠 sidebar UI手机端不展开 sidebar 就没有切换入口
3. **多 tab 互相覆盖**`multica_workspace_id` 是全局 localStorage key两个 tab 打开不同 workspace 会互相污染
除了这 3 个显性 bug架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
行业惯例Linear / Notion / Vercel / GitHub都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
## 二、调研结论
### 好消息:基础设施已经就位
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
- 后端已有 `GetWorkspaceBySlug` 查询
- 前端 `Workspace` 类型已包含 `slug` 字段
- Web 端认证已经切换为 HttpOnly cookie 模式Next.js middleware 可读到登录态
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
### 坏消息:范围比最初估计大
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
1. **URL 路由重组**web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
3. **所有路径引用替换**`push("/issues")` 改为 path builder`paths.issues()`),影响 ~25 个组件文件
4. **Mutation 副作用重构**`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
5. **桌面端 tab 系统适配**tab 路径天然包含 workspace切 workspace = 开新 tab 或导航,不再有全局切换动作
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug需要更新
7. **后端保留词校验**slug 不能和前端顶级路由冲突(`login``onboarding``invite``api``settings` 等),后端创建时校验
8. **内部 markdown 链接兼容**issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
### 不需要改的(边界已确认)
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
- `mention://type/id` 协议 — 只存 UUIDworkspace-agnostic
- CLI 登录 URL — `/login` 也是 pre-workspace不需要 slug
- 后端 API 路径 — 保持 `/api/workspaces/{id}`slug 仅用于前端 URL
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
## 三、方案要点
**核心原则**URL 是 workspace 上下文的唯一 source of truth其他状态都是派生态。
**URL 形状**`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
**切换 workspace = 导航**sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
**预估影响面**~30-35 个文件,其中约 20 个是机械替换hardcoded 路径 → path builder真正需要思考的核心逻辑改动集中在 5-6 个文件。
**一个 PR 合并**中间状态不可运行URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
## 四、执行与测试计划
### 执行阶段
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
2. **下一步**:在独立 worktree 上开发AI 辅助写代码,过程中人工 review
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E
### 测试阶段
1. **本地自测**
- 已知功能路径(创建 / 浏览 / 搜索 issue切换 workspace接受邀请分享链接
- 已知 bug 场景MUL-43 / MUL-509 / MUL-727 / MUL-820逐一验证已修复
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程
- URL 使用感受(分享、收藏、刷新)
3. **灰度 / 生产**:测试环境稳定后推生产
### 风险提示
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404产品还没正式 ship、用户量可忽略所以不做兼容性重定向
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate可能性极低因 slug 限制较严)
## 五、附注
这次重构会**顺带修掉**以下已登记 issue不需要单独开 PR
| Issue | 修复方式 |
|---|---|
| MUL-43切换 workspace 报错 / 分享链接失效) | URL 带 slug根本解决 |
| MUL-509手机端无法切 workspace | 切换变导航,手机能点链接就能切 |
| MUL-723workspace 不在 URL | 核心目标 |
| MUL-727创建 workspace 闪 /issues | 删除 mutation 里的 switchWorkspace 副作用 |
| MUL-728删除 workspace 后留在 /settings | 删除成功后 navigate 到下一个 workspace |
| MUL-820sidebar Join 不切 workspace | Join 改成跳转到 `/invite/{id}` 走统一路径 |
不在本次范围内的Issue #951WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
---
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",

View File

@@ -0,0 +1,113 @@
/**
* Download funnel instrumentation.
*
* Complements the onboarding events added in PR #1489 by covering
* every surface that advertises the desktop app — landing hero,
* landing footer, login, Welcome (web branch), Step 3 — and the
* /download page itself. Without this layer we can see Step 3
* path selection but not the touchpoint that got the user there,
* nor the /download → installer conversion.
*
* Event names and property shapes are governed by docs/analytics.md;
* keep the two in sync when adding a new source or field.
*/
import posthog from "posthog-js";
import { captureEvent, setPersonProperties } from "./index";
/**
* Where the user clicked a CTA that points at `/download`. Typed union
* prevents drift across the five touchpoints and lets PostHog funnels
* split cleanly by top-of-funnel entry.
*/
export type DownloadIntentSource =
| "landing_hero"
| "landing_footer"
| "login"
| "welcome"
| "step3";
/**
* OS + arch detect result for the /download page. Mirrors the shape of
* `@/features/landing/utils/os-detect.ts` without importing it (that
* module lives in the web app; core packages can't depend on it). Keep
* these enums in lockstep.
*/
export interface DownloadDetectPayload {
detected_os: "mac" | "windows" | "linux" | "unknown";
detected_arch: "arm64" | "x64" | "unknown";
detect_confident: boolean;
version_available: boolean;
}
/**
* Specific installer the user chose on /download. Version is the GitHub
* tag name (e.g. "v0.2.13") so we can correlate adoption-by-release.
*/
export interface DownloadInitiatedPayload {
platform: "mac" | "windows" | "linux";
arch: "arm64" | "x64";
format: "dmg" | "zip" | "exe" | "appimage" | "deb" | "rpm";
version: string;
primary_cta: boolean;
matched_detect: boolean;
}
/**
* Fires when a user clicks any CTA that navigates to `/download`. We
* also write `platform_preference` to person properties so the backend
* can segment subsequent events — same convention the Step 3 handler
* already uses (see `step-platform-fork.tsx`).
*/
export function captureDownloadIntent(source: DownloadIntentSource): void {
captureEvent("download_intent_expressed", {
source,
});
setPersonProperties({ platform_preference: "desktop" });
}
/**
* Fires once on /download page mount, after OS detection resolves. The
* first detection for a given person is mirrored into person properties
* via `$set_once` so every downstream event gains a platform dimension
* without re-emitting.
*/
export function captureDownloadPageViewed(
payload: DownloadDetectPayload,
): void {
captureEvent("download_page_viewed", {
detected_os: payload.detected_os,
detected_arch: payload.detected_arch,
detect_confident: payload.detect_confident,
version_available: payload.version_available,
});
setPersonPropertiesOnce({
first_detected_os: payload.detected_os,
first_detected_arch: payload.detected_arch,
});
}
/**
* Fires when the user clicks a concrete installer link on `/download`.
* `primary_cta` marks the hero-level recommendation versus a manual
* pick from the All Platforms matrix; `matched_detect` captures
* whether the click matched what we detected (miss = detect got it
* wrong / user overrode).
*/
export function captureDownloadInitiated(
payload: DownloadInitiatedPayload,
): void {
captureEvent("download_initiated", { ...payload });
}
/**
* $set_once wire form. Mirrors the backend's `Event.SetOnce` path —
* first write wins, subsequent ones are no-ops on PostHog's side.
* Wrapping it here keeps call sites free of the no-op `$set_once`
* envelope quirk.
*/
function setPersonPropertiesOnce(props: Record<string, unknown>): void {
if (typeof window === "undefined") return;
posthog.capture("$set", { $set_once: props });
}

View File

@@ -0,0 +1,34 @@
/**
* Feedback funnel instrumentation.
*
* Pairs with the backend's `feedback_submitted` event (emitted from
* `CreateFeedback` after a successful insert) so we can compute a
* completion rate: users who open the modal → users who actually send.
* The message content itself is never sent to PostHog; see
* docs/analytics.md and the backend `FeedbackSubmitted` helper for the
* PII contract.
*/
import { captureEvent } from "./index";
/**
* Entry point the user took to reach the Feedback modal. Typed union so
* future surfaces (keyboard shortcut, error-toast CTA, sidebar menu
* item) have to extend this list explicitly rather than drift.
*/
export type FeedbackOpenedSource = "help_menu";
/**
* Fires once on FeedbackModal mount. Workspace id is attached when the
* modal opens inside a workspace; pre-workspace surfaces (e.g. inbox,
* onboarding transitions) omit it rather than sending an empty string.
*/
export function captureFeedbackOpened(
source: FeedbackOpenedSource,
workspaceId?: string,
): void {
captureEvent("feedback_opened", {
source,
...(workspaceId ? { workspace_id: workspaceId } : {}),
});
}

Some files were not shown because too many files have changed in this diff Show More