Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
a6a5ef0aa8 feat(views): show issue title in detail page header
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:34:01 +08:00
366 changed files with 2497 additions and 31381 deletions

View File

@@ -4,23 +4,8 @@ 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
@@ -36,28 +21,17 @@ 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 (only when APP_ENV != "production"; see above).
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# 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=
@@ -66,13 +40,6 @@ 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)
@@ -96,25 +63,3 @@ 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

@@ -1,59 +0,0 @@
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,21 +3,15 @@ name: Release
on:
push:
tags:
# GitHub Actions uses glob patterns here, not regex. Match versioned
# tags broadly at the trigger layer, then enforce strict semver below.
- "v*.*.*"
- "!v*-dirty*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
permissions:
contents: write
packages: write
jobs:
verify:
release:
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
@@ -25,25 +19,13 @@ jobs:
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
@@ -54,21 +36,6 @@ jobs:
- name: Run tests
run: cd server && go test ./...
release:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -77,145 +44,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
docker-images:
needs: verify
runs-on: ubuntu-latest
concurrency:
group: release-docker-images-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- 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: Compute backend image tags
id: meta_backend
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-
labels: |
org.opencontainers.image.title=Multica Backend
org.opencontainers.image.description=Multica self-hosted backend
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
pull: true
push: true
platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta_backend.outputs.labels }}
tags: ${{ steps.meta_backend.outputs.tags }}
cache-from: type=gha,scope=release-backend
cache-to: type=gha,mode=max,scope=release-backend
build-args: |
VERSION=${{ needs.verify.outputs.tag_name }}
COMMIT=${{ github.sha }}
- name: Compute web image tags
id: meta_web
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-
labels: |
org.opencontainers.image.title=Multica Web
org.opencontainers.image.description=Multica self-hosted web frontend
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
pull: true
push: true
platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta_web.outputs.labels }}
tags: ${{ steps.meta_web.outputs.tags }}
cache-from: type=gha,scope=release-web
cache-to: type=gha,mode=max,scope=release-web
build-args: |
REMOTE_API_URL=http://backend:8080
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
# 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,4 +57,3 @@ _features/
server/server
data/
.kilo
.idea

View File

@@ -21,12 +21,12 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
# 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
- id: default
formats:
- tar.gz
format_overrides:
@@ -34,16 +34,6 @@ 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"
@@ -58,8 +48,6 @@ changelog:
brews:
- name: multica
ids:
- versioned
repository:
owner: multica-ai
name: homebrew-tap

View File

@@ -106,7 +106,6 @@ 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
@@ -220,35 +219,26 @@ Cross-workspace `push(path)` is detected by the navigation adapter (`platform/na
### Drag region (macOS window-move)
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
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**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
**Pattern**: flex child at top, not absolute overlay.
```tsx
import { DragStrip } from "@multica/views/platform";
return (
<div className="flex min-h-svh flex-col bg-background">
<DragStrip />
<div className="flex flex-1 flex-col px-6 pb-12">
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
</div>
<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. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
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.
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
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 (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
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.
## UI/UX Rules

View File

@@ -76,8 +76,7 @@ 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
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
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${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,19 +592,6 @@ 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)

164
Makefile
View File

@@ -1,4 +1,4 @@
.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
.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
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,23 +36,10 @@ define REQUIRE_ENV
fi
endef
# 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
##@ 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
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -64,16 +51,8 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
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
docker compose -f docker-compose.selfhost.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 \
@@ -87,11 +66,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
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 "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -102,57 +77,16 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
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
# Stop all Docker Compose self-host services
selfhost-stop:
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
##@ One-click
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
# First-time setup: install deps, start DB, run migrations
setup:
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
@@ -163,7 +97,8 @@ setup: ## Prepare the current checkout from its env file: install deps, ensure D
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
start: ## Start backend and frontend for the current checkout and run migrations first
# Start all services (backend + frontend)
start:
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@@ -177,7 +112,8 @@ start: ## Start backend and frontend for the current checkout and run migrations
pnpm dev:web & \
wait
stop: ## Stop backend and frontend processes for the current checkout
# Stop all services
stop:
$(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@@ -189,52 +125,33 @@ stop: ## Stop backend and frontend processes for the current checkout
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
# Full verification: typecheck + unit tests + Go tests + E2E
check:
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up: ## Start the shared PostgreSQL container used by main and worktrees
db-up:
@$(COMPOSE) up -d postgres
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
db-down:
@$(COMPOSE) down
# 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
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
setup-main: ## Prepare the main checkout using .env
setup-main:
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main: ## Start the main checkout using .env
start-main:
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main: ## Stop the main checkout processes defined by .env
stop-main:
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main: ## Run the full verification pipeline for the main checkout
check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
setup-worktree:
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
@@ -243,68 +160,65 @@ setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree: ## Start this worktree using .env.worktree
start-worktree:
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree: ## Stop this worktree's backend and frontend processes
stop-worktree:
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree: ## Run the full verification pipeline for this worktree
check-worktree:
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
##@ Individual commands
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
# One-command dev: auto-setup env/deps/db/migrations, then start all services
dev:
@bash scripts/dev.sh
server: ## Run only the Go server for the current checkout
# Go server only
server:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
daemon:
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
cli:
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
multica: ## Run the multica CLI entrypoint directly from the Go source tree
multica:
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 the server, CLI, and migrate binaries into server/bin
build:
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: ## Run Go tests after ensuring the target DB exists and migrations are applied
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database
##@ Database
migrate-up: ## Create the target DB if needed, then apply database migrations
migrate-up:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down: ## Create the target DB if needed, then roll back database migrations
migrate-down:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc: ## Regenerate sqlc code
sqlc:
cd server && sqlc generate
# Cleanup
##@ Cleanup
clean: ## Remove generated server binaries and temp files
clean:
rm -rf server/bin server/tmp

View File

@@ -85,8 +85,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> 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.
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---

View File

@@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
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.
@@ -54,10 +54,6 @@ 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
@@ -73,8 +69,6 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **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
@@ -162,15 +156,14 @@ 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.
## Upgrading
## Rebuilding After Updates
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
make selfhost
```
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`.
Migrations run automatically on backend startup.
---
@@ -193,7 +186,6 @@ 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,15 +14,6 @@ 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).
@@ -42,18 +33,6 @@ 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:
@@ -65,14 +44,7 @@ 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) |
### 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.
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
@@ -246,7 +218,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 (only if you are building the web image from source via docker-compose.selfhost.build.yml)
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -262,15 +234,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then restart the stack:
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d --build
```
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.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **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.
## Health Check
@@ -286,9 +258,8 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
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`.
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

@@ -21,26 +21,23 @@ mac:
- zip
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
# `${name}` produces for scoped package names.
# 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}
artifactName: multica-desktop-${version}-${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}-mac-${arch}.${ext}
artifactName: multica-desktop-${version}-${arch}.${ext}
linux:
target:
- AppImage
- deb
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
artifactName: ${name}-${version}-${arch}.${ext}
win:
target:
- nsis
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
artifactName: ${name}-${version}-setup.${ext}
publish:
provider: github
owner: multica-ai

View File

@@ -2,31 +2,17 @@
"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"
@@ -39,7 +25,6 @@
"@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, rm } from "node:fs/promises";
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
@@ -23,54 +23,8 @@ const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
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 binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
@@ -107,9 +61,8 @@ if (hasGo()) {
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
);
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
execFileSync(
"go",
[
@@ -117,19 +70,10 @@ if (hasGo()) {
"-ldflags",
ldflags,
"-o",
srcBinary,
join("bin", binName),
"./cmd/multica",
],
{
cwd: serverDir,
stdio: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
GOOS: goos,
GOARCH: goarch,
},
},
{ cwd: serverDir, stdio: "inherit" },
);
} else {
console.warn(
@@ -144,11 +88,9 @@ 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.
//
// 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.
// 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.
//
// The electron-vite step is important: electron-builder only packages
// whatever is already in out/, so skipping it (or relying on stale
@@ -25,50 +25,11 @@
// version-derivation logic without shelling out.
import { execFileSync, spawnSync, execSync } from "node:child_process";
import { delimiter, dirname, resolve } from "node:path";
import { 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 {
@@ -116,231 +77,20 @@ function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function uniqueOrdered(values) {
return [...new Set(values)];
}
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 + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
// Step 1: build the Electron main/preload/renderer bundles. Without
// Step 2: 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(
@@ -353,7 +103,7 @@ function main() {
process.exit(viteResult.status ?? 1);
}
// Step 2: derive the version that should be written into the app.
// Step 3: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -363,62 +113,43 @@ function main() {
);
}
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
if (disableMacNotarize) {
// 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) {
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");
}
const useScopedOutputDir = buildMatrix.length > 1;
builderArgs.push(...passthrough);
// 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,
},
// 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,
);
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(1);
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.

View File

@@ -1,13 +1,5 @@
import { delimiter, resolve } from "node:path";
import { describe, it, expect } from "vitest";
import {
builderArgsForTarget,
envWithLocalBins,
normalizeGitVersion,
parsePackageArgs,
resolveBuildMatrix,
stripLeadingSeparator,
} from "./package.mjs";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -67,207 +59,3 @@ 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,15 +8,35 @@ import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
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.
// 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.
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";
}
@@ -72,8 +92,14 @@ 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(
@@ -92,14 +118,7 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
async function installFresh(): Promise<string> {
const target = managedCliPath();
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 assetName = platformAssetName();
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
@@ -111,7 +130,7 @@ async function installFresh(): Promise<string> {
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName, expectedChecksum);
await verifyChecksum(archivePath, assetName);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
@@ -124,7 +143,6 @@ 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);
@@ -148,10 +166,8 @@ 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(
options: { forceInstall?: boolean } = {},
): Promise<string> {
export async function ensureManagedCli(): Promise<string> {
const target = managedCliPath();
if (existsSync(target) && !options.forceInstall) return target;
if (existsSync(target)) return target;
return installFresh();
}

View File

@@ -1,59 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -316,36 +316,6 @@ 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.
@@ -369,55 +339,27 @@ async function resolveCliBinary(): Promise<string | null> {
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
const version = await probeCliBinary(bundled, "bundled");
if (version) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
cachedCliBinaryVersion = version;
return bundled;
}
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
}
const managed = managedCliPath();
if (existsSync(managed)) {
const version = await probeCliBinary(managed, "managed");
if (version) {
cachedCliBinary = managed;
cachedCliBinaryVersion = version;
return managed;
}
cachedCliBinary = managed;
return managed;
}
try {
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`,
);
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
} 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 {
@@ -428,10 +370,11 @@ async function resolveCliBinary(): Promise<string | null> {
}
/**
* Reads the version of the currently resolved CLI binary. Cached for the
* process lifetime — the bundled binary doesn't change after bundle time.
* 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.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* wrong-arch bundled binary, etc.) so callers can fail open.
* etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
@@ -440,7 +383,24 @@ async function getCliBinaryVersion(): Promise<string | null> {
cachedCliBinaryVersion = null;
return null;
}
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
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;
}
return cachedCliBinaryVersion;
}

View File

@@ -193,16 +193,6 @@ 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,31 +1,9 @@
import { autoUpdater } from "electron-updater";
import { app, BrowserWindow, ipcMain } from "electron";
import { 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();
@@ -59,42 +37,10 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.quitAndInstall(false, true);
});
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.
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 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);
}, 5000);
}

View File

@@ -1,11 +1,6 @@
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. */
@@ -58,10 +53,6 @@ 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,32 +1,7 @@
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) =>
@@ -121,10 +96,6 @@ 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,16 +1,14 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, 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";
@@ -92,28 +90,11 @@ 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;
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]);
const wsCount = workspaces?.length ?? 0;
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
@@ -133,6 +114,22 @@ function AppContent() {
}
}, [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();
}
}, [user, workspaceListFetched, wsCount]);
// 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
@@ -161,15 +158,8 @@ function AppContent() {
);
}
// 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 />}
</>
);
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
}
// Backend the daemon should connect to — same URL the renderer talks to.
@@ -197,20 +187,12 @@ 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

@@ -13,7 +13,6 @@ 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";
@@ -135,7 +134,6 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -1,39 +0,0 @@
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

@@ -1,69 +0,0 @@
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

@@ -110,25 +110,12 @@ export function UpdateNotification() {
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<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>
<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>
</div>
)}

View File

@@ -1,86 +0,0 @@
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,21 +9,18 @@ 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 (onboarding, create workspace, accept
* invite).
* user is in a pre-workspace flow (create workspace, accept invite).
*
* 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.
* 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
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared view components under `packages/views/`,
* so web and desktop render identical content.
* 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.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
@@ -37,6 +34,8 @@ 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
@@ -45,35 +44,42 @@ function WindowOverlayInner() {
const onBack = wsList.length > 0 ? close : undefined;
return (
<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 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>
);
}

View File

@@ -25,8 +25,6 @@
--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,11 +4,6 @@ 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,5 +1,4 @@
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";
@@ -15,7 +14,11 @@ export function DesktopLoginPage() {
return (
<div className="flex h-screen flex-col">
<DragStrip />
{/* Traffic light inset */}
<div
className="h-[38px] shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
onSuccess={() => {

View File

@@ -15,11 +15,10 @@ import {
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
// 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";
// 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";
/**
* Extract the leading workspace slug from a path, or null if the path isn't
@@ -54,13 +53,6 @@ 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 {

View File

@@ -13,14 +13,14 @@ 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 { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { Download, Server } from "lucide-react";
import { 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,7 +113,7 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <DesktopRuntimesPage />,
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
@@ -130,12 +130,6 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -14,8 +14,7 @@ import { create } from "zustand";
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string }
| { type: "onboarding" };
| { type: "invite"; invitationId: string };
interface WindowOverlayStore {
overlay: WindowOverlay | null;

View File

@@ -169,16 +169,6 @@ 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 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.
This clones the repo, starts all services, installs the CLI, and configures it 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,17 +53,13 @@ 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 pull && 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 up -d`.
</Callout>
### Step 2 — Log In
@@ -74,8 +70,6 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod
- **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>
**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>
@@ -157,15 +151,14 @@ 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>
## Upgrading
## Rebuilding After Updates
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
make selfhost
```
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`.
Migrations run automatically on backend startup.
---
@@ -198,18 +191,6 @@ 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:
@@ -221,14 +202,7 @@ 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) |
### 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.
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server

View File

@@ -11,32 +11,16 @@ function createWrapper() {
);
}
const {
mockSendCode,
mockVerifyCode,
mockIssueCliToken,
searchParamsState,
authStateRef,
} = vi.hoisted(() => ({
const { mockSendCode, mockVerifyCode } = 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: () => searchParamsState.params,
useSearchParams: () => new URLSearchParams(),
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
@@ -48,12 +32,15 @@ vi.mock("@multica/core/auth", async () => {
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
authStateRef.state.sendCode = mockSendCode;
authStateRef.state.verifyCode = mockVerifyCode;
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
user: null,
isLoading: false,
};
const useAuthStore = Object.assign(
(selector: (s: typeof authStateRef.state) => unknown) =>
selector(authStateRef.state),
{ getState: () => authStateRef.state },
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
);
return { ...actual, useAuthStore };
});
@@ -70,7 +57,6 @@ vi.mock("@multica/core/api", () => ({
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
issueCliToken: mockIssueCliToken,
},
}));
@@ -79,9 +65,6 @@ 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", () => {
@@ -154,44 +137,4 @@ 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,36 +1,20 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { useConfigStore } from "@multica/core/config";
import { workspaceKeys } from "@multica/core/workspace/queries";
import {
paths,
resolvePostAuthDestination,
useHasOnboarded,
} from "@multica/core/paths";
import { api } from "@multica/core/api";
import { paths } from "@multica/core/paths";
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();
@@ -38,7 +22,6 @@ 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
@@ -46,59 +29,34 @@ function LoginPageContent() {
// 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 /onboarding if the user has none). Skip this entire path when
// (or /workspaces/new 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()) ?? [];
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
const [first] = list;
router.replace(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, 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()) ?? [];
router.push(resolvePostAuthDestination(list, onboarded));
const [first] = list;
router.push(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
};
// Build Google OAuth state: encode platform + next URL so the callback
@@ -110,52 +68,6 @@ 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}
@@ -174,22 +86,6 @@ 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

@@ -1,72 +0,0 @@
"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

@@ -1,140 +0,0 @@
"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

@@ -1,29 +0,0 @@
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

@@ -4,21 +4,13 @@ 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 />
<StarterContentPrompt />
</>
}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
>
{children}
</DashboardLayout>

View File

@@ -10,18 +10,6 @@ const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
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,
@@ -63,44 +51,30 @@ describe("CallbackPage", () => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockLoginWithGoogle.mockResolvedValue(undefined);
mockListWorkspaces.mockResolvedValue([]);
});
it("unonboarded user lands on /onboarding regardless of next=", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
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());
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
mockSearchParams.set("state", "next:https://evil.example");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
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" }),
);
it("honors a safe next= target (e.g. /invite/{id})", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);

View File

@@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
import {
Card,
@@ -62,17 +62,18 @@ function CallbackContent() {
} else {
// Normal web flow
loginWithGoogle(code, redirectUri)
.then(async (loggedInUser) => {
.then(async () => {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const onboarded = loggedInUser.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
router.push(
nextUrl || resolvePostAuthDestination(wsList, onboarded),
);
// 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);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { Inter, Geist_Mono } 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,23 +39,6 @@ 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",
@@ -106,7 +89,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />

View File

@@ -1,29 +0,0 @@
"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,14 +1,11 @@
"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
@@ -35,20 +32,8 @@ 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}
@@ -56,13 +41,7 @@ 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,239 +0,0 @@
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

@@ -1,108 +0,0 @@
"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

@@ -1,38 +0,0 @@
"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

@@ -1,285 +0,0 @@
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

@@ -1,54 +0,0 @@
/**
* 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

@@ -4,7 +4,6 @@ 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";
@@ -72,11 +71,6 @@ 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,9 +2,7 @@
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,
@@ -44,11 +42,25 @@ export function LandingHero() {
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href="/download"
href="https://github.com/multica-ai/multica/releases/latest"
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
onClick={() => captureDownloadIntent("landing_hero")}
>
<Download className="size-4" aria-hidden />
<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>
{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 { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths";
import { paths } from "@multica/core/paths";
/**
* Client-side fallback redirect for authenticated visitors on the landing page.
@@ -16,7 +16,7 @@ import { resolvePostAuthDestination, useHasOnboarded } 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 /onboarding if they have none).
* push the user into their workspace (or /workspaces/new if they have none).
*
* Renders nothing. Uses `router.replace` so the landing page never enters
* browser history for authenticated users.
@@ -25,17 +25,21 @@ export function RedirectIfAuthenticated() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hasOnboarded = useHasOnboarded();
const { data: list = [], isFetched } = useQuery({
const { data: list } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (isLoading || !user || !isFetched) return;
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, isFetched, list, hasOnboarded, router]);
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]);
return null;
}

View File

@@ -1,15 +1,11 @@
"use client";
import { createContext, useContext, useState, useCallback, useMemo } from "react";
import { useConfigStore } from "@multica/core/config";
import { createEnDict } from "./en";
import { createZhDict } from "./zh";
import { createContext, useContext, useState, useCallback } from "react";
import { en } from "./en";
import { zh } from "./zh";
import type { LandingDict, Locale } from "./types";
const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict> = {
en: createEnDict,
zh: createZhDict,
};
const dictionaries: Record<Locale, LandingDict> = { en, zh };
const COOKIE_NAME = "multica-locale";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
@@ -30,11 +26,6 @@ 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);
@@ -43,7 +34,7 @@ export function LocaleProvider({
return (
<LocaleContext.Provider
value={{ locale, t, setLocale }}
value={{ locale, t: dictionaries[locale], setLocale }}
>
{children}
</LocaleContext.Provider>

View File

@@ -1,8 +1,7 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createEnDict(allowSignup: boolean): LandingDict {
return {
export const en: LandingDict = {
header: {
github: "GitHub",
login: "Log in",
@@ -121,10 +120,9 @@ export function createEnDict(allowSignup: boolean): LandingDict {
headlineFaded: "in the next hour.",
steps: [
{
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: "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: "Install the CLI & connect your machine",
@@ -226,7 +224,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{ label: "Features", href: "#features" },
{ label: "How it Works", href: "#how-it-works" },
{ label: "Changelog", href: "/changelog" },
{ label: "Download", href: "/download" },
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
],
},
resources: {
@@ -281,57 +279,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
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",
@@ -725,80 +672,4 @@ export function createEnDict(allowSignup: boolean): 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

@@ -101,63 +101,4 @@ 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,8 +1,7 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createZhDict(allowSignup: boolean): LandingDict {
return {
export const zh: LandingDict = {
header: {
github: "GitHub",
login: "\u767b\u5f55",
@@ -121,10 +120,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: allowSignup
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
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: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
@@ -226,7 +224,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
{ label: "更新日志", href: "/changelog" },
{ label: "下载", href: "/download" },
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
],
},
resources: {
@@ -281,57 +279,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
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",
@@ -725,78 +672,4 @@ export function createZhDict(allowSignup: boolean): 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

@@ -1,76 +0,0 @@
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.
*
* 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_LATEST_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
const REVALIDATE_SECONDS = 300;
interface GitHubReleasePayload {
tag_name?: string;
published_at?: string;
html_url?: string;
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_LATEST_URL, {
next: { revalidate: REVALIDATE_SECONDS },
headers,
});
if (!res.ok) {
throw new Error(`GitHub API responded ${res.status}`);
}
const data = (await res.json()) as GitHubReleasePayload;
return {
version: data.tag_name ?? null,
publishedAt: data.published_at ?? null,
htmlUrl: data.html_url ?? null,
assets: parseReleaseAssets(data.assets ?? []),
};
} catch (err) {
console.warn("[download] fetchLatestRelease failed:", err);
return {
version: null,
publishedAt: null,
htmlUrl: null,
assets: {},
};
}
}

View File

@@ -1,97 +0,0 @@
/**
* 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

@@ -1,94 +0,0 @@
/**
* 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

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -9,11 +9,6 @@ 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",
};
@@ -64,7 +59,6 @@ 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

@@ -1,19 +0,0 @@
# 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

@@ -29,7 +29,9 @@ services:
retries: 5
backend:
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
@@ -59,7 +61,13 @@ services:
restart: unless-stopped
frontend:
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
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:-}
depends_on:
- backend
ports:

View File

@@ -1,362 +0,0 @@
# 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.
### `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.
- `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).
- 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.

View File

@@ -1,509 +0,0 @@
# Desktop 下载体系 — 文案定位Step 1 产出)
**目的**:为 `/download` 页面、onboarding、login、landing 所有 Desktop/CLI/Cloud 相关触点提供**唯一文案真相源**。后续 Step 2/3/4 实现时UI 层只从这里拿文案,不临时发明。
**双语策略**:遵循当前项目 i18n 现状——
- **Landing / `/download` / Web Login**i18n 双语en + zh接入 `apps/web/features/landing/i18n/`
- **Onboarding共享 views 包)**保持英文单语当前现状i18n 基建本次不引入)
---
## 一、三个 surface 的核心定位句
写 UI 时所有文案都派生自这三句。每一句都**以场景开头**,不以能力比较开头。
| Surface | EN | ZH |
|---|---|---|
| **Desktop** | Install the app. Agents run on your machine. | 下载桌面应用agent 在你的电脑上运行。 |
| **CLI** | For servers, remote dev boxes, and automation. | 适合服务器、远程开发机、自动化场景。 |
| **Cloud** | We host the runtime. No local install. | 我们为你托管 runtime无需本地安装。 |
### 一句话决策树(用户视角)
- "我就是想在自己电脑上用" → Desktop
- "我想让 agent 跑在我的服务器 / 远程机器上" → CLI
- "我一点都不想装东西" → Cloud目前是 waitlist
---
## 二、文案设计原则
| 原则 | 理由 | 例子 |
|---|---|---|
| 场景先于能力 | Desktop 和 CLI 运行后能力等价,差异在 setup moment 和使用场景 | ✅ "For servers and remote boxes" / ❌ "Lighter-weight Desktop" |
| 避免"easy / simple / just" | 这些是 claim用户不信且会和现实冲突CLI 的 `multica setup` 实际 10-30s | ✅ "Terminal setup" / ❌ "Just one command" |
| 诚实时间估计 | Welcome 当前 "Takes about 3 minutes" 对 web 用户是谎 | ✅ 差异化文案或去掉时间 |
| 第二人称 + 直接语气 | 和 Linear / Cursor 一致 | ✅ "Agents run on your Mac." / ❌ "Our runtime operates locally." |
| 不夸"强大"/"智能" | 现代用户免疫 marketing 形容词 | ✅ "Agents pick up tasks." / ❌ "Powerful AI agents tackle your work." |
---
## 三、触点文案对照表
### 3.1 Landing Heroweb only
**位置**`apps/web/features/landing/components/landing-hero.tsx:44-65` + `landing/i18n/en.ts:19` + `zh.ts:19`
**当前**
- EN: `"Download Desktop"` (ghost 按钮)
- ZH: `"下载桌面端"` (ghost 按钮)
- href: `https://github.com/multica-ai/multica/releases/latest`
**新**
- EN: `"Download Desktop"` ← 文案不变,**视觉升级为 primary/solid** + **href 改为 `/download`**
- ZH: `"下载桌面端"` ← 同上
- i18n key复用现有 `hero.downloadDesktop`**不新增 key**
**理由**
- 文案已经合适
- 改动只在视觉权重ghost → solid和链接目标GitHub releases → `/download`
- href 变更落在 `landing-hero.tsx:45` 的 hardcoded URL——改成相对路径 `/download`
---
### 3.2 Landing Nav / Footerweb only
**位置**`landing/i18n/en.ts:230` + `zh.ts:230`
**当前**
```ts
{ label: "Desktop" / "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" }
```
**新**
```ts
{ label: "Download" / "下载", href: "/download" }
```
**理由**
- 把"Desktop"改成"Download"——`/download` 页面本身就是三个选项的聚合Desktop/CLI/Cloud不只是 desktop
- href 统一到 `/download`
---
### 3.3 Web Login Page — 新增 Desktop CTA
**位置**`apps/web/app/(auth)/login/page.tsx` 调用 `LoginPage``packages/views/auth/login-page.tsx`)时注入新 prop `extra`
**当前**:无此 UI
**新**Google 按钮下方低调一行):
- EN: `"Prefer the desktop app? Download →"`
- ZH: `"想用桌面应用?下载 →"`
**i18n**:新增 key
```ts
auth: {
login: {
extraDownloadPrompt: "Prefer the desktop app?" / "想用桌面应用?",
extraDownloadCta: "Download" / "下载",
}
}
```
**理由**
- 登录页是**最轻投入时刻**,推 Desktop 最便宜
- 不强推,低调一行,不影响 Google OAuth 主流
- Desktop app 的 Login Page`apps/desktop/src/renderer/src/pages/login.tsx`**不传** `extra` → 不显示。这条 CTA 只在 web 出现。
---
### 3.4 Welcome 屏 — web 分支新增 Desktop 引导
**位置**`packages/views/onboarding/steps/step-welcome.tsx`(单语英文)
**当前**(所有平台):
```
Takes about 3 minutes. You'll end with a real agent
replying to a real issue.
```
按钮:`Start exploring` + (optional) `I've done this before`
**新**web 分支,`isWeb=true`
- 上方文字保留 `"Your AI teammates, in one workspace."` 标题
- 副文案改为:`"About 3 minutes on desktop. A bit more on web — you'll need a local runtime."`
- 按钮区追加第三个按钮视觉权重primary > secondary ghost
- Primary: `"Start exploring"` (保留,引导继续 web 流程)
- **新增 secondary**: `"Download Desktop — faster setup"`(指向 `/download`,新窗口打开)
- Ghost: `"I've done this before"` (保留条件)
**新**desktop 分支,`isWeb=false`
- 文案完全保持:`"Takes about 3 minutes. You'll end with a real agent replying to a real issue."`
- 按钮不变
**理由**
- 首次向 web 用户承认"desktop 更顺"——早于任何投入
- Primary CTA 仍是"Start exploring"——不强推 desktop只是让它可见
- 3 minutes 文案按平台差异化——对 desktop 用户诚实,对 web 用户不再骗
---
### 3.5 Step 3 Platform Fork — Desktop 卡
**位置**`packages/views/onboarding/steps/step-platform-fork.tsx:232-299``ForkPrimary` 组件)
**当前Mac detect 命中)**
- 标题:`"Download the desktop app"`
- 副文案:`"macOS · runtime bundled — detects your tools automatically, nothing to install."`
**当前(非 Mac**
- 标题:`"Desktop app — macOS only for now"` (disabled card)
- 副文案:`"Windows and Linux builds are on the way. In the meantime, install the CLI below — it takes about two minutes."`
**新(所有平台,按 detect 结果适配)**
- 标题:`"Download the desktop app"`
- 副文案(按 detect 分支):
- macOS arm64: `"macOS (Apple Silicon) · bundled daemon, zero setup."`
- macOS Intel/unknown: `"macOS · bundled daemon, zero setup."` + 小字 `"Apple Silicon only — on Intel? Use CLI."`
- Windows: `"Windows · bundled daemon, zero setup."`
- Linux: `"Linux · bundled daemon, zero setup."`
- 非 Mac 检测不到: `"Bundled daemon, zero setup."`
- 按钮 pill`"Download"`(不变)
**理由**
- 拆掉 `isMac` 门——Windows/Linux 包已经齐
- 副文案主打**"zero setup"**——这才是和 CLI 的真差异
- Intel Mac 诚实提示,不骗他们点 arm64 包
---
### 3.6 Step 3 Platform Fork — CLI 卡
**位置**:同上,`ForkAlt` 调用
**当前**
- 标题:`"Install the CLI"`
- 副文案:`"Run the Multica daemon yourself — a couple of terminal commands."`
- 按钮:`"Show steps"`
**新**
- 标题:`"Install the CLI"` (不变)
- 副文案:`"For servers, remote dev boxes, and headless setups. Terminal required."`
- 按钮:`"Show steps"` (不变)
**理由**
- 副文案从"自己跑 daemon"改为"服务器 / 远程 / headless"——让 CLI 归位到它真正的场景
- 加 "Terminal required" 给用户明确预期,不伪装成轻量路径
---
### 3.7 Step 3 Platform Fork — Cloud 卡
**位置**:同上,`ForkAlt` 调用
**当前**
- 标题:`"Cloud runtime"`
- 副文案:`"We host it for you. Not live yet — leave your email and we'll let you know."`
- 按钮:`"Join waitlist"` / `"On the list"`
**新**
- 标题:`"Cloud runtime"` (不变)
- 副文案:`"We host the runtime. Not live yet — join the waitlist."`
- 按钮不变
**理由**
- 微调,对齐定位句
- 不再把 "we'll let you know" 说得像客户支持
---
### 3.8 Step 3 Footer Hint
**位置**`step-platform-fork.tsx:101-112`
**当前 non-Mac**`"Install the CLI to connect a runtime, or skip for now."`
**新(去掉 non-Mac 分支,因为 Desktop 对所有平台 active**
```ts
if (waitlistSubmitted) return "You're on the waitlist — pick Skip to keep exploring.";
if (downloaded) return "Downloading… finish setup in the desktop app, or pick another path.";
return "Pick a path above — or skip and configure a runtime later.";
```
**理由**:删掉 non-Mac 专属分支,现在所有平台 Desktop 都可用。
---
### 3.9 CLI Install Dialog — Title + Description
**位置**`step-platform-fork.tsx:378-384`
**当前**
```
Title: "Install the CLI"
Description: "Runs the same daemon the desktop app bundles — you install it yourself."
```
**新**
```
Title: "Install the CLI"
Description: "Same daemon, installed on your terminal. Use it when Desktop doesn't fit — servers, remote dev boxes, or headless setups."
```
**理由**
- 明确 CLI 和 Desktop 是**同一个 daemon**——消除"CLI 是否弱化版 Desktop"的误解
- 直接说 CLI 的正当场景——当 Desktop 不适合时
---
### 3.10 CliInstallInstructions — 头部提示
**位置**`packages/views/onboarding/steps/cli-install-instructions.tsx:65-68`
**当前**
```
You'll need a local AI coding tool (Claude Code, Codex,
Cursor, …) installed for the runtime to do real work.
```
**新**
```
You'll need an AI coding tool on this machine (Claude Code,
Codex, Cursor, …) for the daemon to do real work. Also works
on servers and remote dev boxes.
```
**理由**
- 最后一句点出 CLI 的远程场景——和 Step 3 CLI 卡的副文案呼应
---
### 3.11 CLI Dialog Waiting — "Stalled" 文案
**位置**`step-platform-fork.tsx:552-561`
**当前**
```
Nothing coming through yet. Close this dialog and try another
path on the previous screen — Skip for now (in the footer)
enters your workspace in read-only mode, or the Cloud runtime
card lets you join the waitlist.
```
**新**
```
Nothing coming through yet. If you're not comfortable with the
terminal, Desktop is the smoother path — it bundles the daemon.
Close this dialog and pick Desktop, or hit Skip to continue.
```
**理由**
- 在 stall 发生时主动把 Desktop 作为退路——这是用户最需要听到的
- 原文案把 Cloud waitlist 作为退路不合理(那是 soft exit不解决问题
---
### 3.12 `/download` 页面全新i18n 双语)
**位置**`apps/web/app/(landing)/download/page.tsx`(新建)
**页面结构 + 文案**
#### Hero 区(顶部主 CTA按 detect 结果拼出)
**检测到 macOS arm64**
- EN:
- H1: `"Multica for macOS"`
- Sub: `"Apple Silicon · bundled daemon, zero setup"`
- Primary button: `"Download (.dmg)"` → macArm64Dmg
- Alt link: `"or download .zip"` → macArm64Zip
- ZH:
- H1: `"Multica for macOS"`
- Sub: `"Apple Silicon · 内置 daemon无需额外配置"`
- Primary: `"下载 (.dmg)"`
- Alt: `"或下载 .zip"`
**检测到 macOS IntelChromium**
- EN:
- H1: `"Multica for macOS"`
- Sub: `"Apple Silicon required — Intel Macs not yet supported."`
- Primary button 样式: **muted + disabled**,文案 `"Apple Silicon required"`
- 次要段落:`"On an Intel Mac? Use the CLI below — it runs the same daemon."`
- ZH对应翻译
**检测到 Windows x64**
- EN:
- H1: `"Multica for Windows"`
- Sub: `"Bundled daemon, zero setup"`
- Primary: `"Download (.exe)"` → winX64Exe
- ZH: `"Multica for Windows"` / `"内置 daemon无需额外配置"` / `"下载 (.exe)"`
**检测到 Linux**
- EN:
- H1: `"Multica for Linux"`
- Primary: `"Download AppImage"` → linuxAmd64AppImage
- Alt links: `"or .deb / .rpm"`
- ZH: 对应翻译
**未检测 / SSR 初始状态**
- 默认渲染 macOS arm64 作为 H1占位JS hydration 后按 detect 替换
#### All Platforms 区(永远可见,在 Hero 下方)
**标题**
- EN: `"All platforms"`
- ZH: `"所有平台"`
**内容**:表格或卡片,每行一个包:
```
macOS · Apple Silicon (.dmg / .zip)
Windows · x64 (.exe) · ARM64 (.exe)
Linux · x64 (.AppImage / .deb / .rpm) · ARM64 (.AppImage / .deb / .rpm)
```
**Intel Mac 说明**
- EN: `"Apple Silicon only — Intel Macs not supported in this release."`
- ZH: `"仅支持 Apple Silicon——Intel Mac 目前暂不支持。"`
#### CLI 区(二级标题,独立 section
**标题**
- EN: `"Prefer the CLI?"`
- ZH: `"想用 CLI"`
**副文案**
- EN: `"For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal."`
- ZH: `"适合服务器、远程开发机、无图形界面环境。底层 daemon 和 Desktop 相同,通过终端安装。"`
**命令块**(复用 `CliInstallInstructions` 的样式):
```bash
# Install
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
# Start daemon
multica setup
```
**底部说明**
- EN: `"Already on a server? Same commands work over SSH."`
- ZH: `"已经在服务器上?通过 SSH 执行同样的命令即可。"`
#### Cloud 区(最小、置底)
**标题**
- EN: `"Cloud runtime (waitlist)"`
- ZH: `"Cloud runtime等待名单"`
**副文案**
- EN: `"We'll host the runtime for you. Not live yet — leave your email to be notified."`
- ZH: `"我们将为你托管 runtime目前尚未上线——留下邮箱上线后通知你。"`
**表单**:复用 `CloudWaitlistExpand``packages/views/onboarding/components/cloud-waitlist-expand.tsx`
#### Footer 区
- Release notes 链接:`"What's new in {version}"` → GitHub release tag URL
- All releases`"View all releases →"``https://github.com/multica-ai/multica/releases`
- 版本号小字:`"Current version: v0.2.13"`(来自 `/api/latest-version`
---
## 四、i18n Keys 规划
### 4.1 现有 key 复用
- `hero.downloadDesktop` — 保持landing hero 按钮文案
- `nav.desktop`**重命名为 `nav.download`**(需同步改 landing-nav 组件读的 key
### 4.2 新增 key 命名空间
```ts
// apps/web/features/landing/i18n/en.ts + zh.ts
{
// ... 现有 key ...
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",
},
},
allPlatforms: {
title: "All platforms",
macLabel: "macOS · Apple Silicon",
winX64Label: "Windows · x64",
winArm64Label: "Windows · ARM64",
linuxX64Label: "Linux · x64",
linuxArm64Label: "Linux · ARM64",
intelNote: "Apple Silicon only — Intel Macs not supported in this release.",
},
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.",
},
cloud: {
title: "Cloud runtime (waitlist)",
sub: "We'll host the runtime for you. Not live yet — leave your email to be notified.",
},
footer: {
releaseNotes: "What's new in {version}",
allReleases: "View all releases",
currentVersion: "Current version: {version}",
},
},
auth: {
login: {
extraDownloadPrompt: "Prefer the desktop app?",
extraDownloadCta: "Download",
},
},
}
```
中文翻译按 ZH 对照表同步填入 `zh.ts`
### 4.3 Onboarding 触点(单语英文)
所有 `step-welcome.tsx` / `step-platform-fork.tsx` / `cli-install-instructions.tsx` 的新文案**直接硬编码到 TSX**,不进 i18n——保持和当前 onboarding 代码风格一致。
---
## 五、文案审校清单
Step 2/3/4 实施时,逐条检查:
- [ ] 每个触点的文案都在本文档有定义(不临时发明)
- [ ] Landing / `/download` / Login extra 走 i18n双语齐备
- [ ] Onboarding 触点英文硬编码,与现有代码风格一致
- [ ] "3 minutes" 时间声明仅出现在 desktop 分支
- [ ] 没有 "easy / simple / just" 出现在 Desktop 或 CLI 文案里
- [ ] 所有 Download CTA 指向 `/download`,不再有直接指向 GitHub releases 的链接landing nav、landing hero、step 3 Desktop 卡点击、登录页 extra
- [ ] CLI 文案强调 server/remote/headless 场景,不再暗示"Desktop 的轻量版"
- [ ] Intel Mac 处处诚实标注,不欺骗
---
## 六、开放事项
- `/download` 页面的视觉风格是否跟 landing 一致serif 标题 / 背景色)?→ **建议跟 landing 一致**但本文档不锁死Step 2 UI 实现时决定
- 是否加"系统最低要求"区块?→ **不做**Cursor 有,但我们产品期不引入这种 clutter
- 是否在 `/download` 置顶放一个 `<video>` 或产品截图?→ **不做**保持克制landing 已承担营销角色)

View File

@@ -1,356 +0,0 @@
# Desktop 下载体系重设计 — 执行计划
**日期**2026-04-22
**作者**Naiyuan
**状态**:方案定稿,分步执行中
---
## 一、为什么要做
### 1.1 现状的核心矛盾
Multica 本质是"**本地 runtime + 云端协作**"的产品。Desktop app 内置 bundled daemon登录即用web 是预览/入口,不是等价平台。但**当前代码和文案把 Desktop 和 Web 当作等价路径**,结果:
1. **登录到 Step 3 之间,零 Desktop 推广**。用户建完 workspace 才被告知"其实你得装 app",此时沉没成本已高
2. **Step 3 分流屏三张卡Desktop / CLI / Cloud视觉和文案伪对称**,用户感知不到 Desktop 是正解
3. **`isMac` 过时门**Windows/Linux 桌面包已齐v0.2.13),代码却把非 Mac 用户推去 CLI
4. **所有 Download 入口landing / Step 3 / footer都指向 GitHub releases 页面**——30+ assets 的列表,对非技术用户是灾难
5. **Welcome 屏 "Takes about 3 minutes" 对 web 用户是谎**——不含下载/安装/换端时间
### 1.2 Cursor 对标带来的确认
Cursor 的 `/download` 页面(<https://cursor.com/cn/download>)模式:
- **Client-side auto-detect**:用 `navigator.userAgentData.getHighEntropyValues(['architecture'])`,精确到 archfallback 到 UA 字符串
- **SSR 发全量 HTML**(所有 OS 内容都在hydration 后 JS 挑对应平台作主 CTA
- **三个并列 surface**Desktop / Terminal / Web——不是"三选一",是**三个场景**
-`useDownloadTracking` hook埋点下载事件
这验证了"桌面主推 + 其他平台可见 + CLI 作为独立场景"的正确性。
---
## 二、核心洞察(从代码扒出来的)
### 2.1 两端用户是两种心态,不是两种路径
| 维度 | Desktop 用户 | Web 用户 |
|---|---|---|
| 入口 | 主动下 .app越过安装门槛 | 浏览器打开,零投入 |
| 心态 | **投入者**"我认真对待 Multica" | **探索者**"先试试" |
| 对本地安装的态度 | 已接受 | **主动拒绝过**(选 web 就是为了不装) |
| Step 3 的本质 | 确认屏daemon 已在跑) | 决策屏(产品真相首次披露) |
这解释了为什么 Step 3 在 web 上是漏斗流失点——**那是"你以为是 web 产品 / 实际是本地产品"的期望违约时刻**。
### 2.2 CLI 不是 Desktop 的低配版,是另一个场景
Desktop 和 CLI 跑的是**同一个 Go 二进制**`daemon-manager.ts` spawn 的 bundled CLI。区别仅在 **setup moment**
| 维度 | Desktop | CLI |
|---|---|---|
| 安装 | 双击 .dmg | `curl \| bash` |
| 启动 | `daemonAPI.autoStart()` 登录后自动 | `multica setup` 手动 |
| 运行后能力 | 完全等价 | 完全等价 |
| **真正适合的场景** | 个人机器、交互使用 | **服务器、远程 dev box、on-prem、自动化、CI** |
CLI 的合法性不来自"Desktop 不可得时的替代",来自它**真的有 Desktop 永远覆盖不了的场景**
- Self-host / on-premdaemon 跑在自有服务器
- 远程 dev boxSSH 到 Linux 机器,在那里跑 daemon
- CI/CDheadless 环境里调度 agent
- 多机部署:一个人,多台 runtime
**文案必须攻击 setup moment 不对称,而不是比较能力**——因为能力是一样的。
### 2.3 当前代码和资产状态
**已有的完整跨平台包v0.2.13**
| 平台 | 包 | 大小 |
|---|---|---|
| macOS arm64 | `.dmg` / `.zip` | 184MB |
| macOS Intel (x64) | ❌ 无(`electron-builder.yml` 只 target arm64 | — |
| Windows x64 | `.exe` (NSIS) | 140MB |
| Windows arm64 | `.exe` | 430MB |
| Linux | `.AppImage` / `.deb` / `.rpm`amd64 + arm64 | 113189MB |
**代码当前状态**
- Desktop 端 Step 3 里根本没有 CLI 这张卡(`StepRuntimeConnect.EmptyView` 只有 Skip + Cloud waitlist——产品团队自己的立场是"bundled daemon 是 Multica 的本分"
- Web 端 Step 3`StepPlatformFork`)把 CLI 作为一等平级卡,`isMac` 门 disabled 非 Mac 的 Desktop CTA
- 所有 Download 链指向 `https://github.com/multica-ai/multica/releases/latest`
---
## 三、定位策略(文案骨架)
每个 surface 用同一套"场景导向"句子,不比较能力:
| 形态 | 一句话定位 | 适合谁 |
|---|---|---|
| **Desktop** | "Your personal machine. Double-click, agents run locally." | 95% 新用户 |
| **CLI** | "Servers, remote boxes, on-prem, automation. No GUI required." | 开发者 / 运维 / 自建 |
| **Cloud**waitlist | "No local install — we host the runtime for you." | 评估 / 不想本地跑 |
**Welcome 屏在 web 分支追加一条引导**:诚实告诉用户"Better on desktop",给一个 Download 按钮 + "Continue on web" 次选。
---
## 四、已做的决定
| 决策 | 选择 | 理由 |
|---|---|---|
| 语言 | **中英双语**,跟 landing 的 `en.ts` / `zh.ts` 一致 | 保持全站 i18n 体系 |
| Intel Mac | **暂不支持**`/download` 页诚实标注 | 2026 年 Intel Mac 已是 4+ 年前老机器包体积翻倍影响所有人CLI 对 Intel 用户是合理路径 |
| 版本号获取 | **Next.js API route 代理 GitHub API**`/api/latest-version`Vercel ISR 5 分钟 cache | `latest.yml` 无 CORSbuild-time 注入需每次重部署GitHub API 未认证 60/hr 对 5min cache 绰绰有余 |
| 部署 | Vercel已确认 | ISR 原生支持,`export const revalidate = 300` 一行解决 |
| Desktop 链接 URL | 客户端按 detect 结果拼 GitHub asset 直链 | 无需后端端点;带版本号的 URL 从 `/api/latest-version` 返回的 assets 里取 |
| Auto-detect 方式 | `navigator.userAgentData.getHighEntropyValues(['platform','architecture'])` + UA 字符串 fallback | 抄 Cursor 模式Safari 无此 APImacOS 下无法区分 Intel/arm——默认推 arm64 + 诚实文案 |
| CLI 在 onboarding 的定位 | 保留 Step 3 第二张卡,但**文案重写为服务器/远程场景**,不再假装是 Desktop 的轻量版 | CLI 场景真实存在,但大多数 onboarding 用户不在这个场景里 |
| 开发顺序 | **Step 1 文案 → Step 2 `/download` → Step 3 Onboarding → Step 4 Login + Landing** | Step 1 确立真相源,后续 UI 改动有唯一文案来源,可并行 |
---
## 五、执行步骤
### Step 1 · 文案与定位对齐(不写代码)
**做什么**
-`docs/download-positioning.md`(本文档的姊妹文档,专门放文案)
- 三个 surface 的定位句(中英)
- 盘点所有触点的**当前文案 vs 新文案**
- Landing hero`landing-hero.tsx`
- Landing nav + footer 链接(`landing/i18n/en.ts``zh.ts`
- Login page`packages/views/auth/login-page.tsx`)——新增 Desktop CTA
- Welcome step`step-welcome.tsx`——web 分支新增 Desktop CTA
- Step 3 三张卡(`step-platform-fork.tsx`
- `/download` 页面(全新)
- `CliInstallInstructions``cli-install-instructions.tsx`
- 每条文案带中英双语对照
**目标**:后续所有 UI 改动有唯一文案真相源,不临时发明
**产出**1 个 markdown doc
**工期**0.5 天
**产物文件**`docs/download-positioning.md`
### Step 2 · `/download` 页面
**做什么**
- **路由**`apps/web/app/(landing)/download/page.tsx`(放 landing group 共享 layout
- **SSR**:全量渲染 Desktop / CLI / Cloud 三块,所有平台包都在 HTML 里
- **API route**`apps/web/app/api/latest-version/route.ts`
-`https://api.github.com/repos/multica-ai/multica/releases/latest`
- 解析 assets按文件名模式抽出每个平台的 URL
- 返回 `{ version, assets: { macArm64, winX64, winArm64, linuxDeb, linuxRpm, linuxAppImage, ... } }`
- Vercel ISR`export const revalidate = 300`
- **Client detect**`packages/views/utils/os-detect.ts`(新建)
- 优先用 `navigator.userAgentData.getHighEntropyValues(['platform','architecture'])`
- Fallback 到 `navigator.userAgent` + `navigator.platform`
- 返回 `{ os: 'mac'|'windows'|'linux'|'unknown', arch: 'arm64'|'x64'|'unknown' }`
- **UI 行为**
- 顶部大 CTA按 detect 结果拼好的 Desktop 下载按钮macOS arm64 / Windows x64 / Linux AppImage 作主推)
- 检测到 Intel MacChromium→ 主 CTA 变成"Apple Silicon required — use CLI"CLI 区块置顶
- 检测到 Safari on macOS → 默认推 arm64 + 小字提示"On Intel Mac? Use CLI"
- 全平台直链列表arch 清晰标注)
- CLI 区块:`curl | bash` + 场景说明
- Cloud 区块:复用 `CloudWaitlistExpand`
- **i18n**`apps/web/features/landing/i18n/en.ts` / `zh.ts` 新增 `download` 命名空间
**目标**:全站下载总入口,版本自动更新,用户下到对的包
**工期**1-2 天
**产物文件**
- `apps/web/app/(landing)/download/page.tsx`
- `apps/web/app/api/latest-version/route.ts`
- `packages/views/utils/os-detect.ts`(或 `packages/core/platform/os-detect.ts`
- `apps/web/features/landing/i18n/en.ts` + `zh.ts` 新增 keys
- `apps/web/features/landing/components/download-page.tsx`主组件landing 风格)
### Step 3 · Onboarding 修缮
**做什么**
- **Welcome 屏 web 分支**
- `OnboardingFlow` 里派生 `isWeb = !!runtimeInstructions`,传给 `StepWelcome`
- `StepWelcome``isWeb`CTA 区域追加一行 "Better on desktop — bundled daemon, zero setup" + **Download 按钮**(指 `/download`+ "Continue on web" 次选
- "Takes about 3 minutes" 文案按平台差异化
- **Step 3 分流屏**
- 拆掉 `isMac` 门(`step-platform-fork.tsx:244-268`
- Desktop 卡对所有平台 active按 detect 显示对应平台文案
- Non-Mac 兜底卡改成 Cloud waitlist 强化,不再假装推 CLI
- 三张卡的文案按 Step 1 确定的定位句重写
- **CLI dialog**
- `CliInstallInstructions` 加一行场景说明:"Also great for servers and remote dev boxes."
- `multica setup` 命令旁边保留现状
- **"Downloading"后态**
- Desktop 卡点击后的 downloaded 态文案改得更明确("Check your Downloads folder. Open the .dmg to install."
**目标**Welcome 不再骗 web 用户Step 3 三张卡场景清晰Windows/Linux 用户不再被推 CLI
**工期**0.5 天
**产物文件**
- `packages/views/onboarding/onboarding-flow.tsx`
- `packages/views/onboarding/steps/step-welcome.tsx`
- `packages/views/onboarding/steps/step-platform-fork.tsx`
- `packages/views/onboarding/steps/cli-install-instructions.tsx`
### Step 4 · 上游漏斗Login + Landing
**做什么**
- **Login page**
- `packages/views/auth/login-page.tsx``LoginPageProps``extra?: ReactNode` prop
- Google 按钮下方低调一行 "Prefer the desktop app? **Download →**"
- Desktop 调用方(`apps/desktop/src/renderer/src/pages/login.tsx`**不传** extra → 不显示
- Web 调用方(`apps/web/app/(auth)/login/page.tsx`**传** extra → 显示
- **Landing hero**
- `landing-hero.tsx:44-65` 的 Download 按钮从 `heroButtonClassName("ghost")` 升级为 `heroButtonClassName("solid")`(或至少主次分明的 outline
- href 从 `https://github.com/multica-ai/multica/releases/latest` 改为 `/download`
- **Landing nav + footer**
- `landing/i18n/en.ts:230` / `zh.ts:230` 的 Desktop 链接统一改为 `/download`
**目标**:用户最轻投入时刻就看到 DesktopStep 3 之前已有两次 Desktop touch
**工期**2 小时
**产物文件**
- `packages/views/auth/login-page.tsx`
- `apps/web/app/(auth)/login/page.tsx`
- `apps/desktop/src/renderer/src/pages/login.tsx`(确认不传 extra 即可)
- `apps/web/features/landing/components/landing-hero.tsx`
- `apps/web/features/landing/i18n/en.ts` + `zh.ts`
---
## 六、不做的事(明确范围)
- **后端 `/api/download?os=X&arch=Y` 302 端点**:方案 A 已够用,后端不动
- **下载埋点/数据分析**本次不做Cursor 有但我们暂缓
- **下载后 "waiting on desktop" 屏**:让 handoff 更丝滑的想法,留到数据出现再决定
- **Intel Mac universal build**:暂不补,`/download` 诚实标注"暂不支持"
- **CLI 文档页 / 自托管文档**Step 3 CLI 卡副文案引向 docsdocs 本身不在本次范围
- **/download 页的 "system requirements" 区块**:不做详细 minimum specs保持简洁
---
## 七、技术细节速查
### 7.1 OS + Arch Detection
```typescript
// 推荐实现骨架
export async function detectOS(): Promise<{ os: OSName; arch: Arch }> {
// 优先用 userAgentDataChromium
if (navigator.userAgentData?.getHighEntropyValues) {
try {
const data = await navigator.userAgentData.getHighEntropyValues([
"platform",
"architecture",
]);
// data.platform: "macOS" | "Windows" | "Linux"
// data.architecture: "x86" | "arm"
return normalizePlatform(data);
} catch {
// fall through
}
}
// Fallback: UA 字符串 + navigator.platform
const ua = navigator.userAgent;
const platform = navigator.platform || "";
// ... 按 "Mac" / "Windows" / "Linux" 分支
}
```
**已知限制**Safari on macOS 无法区分 Intel/arm64Apple 故意不暴露)。默认推 arm64 + 诚实文案。
### 7.2 `/api/latest-version` Response Shape
```typescript
{
version: "v0.2.13",
publishedAt: "2026-04-21T13:13:52Z",
assets: {
macArm64Dmg: "https://github.com/.../multica-desktop-0.2.13-mac-arm64.dmg",
macArm64Zip: "https://github.com/.../multica-desktop-0.2.13-mac-arm64.zip",
winX64Exe: "https://github.com/.../multica-desktop-0.2.13-windows-x64.exe",
winArm64Exe: "https://github.com/.../multica-desktop-0.2.13-windows-arm64.exe",
linuxAmd64AppImage: "https://github.com/.../multica-desktop-0.2.13-linux-x86_64.AppImage",
linuxAmd64Deb: "https://github.com/.../multica-desktop-0.2.13-linux-amd64.deb",
linuxAmd64Rpm: "https://github.com/.../multica-desktop-0.2.13-linux-x86_64.rpm",
linuxArm64AppImage: "...",
linuxArm64Deb: "...",
linuxArm64Rpm: "...",
}
}
```
Asset 文件名模式由 `electron-builder.yml` 定义:`multica-desktop-${version}-${platform}-${arch}.${ext}`。解析靠正则匹配。
### 7.3 Vercel ISR 配置
```typescript
// apps/web/app/api/latest-version/route.ts
export const revalidate = 300; // 5 min
export async function GET() {
const res = await fetch(
"https://api.github.com/repos/multica-ai/multica/releases/latest",
{ next: { revalidate: 300 } }
);
if (!res.ok) {
return Response.json({ error: "upstream" }, { status: 502 });
}
const data = await res.json();
return Response.json({
version: data.tag_name,
publishedAt: data.published_at,
assets: parseAssets(data.assets),
});
}
```
### 7.4 Welcome 屏 `isWeb` 派生
```typescript
// onboarding-flow.tsx
const isWeb = !!runtimeInstructions;
// 传给 StepWelcome
<StepWelcome
onNext={handleWelcomeNext}
onSkip={canSkipWelcome ? handleWelcomeSkip : undefined}
isWeb={isWeb}
/>
```
---
## 八、执行追踪
- [x] Step 1 · 文案 doc → `docs/download-positioning.md`
- [x] Step 2 · `/download` 页面(分支 `NevilleQingNY/download-redesign`
- [ ] Step 3 · Onboarding 修缮
- [ ] Step 4 · 上游漏斗
### Step 2 产出
- 新文件:`apps/web/app/(landing)/download/page.tsx` + `download-client.tsx`
- 新组件:`apps/web/features/landing/components/download/{hero,all-platforms,cli-section,cloud-section,os-icons}.tsx`
- 新 utils`apps/web/features/landing/utils/{os-detect,parse-release-assets,github-release}.ts`
- 扩展 i18n`types.ts``download` + `auth.login.extra*``en.ts` + `zh.ts` 填双语
- Nav 更新landing footer 的 "Desktop" / "桌面端" 链接 → "Download" / "下载"(指 `/download`
- `@multica/views/onboarding` 新 export`CloudWaitlistExpand``/download` 的 Cloud 区块复用)
### 本地开发注意
GitHub Releases API 未认证限流是 **60 req/hr per IP**。Vercel 生产环境的 fetch cache 跨所有 region 共享,每 5 分钟(`revalidate: 300`)全局最多 1 次调用,远低于限流。但**本地开发** + 共享办公室 IP 容易打爆限流,命中后页面降级到"Version unavailable"。
本地跑 `/download` 如遇到版本信息缺失:
1. 设置 `GITHUB_TOKEN` 环境变量Personal Access Token公共仓库不需要 scope
2. `fetchLatestRelease` 会自动带 `Authorization: Bearer <token>` header限流提到 5000 req/hr
3. Token 只在 server-side 用,不会泄漏到客户端
每完成一步,勾掉 checkbox 并在对应 section 底部补一行实际 commit hash。

View File

@@ -1,611 +0,0 @@
# Onboarding 重新设计 — 项目提案
**日期**2026-04-21
**作者**Naiyuan
**状态**:方案定稿,待评审后进入执行
---
## 一、为什么要做
### 1.1 数据层面的两个漏斗
当前产品数据暴露了两个关键的用户流失点:
1. **第一漏斗**:很多用户创建完 workspace 后,**从未连接本地 daemon**。没有 runtime = 没有 agent = 产品价值归零。这是最严重的漏斗。
2. **第二漏斗**:连接了 daemon 的用户中,**约一半从未创建 issue**。他们跨过了最难的技术门槛,却倒在了空 issue 列表面前——因为"该让 agent 做什么"对新用户并不直观。
这两个漏斗说明:**我们把用户送到了门口,但没有送他们进门**。
### 1.2 当前 Onboarding 的不足
代码层面现状(`packages/views/onboarding/` + `apps/web/app/(auth)/onboarding/page.tsx` + `apps/desktop/src/renderer/src/components/window-overlay.tsx`
| 环节 | 现状 | 问题 |
|---|---|---|
| Welcome | 纯打招呼 + "Get started" 按钮 | 0 价值、+1 次点击、文案"takes about a minute"对 web 用户不诚实 |
| Workspace 创建 | 复用 `CreateWorkspaceForm` | ✅ 基本合理,保留 |
| Runtime 连接 | Desktop 静默、Web 显示 CLI 指南 | ✅ 机制对,但 web 体验上**一路走到第 3 步才撞上 CLI 这堵墙**,没有提前分流 |
| Agent 创建 | 2 个模板Master / Coding+ 手填 name | Master 模板对 96% 的 solo 用户是噪音;手填 name 是多余决策;没有 Assistant 这种零门槛兜底 |
| Complete | 仪式感庆祝 + "Enter workspace" | **aha moment 没发生**。用户被告知 agent 准备好,却看不到它工作,进去就是空 issue 列表——正好是第二漏斗 |
| 个性化 | 无 | 所有用户看到同一套流程,不利用任何已知信息 |
| 进度持久化 | `useHasOnboarded()` 硬编码 `false` | 中途退出会从头开始;跨端切换完全无法恢复 |
### 1.3 行业对标
调研多篇一线案例和数据后,业界已收敛到几条硬原则:
- **激活 > 教育**Onboarding 唯一的 KPI 是用户到达 aha moment 的速度和比例。Slack 的 "2000 条消息 → 留存 93%" 是最经典案例
- **2 分钟到首次价值**:通用 SaaS 目标
- **<90 秒 TTFAC**Stripe / Vercel 为开发者工具设定的标杆
- **开发者工具转化率天然低**:通用 SaaS 试用转化 1525%,开发者工具只有 815%**68% 放弃原因是 setup 太复杂**
- **问卷是杀手**:每多一个表单字段完成率下降 35%,某 case 强制问卷导致转化率下降 80%+,另一 case 6→3 题响应率 +11%
- **Progressive disclosure 淘汰前置大 tour**:学习应该分散在使用过程中,不是一次性塞给用户
- **Notion 模式是黄金范本**1 题驱动模板选择 + 邮件路径 + 界面预览——"一题多用"
### 1.4 对标 Multica 的定位
Multica 不是"做一个 agent"的产品。它的核心价值是**把一支由用户编排的 AI agent 小队组织起来协作**——一个 agent 写代码、一个规划任务、一个做研究、一个写内容——每个 agent 是带配置provider / runtime / instructions / skills的独立工作者像同事一样被指派 issue。
这意味着:
- 用户不是单一场景("AI 帮我写代码"),而是多角色用户都在编排 agent开发者、产品 / 项目负责人、writer、founder
- "用户在用什么本地 CLI"是 daemon 自动探测的技术事实(`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor-agent` 扫 PATH 即可),**不需要问用户**
- 真正值得问的是**用户是谁、想让 agent 干什么**——这个答案驱动 Step 4 模板、Step 5 first issue 和 Onboarding Project 的内容
---
## 二、调研结论与核心原则
- 主流程必须严格以激活为目的——Welcome、功能介绍、问卷这些"非激活"内容都要极限压缩或后置
- 问卷题数 ≤3 题,且每题答案必须能直接改变下游某个屏的内容,否则砍掉
- "Onboarding Project + sub-issues" 属于**教育载体**,不是 onboarding 主流程——它应该在 aha moment 发生后以侧边栏常驻形式出现
- Web 不应该是 desktop 的"平行路径",而应该是**漏斗入口**:鼓励用户下载 desktop保留 web+CLI 作为备选
- 进度必须后端持久化,跨端 resume 是硬要求
主要 Sources 列在文末第八节。
---
## 三、方案要点
### 3.1 主流程5 步(严格有序)
```
Step 0: Welcome (产品介绍, 首次进入时展示, 不入后端 state)
Step 1: 3-Q 问卷 team_size / role / use_case
Step 2: 创建 workspace
Step 3: 连接 runtime ← 两端最大差异在这一步
Step 4: 创建 agent ← 按 Q1 × Q3 预填
Step 5: 🎯 First Issue ← aha moment按 Q3 驱动文案
```
**Onboarding Project** 在 Step 5 完成的那一刻后台创建,作为进入 workspace 之后的侧边栏常驻项——**不算 onboarding 的一步**。
### 3.2 两端差异表
| Step | Desktop | Web |
|---|---|---|
| 1. 问卷 | 一屏 3 题 | 一屏 3 题(完全一致) |
| 2. Workspace | `CreateWorkspaceForm` | 完全一致 |
| 3. Runtime | **静默自动**bundled daemon 12s 内 online → 直接跳 Step 4。只在失败时显示诊断 | **分流决策屏**(见 3.3 |
| 4. Agent | 一键 Create按 Q1×Q3 预填模板 + provider | 完全一致 |
| 5. First issue | 跳到 issue 详情页,观察 agent reply | 完全一致 |
唯一真正不同的是 Step 3。其他"差异"本质是问卷答案驱动的个性化,跨端一致。
### 3.3 Web 端 Step 3 分流屏
这是 web 用户创建完 workspace 后看到的屏,**取代当前直接展示 CLI install 指南的做法**
```
┌─────────────────────────────────────────────┐
│ Multica runs on your machine │
│ Agents need a local runtime to run. │
│ How would you like to set up? │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ [Primary CTA, 80% 视觉权重] │ │
│ │ ⬇ Download for macOS (recommended) │ │
│ │ Fastest setup, bundled runtime │ │
│ └───────────────────────────────────────┘ │
│ │
│ Or: Continue on web with CLI │
│ Or: I want cloud agents (join waitlist) │
└─────────────────────────────────────────────┘
```
三条路径:
- **下载桌面端(默认,目标 60%+**:点下载 → 写 `platform_preference: "desktop"` → 桌面端装完登录同账号 → 后端 state 触发跳 Step 3 → bundled daemon 1s pass → 进 Step 4
- **CLI 继续(次选)**:保留现有 `CliInstallInstructions`,但新增预期管理("通常 24 分钟")和 60s stuck-state fallback"Stuck? 常见问题"
- **Cloud waitlistsoft exit**:邮箱 capture → 标记为"临时完成"`onboarded_at` 写当前时间,保留 `cloud_waitlist_email`)→ 进 workspace + 顶部 banner
### 3.4 三个问题的设计
**Q1Who will use this workspace?**(单选)
- ○ Just me
- ○ My team (210 people)
- ○ Other ⇒ 展开 80 字符文本框
注意:删掉了"Just exploring for now"——它本质是"态度"而不是"人数结构",和这题的题意不契合;评估型用户如果真的选项都不合适,可以通过 Other 写自由文本("just trying it out" 等)表达。
**Q2What best describes you?**(单选)
- ○ Software developer
- ○ Product / project lead
- ○ Writer or content creator
- ○ Founder / solo operator
- ○ Other ⇒ 展开 80 字符文本框
**Q3What do you want to do first?**(单选)
- ○ Write and ship code
- ○ Plan and manage projects
- ○ Research or write
- ○ Just explore what's possible
- ○ Other ⇒ 展开 80 字符文本框
**提交策略(必答)**
- Continue 按钮只在**三题全部有具体选择**时启用;否则禁用
- 任一问题选了 Other 但文本框为空 → 也禁用
- 从 Other 切回其他选项 → 对应的 `*_other` 字段自动清空
- **没有 Skip 路径**。理由:三个答案驱动 Step 4 agent template、Step 5 first-issue prompt、Onboarding Project sub-issue 排序partial 答案会在下游每一步都留洞。Other 自由文本(+ 80 字符上限)已经兜住所有非典型用户,不需要再开 null 这个口子
- 之前允许"全部不选 Skip"的策略在 commit 中已反悔——实测下来"给自由 = 问卷质量塌方"的风险比"多一点摩擦"更值得警惕
**"Other" 的下游价值——不是兜底,是 escape hatch + 个性化输入**
Q3 的 `use_case_other` 会**直接嵌入到 Step 5 first issue 的 prompt** 里:
> "Hi, I'm {user}. I told Multica I want to use you for \"{use_case_other}\". Introduce yourself and give me 3 concrete ways you could help with that."
也就是说,选 Other 的用户**反而**得到最个性化的 first issue——他们给 agent 的任务描述就是他们亲口写的。Q2 `role_other` 没有同样的嵌入位置,但会存进 state 给市场研究用。
**被砍掉的问题及理由**
- ~~"你在用哪些 AI agent"~~(原方案 Q1→ daemon 启动时自动扫 PATH 探测已安装的 CLI`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor-agent`),比问用户更准——用户可能说"我用 Claude Code"但 PATH 里并不存在。从"问"改成"测",问卷压掉一题
- ~~"你是做什么的"(职业)~~ → 原方案砍掉过现因为定位校准Multica 不是 coding-focused 产品),重新作为 Q2 加回,驱动 agent template 选择
- ~~"公司规模"~~ → solo/team 二分已经够用;具体公司规模属于 Day 3 邮件采集范围
- ~~"从哪里知道 Multica"~~ → 归因数据走分析系统,不占问卷位
### 3.5 个性化映射
所有个性化来自这三个答案 + daemon 自动探测到的 runtime 列表。**不做 Q 之外的任何猜测**——透明、可预期、可调试。
#### Runtime 优先级(来自 daemon 探测,不来自问卷)
Step 3 结束时 daemon 会报告"当前 PATH 上探测到的 CLI 列表"。Step 4 的 provider 预选逻辑:
| daemon 探测结果 | Step 4 provider 预选 |
|---|---|
| 有 online runtime | 第一个 online 的 provider |
| 列表非空但全 offline | 列表中第一个 |
| 列表为空Cloud waitlist 或 CLI 没装成功) | 不预选,在 Step 4 给用户手选或跳过 |
provider 值对齐 `packages/views/runtimes/components/provider-logo.tsx` 中已支持的:`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor`
#### Q1 (team_size) → Onboarding Project sub-issue 排序
| Q1 | Onboarding Project 顶部 sub-issue |
|---|---|
| `solo` | "Assign a real task to your agent" |
| `team` | **"Invite teammates"** 置顶 |
| `other` | 按 `solo` 路径处理(不强行归类;`team_size_other` 文本存下做市场研究) |
#### Q2 (role) → Step 4 agent template 默认选择(× Q3 细化)
Multica 是服务多角色 agent 编排用户的平台,不同 role 在 agent template 上应该看到默认的 role-matched 模板:
| Q2 role | Q3 use_case | 默认 template |
|---|---|---|
| `developer` | `coding` | Coding Agent |
| `developer` | `planning` | Planning Agent |
| `developer` | `writing_research` / `explore` / `other` | Coding Agent仍默认因为角色是开发者 |
| `product_lead` | `coding` | Coding Agent |
| `product_lead` | `planning` | Planning Agent |
| `product_lead` | `writing_research` / `explore` / `other` | Planning Agent |
| `writer` | `writing_research` | Writing Agent |
| `writer` | 其他 | Writing Agent |
| `founder` | 任意 | Assistantfounder 什么都干,通用兜底) |
| `other` | 任意 | Assistant |
**Agent 模板集从 3 个扩到 4 个**Coding Agent / Planning Agent / **Writing Agent新增** / Assistant。砍掉旧的 "Master Agent"(对 solo 用户完全不适用。Writing Agent 的增加是因为产品定位校准——原方案默认 coding-focused新方案支持 writer 作为一等用户。
#### Q3 (use_case) → Step 5 first issue prompt
First issue 的标题和 prompt 都由 Q3 单独驱动(与 Q2 role 解耦——同一个 role 做不同的 first task 是正常的):
| Q3 | First Issue 标题 | First Issue 描述(= 给 agent 的 prompt |
|---|---|---|
| `coding` | "Welcome me and show me what you can do" | "Hi, I'm {user}. I'll use you mostly for coding work. Introduce yourself and suggest 3 concrete coding tasks I could try." |
| `planning` | "Help me plan my first project" | "Hi, I'm {user}. I want you to help me plan and break down work. Introduce yourself and suggest 3 types of projects we could tackle." |
| `writing_research` | "Show me how you help with research and writing" | "Hi, I'm {user}. I'll use you for research and writing. Introduce yourself and give me 3 examples of how you can help — drafting, summarizing, analysis, etc." |
| `explore` | "What can you do?" | "Hi. I'm exploring what Multica can do. Give me a quick tour of what you can help with and suggest 3 concrete things to try." |
| `other` | "Help me with what I had in mind" | "Hi, I'm {user}. I told Multica I want to use you for \"{use_case_other}\". Introduce yourself and give me 3 concrete ways you could help with that." |
`{use_case_other}` 的嵌入是 Other 选项的关键价值——选 Other 的用户不是被降级成通用兜底,反而得到最精准的 first issue。
### 3.6 Onboarding Project 设计
Project 名称:"Getting Started"。在 Step 5 完成那一刻后台创建,包含以下 sub-issues。
**Core sub-issues所有用户都有**
1. **"Chat with your agent without creating an issue"**
> Some tasks are quick back-and-forth — you don't need a full issue. Open the chat panel from the top-right and try asking your agent a question.
2. **"Assign a real task to your agent"**
> You've seen your agent reply in this welcome issue. Now try assigning them something you actually need done. Create a new issue, describe the task, assign it to {agent_name}.
3. **"Write your Workspace Context"**
> Workspace Context is the shared system prompt every agent in this workspace sees. Tell them who you are, what you're building, and how they should behave. Go to Workspace settings → Context.
4. **"Create a second agent with a different role"**
> Multica's real power is running a small team of specialized agents. Create a Planning agent to complement your Coding agent, or a Writing agent to draft content. Go to Agents → "New agent".
5. **"Configure your agent's skills"**
> Skills let you give your agent specific tools and capabilities. Go to your agent's settings and try toggling a skill.
6. **"Set up an Autopilot for recurring work"**
> Autopilot creates issues on a schedule — daily standup summaries, weekly bug triage, monthly reports. Your agent picks them up automatically. Go to Autopilots → "New autopilot".
**Conditional sub-issues**(按答案插入 / 置顶 / 过滤):
- **Q1 = `team`** → "**Invite your teammates**" 置顶
- **Q2 = `developer`** 或 **Q3 = `coding`** → "**Connect a repo to your workspace**" 加入 core #2 之后
- **Q2 = `product_lead`** → "**Create a project with sub-issues**" 置顶
- **Q2 = `writer`** → 跳过 "Connect a repo"coding-specific其余 core 保留
- **runtime 列表为空**Cloud waitlist 或 CLI 未装成功)→ 插入 "**Install your first local runtime**" 置顶
**设计原则**:每个 sub-issue 都可以直接 assign 给 agent。Agent 读到 description 后,用自然语言给用户一句引导 + 一个具体建议。这样 sub-issue 既是"教程"又是"和 agent 互动"的自然场景——学习动作本身就是使用产品。
### 3.7 Resume 策略
**核心原则**:恢复到上次 step不重头开始MVP 阶段不设过期时间,允许任意回退改答案。
理由:
- Onboarding 总时长 <10 分钟,绝大多数用户一口气走完
- 中途离开再回来的,基本都是被别的事打断——重头开始是侮辱
- 过期策略7 天后重置之类)是用代码解决还没发生的问题——**等真观察到 abandon-return 模式再加**
跨端 resume 的完整行为表:
| 场景 | 预期行为 |
|---|---|
| Web 完成 Step 1&2关浏览器2h 后重开 web | 读 state → 跳过 Step 1/2 → 直接 Step 3 |
| Web 到 Step 3 点"下载桌面端",装完登录 desktop | Desktop 读 state → 跳 Step 3 → bundled daemon 1s pass → 进 Step 4 |
| Web 到 Step 3 点"下载桌面端"没装3 天后回 web | 检测到 `platform_preference=desktop` 但当前是 web → 显示 "Waiting for you on desktop" 屏 + "改用 web/CLI 继续" 入口 |
| Desktop Step 5 first issue 刚创建但没看 agent reply 就关闭 | 重开 desktop → current_step 仍是 `first_issue` → 直接打开那个 issue 详情页 |
| Onboarding 完成后再登录 | `onboarded_at` 非 null → 跳过 onboarding → 正常进 workspace |
| Onboarding 中创建的 workspace 被删(边缘 case | `workspace_id` 变 NULL → 下次进 onboarding 检测到 `current_step=runtime``workspace_id=null` → 回退到 Step 2 重新建 |
**"回退改答案" 的 UX 细节**:每一步有 "Back" 按钮回上一步。回退**不清空已保存的数据**——用户只是修改,不是重置。
---
## 四、后端数据设计
### 4.1 `user_onboarding` 表 schema
**设计决策**:稳定字段用列,灵活字段用 JSONB。问卷答案放 JSONB题目可能演化其他字段FK、控制字段、enum都是独立列。
```sql
CREATE TABLE user_onboarding (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- 控制状态
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
onboarded_at TIMESTAMPTZ, -- null = 未完成
current_step TEXT, -- null after onboarded_at
-- 'questionnaire'|'workspace'|'runtime'|'agent'|'first_issue'
-- 问卷答案(会演化,放 JSONB
questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb,
-- 期望结构:
-- {
-- "team_size": "solo" | "team" | "other", -- Q1
-- "team_size_other": "<= 80 chars" | null, -- Q1 自由文本(选 other 时必填)
-- "role": "developer" | "product_lead" | "writer" | "founder" | "other", -- Q2
-- "role_other": "<= 80 chars" | null, -- Q2 自由文本
-- "use_case": "coding" | "planning" | "writing_research" | "explore" | "other", -- Q3
-- "use_case_other": "<= 80 chars" | null -- Q3 自由文本(会嵌入 Step 5 prompt
-- }
-- Onboarding 产物FK要 join / 查询)
workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
runtime_id UUID REFERENCES agent_runtimes(id) ON DELETE SET NULL,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
first_issue_id UUID REFERENCES issues(id) ON DELETE SET NULL,
onboarding_project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
-- Platform 偏好(决定 handoff 和 resume 行为)
platform_preference TEXT, -- 'web' | 'desktop' | null
-- Cloud waitlist 支路soft exit 记录)
cloud_waitlist_email TEXT,
-- 约束
CONSTRAINT current_step_valid CHECK (
current_step IS NULL OR
current_step IN ('questionnaire','workspace','runtime','agent','first_issue')
),
CONSTRAINT onboarded_clears_step CHECK (
onboarded_at IS NULL OR current_step IS NULL
)
);
-- 只对未完成的做 index完成后不查analytics 用
CREATE INDEX idx_user_onboarding_incomplete
ON user_onboarding (updated_at)
WHERE onboarded_at IS NULL;
```
**几个关键决策的理由**
- **`ON DELETE SET NULL`** 而不是 CASCADE用户手动删了 onboarding 中创建的 workspace不应丢失整条 onboarding 记录。保留痕迹作为 analytics 信号,同时支持 3.7 表中"回退到 Step 2" 的自愈逻辑
- **`onboarded_clears_step` 约束**:保证不会出现"已完成但还在某 step"的脏状态,发现非法组合直接 DB 层拒绝
- **Partial index `WHERE onboarded_at IS NULL`**:绝大多数用户最终会完成,索引只关注未完成 cohort省空间且 query 更快
- **不存步骤时间戳历史**:步骤转化漏斗走 PostHog 事件系统(项目里 agent/j/db4fefb5 分支已经在做 analytics 基建state 表负责流程控制,事件系统负责分析。分工清晰,不混
### 4.2 API 设计
**读**
```
GET /api/me/onboarding
→ 200 OK { current_step, questionnaire, workspace_id, ... }
→ 404 if never started (客户端 treat as "start fresh")
```
**写(每步结束时)**
```
PATCH /api/me/onboarding
Body: {
current_step: "workspace", // 下一步
questionnaire: { ... }, // 只在 Step 1 提交
workspace_id: "ws_xxx", // 只在 Step 2 提交
// ... 对应字段
}
→ 200 OK { 完整 state }
```
**完成**
```
POST /api/me/onboarding/complete
Body: { first_issue_id, onboarding_project_id }
→ 200 OK { onboarded_at, current_step: null }
```
**关键**:每步结束立即 PATCH server。不要在前端 batch 到最后一起提交——这是 resume 能工作的前提。
### 4.3 State 流转
```
状态机:
(record not exists)
↓ 用户首次进 onboarding
current_step: "questionnaire"
↓ PATCH 提交问卷
current_step: "workspace" + questionnaire
↓ PATCH 工作区创建成功
current_step: "runtime" + workspace_id
↓ PATCH runtime 选择
current_step: "agent" + runtime_id
↓ PATCH agent 创建
current_step: "first_issue" + agent_id
↓ POST /complete
current_step: null + onboarded_at, first_issue_id, onboarding_project_id
支路Cloud waitlist:
current_step: "runtime"
↓ 用户选 cloud waitlist
current_step: null + onboarded_at + cloud_waitlist_email
```
---
## 五、当前代码影响面
### 5.1 后端Go
**新增**
- Migration`server/migrations/0xx_create_user_onboarding.up.sql` + `.down.sql`
- sqlc queries`server/pkg/db/queries/onboarding.sql`GetOnboarding / UpsertOnboarding / CompleteOnboarding
- Handler`server/internal/handler/onboarding.go`GET / PATCH / POST
- Router 挂载:`/api/me/onboarding` 路由组
- 可能需要:`GetUserOnboarding` 也需暴露给认证回调决定重定向(或前端自取)
**迁移 sqlc**`make sqlc` 重生成。
### 5.2 前端TypeScript / React
**新增**
- `packages/core/onboarding/types.ts``OnboardingState` 类型定义
- `packages/core/onboarding/queries.ts` — TanStack Query options
- `packages/core/onboarding/mutations.ts` — advance / complete mutation
- `packages/views/onboarding/steps/step-welcome.tsx` — 产品介绍屏(首次进入时展示;回访自动跳过)
- `packages/views/onboarding/steps/step-questionnaire.tsx` — 3 题问卷屏
- `packages/views/onboarding/steps/step-platform-fork.tsx` — web Step 3 的分流屏
- `packages/views/onboarding/steps/step-first-issue.tsx`**关键**aha moment 所在
- 可能拆分 `packages/views/onboarding/utils/personalization.ts` — Q1/Q2/Q3 → 下游映射的纯函数(方便单测)
**需要改动的现有文件**
- `packages/views/onboarding/onboarding-flow.tsx` — 移除本地 `useState<OnboardingStep>`,改读 `useOnboardingStore`;每次 step 转换调 `advance` mutation
- `packages/views/onboarding/steps/step-welcome.tsx`**删除**,内容合并到新的 step-questionnaire
- `packages/views/onboarding/steps/step-runtime.tsx` — web 分支改为渲染 `<StepPlatformFork />`
- `packages/views/onboarding/steps/step-agent.tsx` — 模板集改为 Coding / Planning / Writing / Assistant按 Q2×Q3 预填,新增"Advanced"折叠区让用户改 name
- `packages/views/onboarding/steps/step-complete.tsx` — **替换**为 StepFirstIssue或作为其前置过渡屏
- `packages/core/paths/resolve.ts``useHasOnboarded` 当前已从 store 读;联调期替换为 TanStack Query against `GET /api/me/onboarding`
- `packages/views/layout/use-dashboard-guard.ts` — guard 条件增加 `!hasOnboarded`,支持 "abandon 后回来自动回到 onboarding" 的 resume 行为
- `apps/web/app/(auth)/onboarding/page.tsx` — 调整 shell 以支持 resume读 state 决定进入哪一步)
- `apps/desktop/src/renderer/src/components/window-overlay.tsx` — 同上
- `apps/desktop/src/renderer/src/stores/window-overlay-store.ts` — 可能需要 `WindowOverlay` 类型微调
**不变**
- `packages/views/workspace/create-workspace-form.tsx` — 复用
- `packages/views/onboarding/steps/cli-install-instructions.tsx` — 仍用,在 CLI 分支里渲染
- 大部分 desktop 的 bundled daemon 启动逻辑 — Step 3 desktop 静默 pass 的前提
### 5.3 影响面估算
| 类别 | 数量 |
|---|---|
| 后端新文件 | ~4 |
| 后端修改文件 | 12router |
| 前端新文件 | ~6 |
| 前端修改文件 | ~10 |
| 测试新文件 | ~5核心逻辑 + personalization 映射 + resume scenarios |
---
## 六、成功指标(上线 30 天内评估)
参考调研结论设定:
| 指标 | 业界标杆 | Multica 目标 |
|---|---|---|
| Time-to-value | < 3 分钟 | Desktop 直达:≤ 3 minWeb→Desktop≤ 5 min含装机Web→CLI≤ 8 min |
| Onboarding 完成率 | 6080% | 目标 70% |
| Day 7 留存 | 2540% | 目标 30% |
| Activation 率 | 4060% | 目标 50% |
| Web→Desktop 转化Step 3 fork | in-product 高于 42% 冷推上限 | 目标 5070% |
**第一漏斗目标**workspace → runtime 连接率从当前水平提升至 80%+(主要靠 web 分流推 desktop 降 CLI 门槛)。
**第二漏斗目标**runtime → 首个 issue 由产品主动创建,比例应接近 100%(因为 StepFirstIssue 自动完成这件事)。
---
## 七、已做的决策(不再讨论)
| 决策 | 选择 | 理由 |
|---|---|---|
| 前置问卷题数 | **3 题**team_size / role / use_case | Notion 范式、调研甜蜜点;每题答案必须驱动下游内容 |
| 问卷 Q1 "已在用哪些 agent" | **不问**daemon 自动探测 PATH | 技术事实不该问用户;扫 PATH 比问答更准 |
| 问卷 Q2 role | **问**5 个具体选项 + Other | 驱动 Step 4 template 默认选择;用户画像数据回到一等位 |
| "Other" 选项机制 | **每题都有 Other**,点击展开 80 字符文本框 | Escape hatchQ3 use_case_other 还会嵌入 Step 5 first issue prompt |
| 问卷必填 | **全可选**Other 选了必填文本) | 给评估型用户零摩擦通道0 选时 Continue 变 Skip |
| Welcome 步骤 | **保留独立 welcome**,但改造为"产品介绍屏"(不是打招呼);只在首次进入时看到,回访 resume 自动跳过 | 多一次点击换来的是首次用户真正理解 Multica 是什么Multica 无心智对标物,没有前置介绍就进问卷 = 用户没有 frame of referenceWelcome 不入后端 state不影响 server schema |
| Web Step 3 分流 | **默认推 desktop**CLI 次选cloud waitlist 兜底 | 96% 是个人用户desktop 是最快路径 |
| Cloud waitlist 放哪 | **Web Step 3 分流屏**,不作为主步骤 | 保留原方案 #3 的数据价值,但不侵占主流程 |
| Agent 模板 | **4 个**Coding / Planning / Writing / Assistant砍 Master | Multica 服务多角色 agent 编排用户Writer 不能被 Assistant 兜底 |
| Onboarding Project | **不算步骤**Step 5 完成后台创建,侧边栏常驻 | Progressive disclosure 原则 |
| Resume 策略 | **恢复到上次 step不过期允许回退改答案** | 未见 abandon-return 数据前不提前优化 |
| Schema 方式 | **专门表 + JSONB 混合** | 稳定字段列化、灵活字段问卷JSON 化 |
| FK 删除行为 | **ON DELETE SET NULL**,不 CASCADE | 保留 analytics 痕迹 + 自愈能力 |
| 步骤时间戳 | **走 PostHog 事件系统**,不进 state 表 | 职责分离state 管流程events 管分析 |
| 进度 handoff 机制 | **纯后端 state**,不用 token 或 deep link | 用户 auth session 已绑身份,简化架构 |
| 开发顺序 | **前端全部搭完 → 后端实现 → 联调测试 → 上线** | 保持当前开发节奏不被后端阻塞;前端本身可以一个 step 一个 step 独立推进 |
| State 访问抽象 | **全部走 `useOnboardingStore()` 一个 hook**component 严禁直接碰 storage | 换后端时只动这一个文件component 不感知——让"先前端后后端"成本低的关键 |
---
## 八、开放问题 / 不在本次范围
- **Cloud agent runtime 本身**:本次只实现 waitlist 邮箱捕获,不做 cloud runtime。这是下一阶段的产品决策
- **Onboarding project sub-issue 文案的 iterate**:先上线现有文案(见 3.6),等真实用户反馈再打磨
- **A/B test 框架**:等用户量达到业界标准(每组 ≥500再启动现阶段全量发
- **个性化 Day 3 邮件**:问卷只问 3 题,剩余的用户画像数据(团队规模、角色等)可以后置到运营邮件收集,本次不实现
- **Onboarding 完成后的 re-engagement**:如"用户 7 天没创建第 2 个 agent 时发通知",属于 retention loop不属于 onboarding
- **自定义 agent template**:当前 3 个硬编码模板够用,自定义模板留到后面
---
## 九、执行计划
### 9.1 详细执行文档
本提案评审通过后,拆出 `docs/plans/2026-04-21-onboarding-redesign.md`,按现有 plan 文档格式(参考 `docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md`)精确到文件 + 行号 + 代码片段。
### 9.2 执行阶段
**原则:前端全部搭完 → 后端实现 → 联调测试 → 上线。**
目的是让当前开发节奏不被后端阻塞——前端可以一个 step 一个 step 独立迭代,每完成一个 step 都能在浏览器里直接看到效果。后端在前端定稿之后一次性实现,联调阶段统一解决跨端 resume 等场景。
**前端阶段**(按顺序推进,每个 step 独立可交付):
1. **建立 `useOnboardingStore()` 骨架**(已完成)——位于 `packages/core/onboarding/`。dev 期间是内存 Zustand store刷新重置方便迭代联调阶段换成 TanStack Query + PATCH mutation。严禁 component 绕过
2. **Step 1welcome + 问卷拆两屏)**:新建 `step-welcome.tsx`(产品介绍,首次进入时展示)+ `step-questionnaire.tsx`3 题);抽出 `<OptionCard>` / `<OtherOptionCard>` 复用组件
3. **Step 2workspace**:基本保留,接入 `useOnboardingStore()`
4. **Step 3runtime**:在 web 分支里新建 `step-platform-fork.tsx`desktop 分支保留静默自动CLI 分支加预期管理和 60s fallback
5. **Step 4agent**:模板集从 3 扩成 4加 Writing按 Q2×Q3 预填 template + providerprovider 来自 daemon 探测),移除手填 name 的强制性
6. **Step 5first issue**:新建 `step-first-issue.tsx`,这是 aha moment 发生的地方;`use_case=other` 时把 `use_case_other` 嵌入 prompt
7. **Flow orchestrator 改造**`onboarding-flow.tsx` 改由 `useOnboardingStore()` 驱动,不再用本地 useState 管 step 切换
8. **Web + Desktop shell 适配**:读 store 决定进入哪一步,支持单浏览器内的 resume
**后端阶段**
9. Migration + sqlc queries + handler + routerAPI shape 见 4.2
10. 按 4.1 schema 实现 `user_onboarding` 表 + partial index + 约束
**联调阶段**
11. `useOnboardingState()` 实现从 localStorage 切换为 TanStack Query + PATCH mutation——**component 0 改动**,这是 hook 抽象的回报
12. 跨端 / 多 session resume 全场景验证3.7 表)
13. E2E 覆盖 4 类用户路径 + 分流屏三条支路 + resume 一条
建议独立 worktree 开发(参考 `superpowers:using-git-worktrees`),避免污染主 checkout。
### 9.3 测试阶段
**本地自测**(按用户类型逐一跑):
- A 类solo + Claude Code + coding → 最短路径 3 分钟
- B 类team + Claude Code + coding/planning → 完成后侧边栏 "Invite teammates" 置顶
- C 类:无 agent + 评估 → web 分流选 cloud waitlist
- D 类solo + writing → Assistant 模板 + 对应 first issue 文案
**Resume 场景**(按 3.7 表逐一验证):
- Web 中途关浏览器 → 重开恢复
- Web → desktop 跨端 handoff
- Web 选下载未装 → 回 web 的"waiting"屏
- 已完成用户重登录 → 跳过 onboarding
**E2E** 测试必须覆盖:
- 完整 happy path至少 desktop A 类)
- Resume 一条
- 分流屏三条路径各一条
**上线指标监控**PostHog 看板跟踪第六节定义的 5 个 KPI上线后每周 review 一次2 周内若主指标偏离 20%+ 需排查。
---
## 十、调研参考
### 核心理论与激活
- [Chameleon — How to find your product's "Aha" moment](https://www.chameleon.io/blog/successful-user-onboarding)
- [Amplitude — The "Aha" Moment: A Guide](https://amplitude.com/blog/aha-moment)
- [Growth Letter — Slack's $3B Growth Loop](https://www.growth-letter.com/p/slacks-3-billion-growth-strategy)
- [June.so — Activation Playbook](https://www.june.so/blog/activation-playbook)
### 开发者工具特有数据
- [Daily.dev — Developer Onboarding Optimization](https://business.daily.dev/resources/developer-onboarding-optimization-from-first-click-to-paying-customer/)
- [Startup Design Journal — Hidden Micro-Friction Killing Conversion](https://startupdesignjournal.com/p/the-hidden-micro-friction-thats-killing)
### 问卷 / 表单 drop-off
- [involve.me — 6→3 题 +11% case](https://www.involve.me/blog/case-study-how-we-use-an-onboarding-survey-in-a-saas-product)
- [SaaSFactor — Why Users Drop Off During Onboarding](https://www.saasfactor.co/blogs/why-users-drop-off-during-onboarding-and-how-to-fix-it)
- [GrowthMentor — Friction Case Study](https://www.growthmentor.com/blog/user-onboarding-friction/)
- [Formbricks — Essential Onboarding Survey Questions](https://formbricks.com/blog/onboarding-survey-questions)
### Progressive Disclosure
- [LogRocket — Progressive Disclosure](https://blog.logrocket.com/ux-design/progressive-disclosure-ux-types-use-cases/)
- [Pendo — Onboarding, Progressive Disclosure, Memory](https://www.pendo.io/pendo-blog/onboarding-progressive-disclosure/)
- [Interaction Design Foundation — Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
### Notion / Linear 案例
- [Candu — How Notion Crafts Personalized Onboarding](https://www.candu.ai/blog/how-notion-crafts-a-personalized-onboarding-experience-6-lessons-to-guide-new-users)
- [Appcues Goodux — Notion's Lightweight Onboarding](https://goodux.appcues.com/blog/notions-lightweight-onboarding)
- [DesignerUp — 200 Onboarding Flows Studied](https://designerup.co/blog/i-studied-the-ux-ui-of-over-200-onboarding-flows-heres-everything-i-learned/)
### Schema / 持久化
- [Shekhar Gulati — When to use JSON data type](https://shekhargulati.com/2022/01/08/when-to-use-json-data-type-in-database-schema-design/)
- [TigerData — Wide vs Narrow Postgres Tables](https://www.tigerdata.com/learn/designing-your-database-schema-wide-vs-narrow-postgres-tables)
- [DbSchema — PostgreSQL JSONB Operators](https://dbschema.com/blog/postgresql/jsonb-in-postgresql/)
- [Pravin Tripathi — Start and Resume Journey for Onboarding](https://medium.com/@pravinyo/approaches-for-start-and-resume-journey-for-user-onboarding-to-platform-part-i-e077c73b4cd7)
### A/B 测试 & 分段
- [Appcues — A/B Testing Onboarding Flows](https://www.appcues.com/blog/flow-variation-a-b-testing)
- [M Accelerator — A/B Testing Onboarding Guide](https://maccelerator.la/en/blog/entrepreneurship/ultimate-guide-to-ab-testing-onboarding-flows/)
- [CXL — Segment A/B Test Results](https://cxl.com/blog/segment-ab-test-results/)
### 2025 综合最佳实践
- [Aakash Gupta — 10 Customer Onboarding Best Practices for PMs 2025](https://www.aakashg.com/customer-onboarding-best-practices/)
- [ProductLed — SaaS Onboarding Best Practices 2025](https://productled.com/blog/5-best-practices-for-better-saas-user-onboarding)
- [Branch — Desktop-to-App Conversions](https://www.branch.io/resources/blog/optimizing-desktop-web-to-app-conversions/)

View File

@@ -1,983 +0,0 @@
# 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 | get | ping | delete
```
#### 配置 / 更新
```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

@@ -6,7 +6,6 @@
"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

@@ -1,113 +0,0 @@
/**
* 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

@@ -1,88 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock posthog-js before importing the module under test so the module's
// top-level `import posthog from "posthog-js"` resolves to the mock.
vi.mock("posthog-js", () => {
const mock = {
init: vi.fn(),
register: vi.fn(),
reset: vi.fn(),
identify: vi.fn(),
capture: vi.fn(),
};
return { default: mock };
});
// Re-import per test so module-level `initialized` / cached super-props
// don't leak between cases.
async function loadModule() {
vi.resetModules();
const analytics = await import("./index");
const posthog = (await import("posthog-js")).default as unknown as {
init: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
};
posthog.init.mockClear();
posthog.register.mockClear();
posthog.reset.mockClear();
return { analytics, posthog };
}
beforeEach(() => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("initAnalytics super-properties", () => {
it("registers client_type and app_version after posthog.init", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
expect(posthog.register).toHaveBeenCalledWith({
client_type: "web",
app_version: "1.2.3",
});
});
it("omits app_version when not provided", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
expect(posthog.register).toHaveBeenCalledWith({ client_type: "web" });
});
it("detects desktop when window.electron is present", async () => {
vi.stubGlobal("window", { electron: {} });
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
expect(posthog.register).toHaveBeenCalledWith({ client_type: "desktop" });
});
});
describe("resetAnalytics", () => {
it("re-registers super-properties after reset so subsequent events keep client_type", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
posthog.register.mockClear();
analytics.resetAnalytics();
// reset() wipes persisted super-props; we re-register the cached set so
// the next session's events keep client_type + app_version.
expect(posthog.reset).toHaveBeenCalledTimes(1);
expect(posthog.register).toHaveBeenCalledWith({
client_type: "web",
app_version: "1.2.3",
});
});
it("is a no-op when analytics was never initialized", async () => {
const { analytics, posthog } = await loadModule();
analytics.resetAnalytics();
expect(posthog.reset).not.toHaveBeenCalled();
expect(posthog.register).not.toHaveBeenCalled();
});
});

View File

@@ -1,335 +0,0 @@
// Frontend analytics glue. Thin wrapper over posthog-js.
//
// The source-of-truth event catalog is `docs/analytics.md`. This module only
// handles the two things the backend can't do itself: attribution capture on
// first anonymous pageview, and person-identity merge on login. Every funnel
// event (signup, workspace_created, runtime_registered, issue_executed,
// invite_sent, invite_accepted) is emitted server-side — see
// `server/internal/analytics`.
//
// Configuration comes from the backend's `/api/config` response (populated
// from POSTHOG_API_KEY on the server), NOT from NEXT_PUBLIC_* envs. That
// keeps self-hosted Docker images from leaking our project key — their
// backend returns an empty key and this module stays inert.
import posthog from "posthog-js";
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
// Per-value cap keeps a long utm_content from blowing the budget. We drop
// the entire cookie if the JSON still exceeds the overall limit — partial
// JSON is worse than no attribution because PostHog can't parse it.
const SIGNUP_SOURCE_VALUE_MAX_LEN = 96;
const SIGNUP_SOURCE_MAX_LEN = 512;
const UTM_KEYS = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
"utm_term",
] as const;
let initialized = false;
// auth-initializer fetches /api/config and /api/me in parallel — on a
// slow-config path, identify() can fire before initAnalytics(). Buffer the
// most recent pending identify (only one matters, since it's per-session)
// and flush it inside initAnalytics.
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
// Likewise pageviews: the initial "/" pageview is the anchor of the
// acquisition funnel, and the Next.js router fires it on mount before the
// config fetch resolves. We keep the first pending pageview so that step
// doesn't silently drop.
let pendingPageview: string | undefined | null = null;
// Frontend-emitted events (captureEvent) and person-property updates
// (setPersonProperties) can also arrive before init — same config-race as
// identify/pageview. We replay them in order once init succeeds. These
// only ever carry user-triggered signals on identified users, so the
// buffer stays small (~one step-transition worth).
type PendingOp =
| { kind: "event"; name: string; props?: Record<string, unknown> }
| { kind: "set"; props: Record<string, unknown> };
const pendingOps: PendingOp[] = [];
// Cached super-properties so resetAnalytics() can re-register them after
// posthog.reset() wipes the persisted set. Without this, logout / account
// switch silently drops client_type + app_version from every subsequent
// event until a full reload.
let superProperties: Record<string, unknown> = {};
export {
captureDownloadIntent,
captureDownloadPageViewed,
captureDownloadInitiated,
type DownloadIntentSource,
type DownloadDetectPayload,
type DownloadInitiatedPayload,
} from "./download";
export interface AnalyticsConfig {
key: string;
host: string;
/**
* Client app version — attached to every event as an `app_version`
* super-property. Web injects the build-time tag / sha; desktop reads from
* the Electron API. Optional because local dev may not have a version
* available.
*/
appVersion?: string;
}
export type ClientType = "desktop" | "web";
/**
* Classify the current runtime as desktop (Electron renderer) or web. Used as
* a super-property so every event can be split by client without relying on
* PostHog's `$lib`, which reports "web" in both the Next.js app and the
* Electron renderer (both Chromium).
*
* Signals we trust:
* - `window.electron` is exposed by the preload script in every renderer.
* - `navigator.userAgent` contains "Electron" as a fallback.
*/
export function detectClientType(): ClientType {
if (typeof window === "undefined") return "web";
const w = window as unknown as { electron?: unknown; desktopAPI?: unknown };
if (w.electron || w.desktopAPI) return "desktop";
if (typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent)) {
return "desktop";
}
return "web";
}
/**
* Initialize posthog-js if a key is present. Safe to call multiple times;
* subsequent calls with the same config are no-ops.
*
* Returns `true` when analytics is actually running; `false` when disabled
* (no key, SSR, or already initialized with a conflicting key — which we
* treat as "use the existing instance").
*/
export function initAnalytics(config: AnalyticsConfig | null | undefined): boolean {
if (typeof window === "undefined") return false;
if (!config?.key) return false;
if (initialized) return true;
posthog.init(config.key, {
api_host: config.host || "https://us.i.posthog.com",
// person_profiles=identified_only keeps anonymous drive-by traffic off
// the billed events until they actually identify, which aligns with how
// our funnel is set up: signup is the first real funnel step.
person_profiles: "identified_only",
// Turn off every on-by-default auto-capture surface. Our funnel is
// narrow and explicit (the events in docs/analytics.md + a manual
// $pageview). Autocapture floods the Activity view with anonymous
// "clicked button" / "clicked link" noise, burns the billed event
// budget, and risks capturing user-typed content in input values.
// Turn things back on deliberately if we ever want them.
capture_pageview: false,
autocapture: false,
capture_heatmaps: false,
capture_dead_clicks: false,
capture_exceptions: false,
disable_session_recording: true,
disable_surveys: true,
});
// Register super-properties — attached to every event emitted from this
// client. `client_type` is the canonical split between desktop and web
// (PostHog's own `$lib` reports "web" for both because Electron renderers
// are Chromium). `app_version` is optional so self-hosted or local dev
// builds without a version don't pollute the property.
// We cache the set so resetAnalytics() can re-apply it after
// posthog.reset() — reset() clears persisted super-properties otherwise.
superProperties = { client_type: detectClientType() };
if (config.appVersion) superProperties.app_version = config.appVersion;
posthog.register(superProperties);
initialized = true;
// Flush any identify() that arrived before init resolved.
if (pendingIdentify) {
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
pendingIdentify = null;
}
// And any first pageview we captured while config was loading.
if (pendingPageview !== null) {
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
pendingPageview = null;
}
// Replay buffered events / person-property updates in their original
// order — funnel correctness depends on sequence (e.g. a user submits
// the questionnaire and then finishes onboarding within the same
// config-race window).
while (pendingOps.length > 0) {
const op = pendingOps.shift()!;
if (op.kind === "event") {
posthog.capture(op.name, op.props);
} else {
capturePersonSet(op.props);
}
}
return true;
}
/**
* Merge the current anonymous session into the logged-in person. Must be
* called exactly once per auth transition (login / session-resume). Pulling
* attribution properties into person_properties on identify is how we keep
* UTM / referrer on the user profile without re-emitting them per event.
*
* Calls before initAnalytics() are buffered — auth-initializer fetches
* config and user in parallel, so identify can arrive first.
*/
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
if (!initialized) {
pendingIdentify = { userId, props: userProperties };
return;
}
posthog.identify(userId, userProperties);
}
/**
* Clear the client-side identity on logout so the next login merges cleanly
* and doesn't bleed the previous user's events into a new session.
*/
export function resetAnalytics(): void {
pendingIdentify = null;
pendingPageview = null;
pendingOps.length = 0;
if (!initialized) return;
posthog.reset();
// reset() wipes persisted super-properties too, so re-register the ones
// set at init time. Otherwise every event after logout / account-switch
// would be missing client_type + app_version until a full reload.
if (Object.keys(superProperties).length > 0) {
posthog.register(superProperties);
}
}
/**
* Capture a frontend-emitted event. Most funnel events fire server-side
* (see `server/internal/analytics`); this wrapper is reserved for the
* handful of signals the backend can't see — primarily the Step 3
* platform-fork choice on web, where the user's click never round-trips
* to a handler.
*
* Calls before initAnalytics() buffer in order so a late-arriving config
* doesn't silently swallow a step transition.
*/
export function captureEvent(
name: string,
props?: Record<string, unknown>,
): void {
if (!initialized) {
pendingOps.push({ kind: "event", name, props });
return;
}
posthog.capture(name, props);
}
/**
* Set (overwrite) person properties on the currently identified user.
* Mirrors the backend's `Event.Set` path — keep these aligned so the
* same cohort signals (role, use_case, platform_preference) are
* queryable regardless of which side emitted last. Use for mutable
* signals; use `identify(userId, { $set_once: {...} })` style for
* attribution fields that must never be overwritten.
*/
export function setPersonProperties(props: Record<string, unknown>): void {
if (!initialized) {
pendingOps.push({ kind: "set", props });
return;
}
capturePersonSet(props);
}
// The public wire-level contract for `$set` is a no-op event carrying a
// `$set` property. Wrapping it here (rather than calling
// `posthog.setPersonProperties` directly) keeps us version-independent —
// older posthog-js builds expose the same protocol under `posthog.people.set`,
// and the capture form works uniformly.
function capturePersonSet(props: Record<string, unknown>): void {
posthog.capture("$set", { $set: props });
}
/**
* Capture a page view. Call once per client-side navigation. We disable
* posthog's automatic pageview tracking in init() so this module owns the
* event shape — that makes it trivial to add properties (e.g. workspace
* slug) without fighting the SDK.
*
* Calls before initAnalytics() buffer the most-recent path so the first
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
* pageviews overwrite the buffer; after init flushes, every navigation
* captures synchronously as expected.
*/
export function capturePageview(path?: string): void {
if (!initialized) {
pendingPageview = path ?? "";
return;
}
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
}
/**
* On the very first anonymous pageview in a browser session, read UTM +
* referrer and stash them in a cookie that the backend reads during signup.
*
* Never use raw `document.referrer` as attribution — it can leak OAuth
* callback URLs with `code` / `state` in the query string. We keep only the
* referrer's origin (scheme + host), which is what a funnel actually needs.
*
* This cookie is what `signup_source` in the backend's signup event reads
* from; both fields are intentionally opaque JSON so the schema can evolve
* without a backend deploy.
*/
export function captureSignupSource(): void {
if (typeof window === "undefined" || typeof document === "undefined") return;
if (readCookie(SIGNUP_SOURCE_COOKIE)) return;
const source: Record<string, string> = {};
const cap = (v: string) =>
v.length > SIGNUP_SOURCE_VALUE_MAX_LEN ? v.slice(0, SIGNUP_SOURCE_VALUE_MAX_LEN) : v;
try {
const params = new URLSearchParams(window.location.search);
for (const key of UTM_KEYS) {
const v = params.get(key);
if (v) source[key] = cap(v);
}
} catch {
// URL APIs unavailable — skip silently.
}
const refOrigin = safeReferrerOrigin(document.referrer);
if (refOrigin) source.referrer_origin = cap(refOrigin);
if (Object.keys(source).length === 0) return;
const payload = JSON.stringify(source);
// Drop rather than mid-JSON truncate — a half-string would fail to parse
// on the backend and the attribution would be worse than missing.
if (payload.length > SIGNUP_SOURCE_MAX_LEN) return;
// 30-day expiry covers the typical signup consideration window. Lax is
// the right default — the cookie is only consumed by same-origin auth.
const maxAge = 60 * 60 * 24 * 30;
document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(payload)}; path=/; max-age=${maxAge}; samesite=lax`;
}
function safeReferrerOrigin(referrer: string): string {
if (!referrer) return "";
try {
const url = new URL(referrer);
if (url.origin === window.location.origin) return "";
return url.origin;
} catch {
return "";
}
}
function readCookie(name: string): string {
if (typeof document === "undefined") return "";
const prefix = `${name}=`;
const parts = document.cookie ? document.cookie.split("; ") : [];
for (const part of parts) {
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
}
return "";
}

View File

@@ -106,42 +106,4 @@ describe("ApiClient", () => {
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
]);
});
it("emits X-Client-* headers when identity is configured", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test", {
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
});
await client.listWorkspaces();
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Client-Platform"]).toBe("desktop");
expect(headers["X-Client-Version"]).toBe("1.2.3");
expect(headers["X-Client-OS"]).toBe("macos");
});
it("omits X-Client-* headers when identity is not configured", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.listWorkspaces();
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Client-Platform"]).toBeUndefined();
expect(headers["X-Client-Version"]).toBeUndefined();
expect(headers["X-Client-OS"]).toBeUndefined();
});
});

View File

@@ -35,10 +35,6 @@ import type {
RuntimeHourlyActivity,
RuntimePing,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelineEntry,
AssigneeFrequencyEntry,
TaskMessagePayload,
@@ -68,29 +64,13 @@ import type {
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
/** Identifies the calling client to the server.
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
* X-Client-OS so the backend can log, gate, or split metrics by client.
* See server/internal/middleware/client.go for the receiving end. */
export interface ApiClientIdentity {
/** Logical client kind. Server expects: "web" | "desktop" | "cli" | "daemon". */
platform?: string;
/** Client/app version string (e.g. "0.1.0", git tag, commit). */
version?: string;
/** Operating system the client is running on: "macos" | "windows" | "linux". */
os?: string;
}
export interface ApiClientOptions {
logger?: Logger;
onUnauthorized?: () => void;
/** Identifies the client to the server. Sent as X-Client-* headers. */
identity?: ApiClientIdentity;
}
export interface LoginResponse {
@@ -98,52 +78,6 @@ export interface LoginResponse {
user: User;
}
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
// The client sends both branches of sub-issues and an unbound welcome
// issue template (title + description, no `agent_id`). The SERVER picks
// the branch by inspecting the workspace's agent list inside the
// import transaction. This removes the client as a trusted decider —
// even if the client has a stale agent cache or lies, the server uses
// the DB as source of truth.
export interface ImportStarterIssuePayload {
title: string;
description: string;
status: string;
priority: string;
/** Server uses `user_id` (per app-wide AssigneePicker convention)
* as assignee when true. No member_id is threaded through. */
assign_to_self: boolean;
}
export interface ImportStarterWelcomeIssueTemplate {
title: string;
description: string;
/** Defaults to "high" on server when empty. */
priority: string;
}
export interface ImportStarterContentPayload {
workspace_id: string;
project: { title: string; description: string; icon: string };
/** Always sent. Server creates it only when an agent exists in the
* workspace; ignored otherwise. Agent id is picked by the server. */
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
/** Used when the workspace has at least one agent. */
agent_guided_sub_issues: ImportStarterIssuePayload[];
/** Used when the workspace has zero agents. */
self_serve_sub_issues: ImportStarterIssuePayload[];
}
export interface ImportStarterContentResponse {
user: User;
project_id: string;
/** Non-null when server took the agent-guided branch. */
welcome_issue_id: string | null;
}
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
@@ -191,10 +125,6 @@ export class ApiClient {
if (slug) headers["X-Workspace-Slug"] = slug;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
const id = this.options.identity;
if (id?.platform) headers["X-Client-Platform"] = id.platform;
if (id?.version) headers["X-Client-Version"] = id.version;
if (id?.os) headers["X-Client-OS"] = id.os;
return headers;
}
@@ -289,62 +219,6 @@ export class ApiClient {
return this.fetch("/api/me");
}
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
async joinCloudWaitlist(payload: {
email: string;
reason?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding/cloud-waitlist", {
method: "POST",
body: JSON.stringify(payload),
});
}
async patchOnboarding(payload: {
questionnaire?: Record<string, unknown>;
}): Promise<User> {
return this.fetch("/api/me/onboarding", {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/**
* Imports the Getting Started project + optional welcome issue + sub-issues
* in a single server-side transaction. Gated by an atomic
* starter_content_state: NULL → 'imported' claim — a second call returns
* 409 (already decided) and creates nothing new.
*
* The content templates live in TypeScript (see
* @multica/views/onboarding/utils/starter-content-templates) and are
* rendered from the user's questionnaire answers before being sent.
*/
async importStarterContent(
payload: ImportStarterContentPayload,
): Promise<ImportStarterContentResponse> {
return this.fetch("/api/me/starter-content/import", {
method: "POST",
body: JSON.stringify(payload),
});
}
async dismissStarterContent(payload?: {
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/starter-content/dismiss", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
async updateMe(data: UpdateMeRequest): Promise<User> {
return this.fetch("/api/me", {
method: "PATCH",
@@ -363,7 +237,6 @@ export class ApiClient {
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.open_only) search.set("open_only", "true");
return this.fetch(`/api/issues?${search}`);
}
@@ -597,49 +470,6 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
}
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
}
async getListModelsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeModelListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
}
async initiateListLocalSkills(
runtimeId: string,
): Promise<RuntimeLocalSkillListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
method: "POST",
});
}
async getListLocalSkillsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillListRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
}
async initiateImportLocalSkill(
runtimeId: string,
data: CreateRuntimeLocalSkillImportRequest,
): Promise<RuntimeLocalSkillImportRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, {
method: "POST",
body: JSON.stringify(data),
});
}
async getImportLocalSkillResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillImportRequest> {
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`);
}
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
return this.fetch(`/api/agents/${agentId}/tasks`);
}
@@ -700,13 +530,7 @@ export class ApiClient {
}
// App Config
async getConfig(): Promise<{
cdn_domain: string;
allow_signup: boolean;
google_client_id?: string;
posthog_key?: string;
posthog_host?: string;
}> {
async getConfig(): Promise<{ cdn_domain: string }> {
return this.fetch("/api/config");
}

View File

@@ -1,11 +1,5 @@
export { ApiClient, ApiError } from "./client";
export type {
ApiClientOptions,
ImportStarterContentPayload,
ImportStarterContentResponse,
ImportStarterIssuePayload,
ImportStarterWelcomeIssueTemplate,
} from "./client";
export type { ApiClientOptions } from "./client";
export { WSClient } from "./ws-client";
import type { ApiClient as ApiClientType } from "./client";

View File

@@ -1,72 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WSClient } from "./ws-client";
// Capture URL passed to WebSocket so we can assert the connect-time
// query string. We don't simulate the full WS lifecycle here — only the
// upgrade URL construction, which is what carries client identity.
class FakeWebSocket {
static lastUrl: string | null = null;
// Fields read by WSClient.connect()/disconnect(), all no-op here.
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
readyState = 0;
constructor(url: string) {
FakeWebSocket.lastUrl = url;
}
close() {}
send() {}
}
describe("WSClient", () => {
beforeEach(() => {
FakeWebSocket.lastUrl = null;
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("includes client identity in the upgrade URL when configured", () => {
const ws = new WSClient("ws://example.test/ws", {
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
});
ws.setAuth("tok", "acme");
ws.connect();
const url = new URL(FakeWebSocket.lastUrl!);
expect(url.searchParams.get("workspace_slug")).toBe("acme");
expect(url.searchParams.get("client_platform")).toBe("desktop");
expect(url.searchParams.get("client_version")).toBe("1.2.3");
expect(url.searchParams.get("client_os")).toBe("macos");
// Token must never appear in the URL — it is delivered as the first
// WS message in token mode.
expect(url.searchParams.has("token")).toBe(false);
});
it("omits client_* params when identity is not configured", () => {
const ws = new WSClient("ws://example.test/ws");
ws.setAuth("tok", "acme");
ws.connect();
const url = new URL(FakeWebSocket.lastUrl!);
expect(url.searchParams.has("client_platform")).toBe(false);
expect(url.searchParams.has("client_version")).toBe(false);
expect(url.searchParams.has("client_os")).toBe(false);
});
it("only includes the identity fields that are set", () => {
const ws = new WSClient("ws://example.test/ws", {
identity: { platform: "cli" },
});
ws.setAuth("tok", "acme");
ws.connect();
const url = new URL(FakeWebSocket.lastUrl!);
expect(url.searchParams.get("client_platform")).toBe("cli");
expect(url.searchParams.has("client_version")).toBe(false);
expect(url.searchParams.has("client_os")).toBe(false);
});
});

View File

@@ -3,23 +3,12 @@ import { type Logger, noopLogger } from "../logger";
type EventHandler = (payload: unknown, actorId?: string) => void;
/** Identifies the WS client to the server. Sent as `client_platform`,
* `client_version`, and `client_os` query parameters on the upgrade URL —
* browsers cannot set custom headers on WebSocket handshakes, so query
* params are the only portable channel. */
export interface WSClientIdentity {
platform?: string;
version?: string;
os?: string;
}
export class WSClient {
private ws: WebSocket | null = null;
private baseUrl: string;
private token: string | null = null;
private workspaceSlug: string | null = null;
private cookieAuth = false;
private identity: WSClientIdentity | undefined;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
@@ -27,18 +16,10 @@ export class WSClient {
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
constructor(
url: string,
options?: {
logger?: Logger;
cookieAuth?: boolean;
identity?: WSClientIdentity;
},
) {
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
this.cookieAuth = options?.cookieAuth ?? false;
this.identity = options?.identity;
}
setAuth(token: string | null, workspaceSlug: string) {
@@ -54,12 +35,6 @@ export class WSClient {
// is delivered as the first WebSocket message after the connection opens.
if (this.workspaceSlug)
url.searchParams.set("workspace_slug", this.workspaceSlug);
if (this.identity?.platform)
url.searchParams.set("client_platform", this.identity.platform);
if (this.identity?.version)
url.searchParams.set("client_version", this.identity.version);
if (this.identity?.os)
url.searchParams.set("client_os", this.identity.os);
this.ws = new WebSocket(url.toString());

View File

@@ -1,6 +1,5 @@
import { create } from "zustand";
import type { User, StorageAdapter } from "../types";
import { identify as identifyAnalytics, resetAnalytics } from "../analytics";
import { ApiError, type ApiClient } from "../api/client";
import { setCurrentWorkspace } from "../platform/workspace-storage";
@@ -24,7 +23,6 @@ export interface AuthState {
loginWithToken: (token: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
refreshMe: () => Promise<void>;
}
export function createAuthStore(options: AuthStoreOptions) {
@@ -86,7 +84,6 @@ export function createAuthStore(options: AuthStoreOptions) {
api.setToken(token);
}
onLogin?.();
identifyAnalytics(user.id, { email: user.email, name: user.name });
set({ user });
return user;
},
@@ -98,7 +95,6 @@ export function createAuthStore(options: AuthStoreOptions) {
api.setToken(token);
}
onLogin?.();
identifyAnalytics(user.id, { email: user.email, name: user.name });
set({ user });
return user;
},
@@ -108,7 +104,6 @@ export function createAuthStore(options: AuthStoreOptions) {
api.setToken(token);
const user = await api.getMe();
onLogin?.();
identifyAnalytics(user.id, { email: user.email, name: user.name });
set({ user, isLoading: false });
return user;
},
@@ -121,7 +116,6 @@ export function createAuthStore(options: AuthStoreOptions) {
storage.removeItem("multica_token");
api.setToken(null);
setCurrentWorkspace(null, null);
resetAnalytics();
onLogout?.();
set({ user: null });
},
@@ -129,10 +123,5 @@ export function createAuthStore(options: AuthStoreOptions) {
setUser: (user: User) => {
set({ user });
},
refreshMe: async () => {
const user = await api.getMe();
set({ user });
},
}));
}

View File

@@ -1,5 +1,5 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -14,8 +14,6 @@ export const DRAFT_NEW_SESSION = "__new__";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/** Focus mode is a personal preference — global across workspaces/sessions. */
const FOCUS_MODE_KEY = "multica:chat:focusMode";
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
const raw = storage.getItem(key);
@@ -60,21 +58,6 @@ export interface ChatTimelineItem {
output?: string;
}
/**
* A derived "where I am" pointer — not stored, recomputed each render from
* the current route + react-query cache. The type is exported because
* consumers (buildAnchorMarkdown, chip props) share the same shape.
*/
export interface ContextAnchor {
type: "issue" | "project";
/** UUID for `issue`, UUID for `project`. */
id: string;
/** Human-readable label: issue identifier (MUL-1) or project title. */
label: string;
/** Optional secondary text — issue title for issue anchors. */
subtitle?: string;
}
export interface ChatState {
isOpen: boolean;
activeSessionId: string | null;
@@ -82,12 +65,6 @@ export interface ChatState {
showHistory: boolean;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/**
* When on, the chat tracks whatever issue/project/inbox-item the user is
* looking at and prepends it to outgoing messages. Persisted globally so
* the preference survives workspace switches and reloads.
*/
focusMode: boolean;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
@@ -100,7 +77,6 @@ export interface ChatState {
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
setFocusMode: (on: boolean) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -124,7 +100,6 @@ export function createChatStore(options: ChatStoreOptions) {
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
@@ -162,12 +137,6 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setFocusMode: (on) => {
logger.info("setFocusMode", { to: on });
if (on) storage.setItem(FOCUS_MODE_KEY, "true");
else storage.removeItem(FOCUS_MODE_KEY);
set({ focusMode: on });
},
clearInputDraft: (sessionId) => {
const current = get().inputDrafts;
if (!(sessionId in current)) {

View File

@@ -3,19 +3,12 @@ import { useStore } from "zustand";
interface ConfigState {
cdnDomain: string;
allowSignup: boolean;
googleClientId: string;
setCdnDomain: (domain: string) => void;
setAuthConfig: (config: { allowSignup: boolean; googleClientId?: string }) => void;
}
export const configStore = createStore<ConfigState>((set) => ({
cdnDomain: "",
allowSignup: true,
googleClientId: "",
setCdnDomain: (domain) => set({ cdnDomain: domain }),
setAuthConfig: ({ allowSignup, googleClientId = "" }) =>
set({ allowSignup, googleClientId }),
}));
export function useConfigStore(): ConfigState;

View File

@@ -1,74 +0,0 @@
import { describe, it, expect } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
import { inboxKeys } from "./queries";
import type { InboxItem } from "../types";
const wsId = "ws-1";
function makeItem(
id: string,
issueId: string | null,
overrides: Partial<InboxItem> = {},
): InboxItem {
return {
id,
workspace_id: wsId,
recipient_type: "member",
recipient_id: "user-1",
actor_type: null,
actor_id: null,
type: "mentioned",
severity: "info",
issue_id: issueId,
title: `item ${id}`,
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2025-01-01T00:00:00Z",
details: null,
...overrides,
};
}
describe("onInboxIssueDeleted", () => {
it("removes all inbox items referencing the deleted issue", () => {
const qc = new QueryClient();
const items = [
makeItem("i1", "issue-a"),
makeItem("i2", "issue-a"),
makeItem("i3", "issue-b"),
makeItem("i4", null),
];
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
onInboxIssueDeleted(qc, wsId, "issue-a");
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
expect(after?.map((i) => i.id)).toEqual(["i3", "i4"]);
});
it("is a no-op when the inbox cache is empty", () => {
const qc = new QueryClient();
expect(() => onInboxIssueDeleted(qc, wsId, "issue-a")).not.toThrow();
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))).toBeUndefined();
});
});
describe("onInboxIssueStatusChanged", () => {
it("updates issue_status only for items referencing the issue", () => {
const qc = new QueryClient();
const items = [
makeItem("i1", "issue-a", { issue_status: "todo" }),
makeItem("i2", "issue-b", { issue_status: "todo" }),
];
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
expect(after?.find((i) => i.id === "i1")?.issue_status).toBe("done");
expect(after?.find((i) => i.id === "i2")?.issue_status).toBe("todo");
});
});

View File

@@ -25,19 +25,6 @@ export function onInboxIssueStatusChanged(
);
}
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
// is deleted, all inbox items that referenced it are gone server-side, so drop
// them from the cache too.
export function onInboxIssueDeleted(
qc: QueryClient,
wsId: string,
issueId: string,
) {
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
}
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}

View File

@@ -1,100 +0,0 @@
import type {
Issue,
IssueStatus,
IssueStatusBucket,
ListIssuesCache,
} from "../types";
import { PAGINATED_STATUSES } from "./queries";
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
export function getBucket(
resp: ListIssuesCache,
status: IssueStatus,
): IssueStatusBucket {
return resp.byStatus[status] ?? EMPTY_BUCKET;
}
export function setBucket(
resp: ListIssuesCache,
status: IssueStatus,
bucket: IssueStatusBucket,
): ListIssuesCache {
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
}
/** Locate which status bucket holds `id`, if any. */
export function findIssueLocation(
resp: ListIssuesCache,
id: string,
): { status: IssueStatus; issue: Issue } | null {
for (const status of PAGINATED_STATUSES) {
const bucket = resp.byStatus[status];
const found = bucket?.issues.find((i) => i.id === id);
if (found) return { status, issue: found };
}
return null;
}
/** Add an issue to its status bucket (no-op if already present). */
export function addIssueToBuckets(
resp: ListIssuesCache,
issue: Issue,
): ListIssuesCache {
const bucket = getBucket(resp, issue.status);
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
return setBucket(resp, issue.status, {
issues: [...bucket.issues, issue],
total: bucket.total + 1,
});
}
/** Remove an issue from whichever bucket contains it. */
export function removeIssueFromBuckets(
resp: ListIssuesCache,
id: string,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const bucket = getBucket(resp, loc.status);
return setBucket(resp, loc.status, {
issues: bucket.issues.filter((i) => i.id !== id),
total: Math.max(0, bucket.total - 1),
});
}
/**
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
* current bucket, the issue moves to the new bucket and both buckets' totals
* are adjusted.
*/
export function patchIssueInBuckets(
resp: ListIssuesCache,
id: string,
patch: Partial<Issue>,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const merged: Issue = { ...loc.issue, ...patch };
const nextStatus = patch.status ?? loc.status;
if (nextStatus === loc.status) {
const bucket = getBucket(resp, loc.status);
return setBucket(resp, loc.status, {
...bucket,
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
});
}
const fromBucket = getBucket(resp, loc.status);
const toBucket = getBucket(resp, nextStatus);
let next = setBucket(resp, loc.status, {
issues: fromBucket.issues.filter((i) => i.id !== id),
total: Math.max(0, fromBucket.total - 1),
});
next = setBucket(next, nextStatus, {
issues: [...toBucket.issues, merged],
total: toBucket.total + 1,
});
return next;
}

View File

@@ -1,26 +1,14 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
type MyIssuesFilter,
} from "./queries";
import {
addIssueToBuckets,
findIssueLocation,
getBucket,
patchIssueInBuckets,
removeIssueFromBuckets,
setBucket,
} from "./cache-helpers";
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction, IssueStatus } from "../types";
import type { Issue, IssueReaction } from "../types";
import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesCache,
ListIssuesResponse,
} from "../types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
@@ -41,18 +29,10 @@ export type ToggleIssueReactionVars = {
};
// ---------------------------------------------------------------------------
// Per-status pagination
// Done issue pagination
// ---------------------------------------------------------------------------
/**
* Paginate one status column into the cache. Works for both the workspace
* issue list and per-scope My Issues lists (pass `myIssues` to target the
* latter).
*/
export function useLoadMoreByStatus(
status: IssueStatus,
myIssues?: { scope: string; filter: MyIssuesFilter },
) {
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
@@ -60,38 +40,39 @@ export function useLoadMoreByStatus(
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
const bucket = cache?.byStatus[status];
const loaded = bucket?.issues.length ?? 0;
const total = bucket?.total ?? 0;
const hasMore = loaded < total;
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
const doneLoaded = cache
? cache.issues.filter((i) => i.status === "done").length
: 0;
const doneTotal = cache?.doneTotal ?? 0;
const hasMore = doneLoaded < doneTotal;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listIssues({
status,
limit: ISSUE_PAGE_SIZE,
offset: loaded,
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: doneLoaded,
...myIssues?.filter,
});
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
if (!old) return old;
const prev = getBucket(old, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
const appended = res.issues.filter((i) => !existingIds.has(i.id));
return setBucket(old, status, {
issues: [...prev.issues, ...appended],
total: res.total,
});
const existingIds = new Set(old.issues.map((i) => i.id));
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
return {
...old,
issues: [...old.issues, ...newIssues],
doneTotal: res.total,
};
});
} finally {
setIsLoading(false);
}
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
return { loadMore, hasMore, isLoading, total };
return { loadMore, hasMore, isLoading, doneTotal };
}
// ---------------------------------------------------------------------------
@@ -104,8 +85,15 @@ export function useCreateIssue() {
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? addIssueToBuckets(old, newIssue) : old,
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old && !old.issues.some((i) => i.id === newIssue.id)
? {
...old,
issues: [...old.issues, newIssue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
}
: old,
);
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
@@ -134,7 +122,7 @@ export function useUpdateIssue() {
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
// Resolve parent_issue_id from the freshest source so we can keep the
@@ -142,14 +130,21 @@ export function useUpdateIssue() {
// sub-issues list).
const parentId =
prevDetail?.parent_issue_id ??
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
null;
const prevChildren = parentId
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, id, data) : old,
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
i.id === id ? { ...i, ...data } : i,
),
}
: old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
@@ -203,11 +198,18 @@ export function useDeleteIssue() {
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, id) : old,
);
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = prevList?.issues.find((i) => i.id === id);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const d = old.issues.find((i) => i.id === id);
return {
...old,
issues: old.issues.filter((i) => i.id !== id),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList, parentIssueId: deleted?.parent_issue_id };
},
@@ -237,13 +239,17 @@ export function useBatchUpdateIssues() {
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
return next;
});
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
ids.includes(i.id) ? { ...i, ...updates } : i,
),
}
: old,
);
return { prevList };
},
onError: (_err, _vars, ctx) => {
@@ -262,19 +268,24 @@ export function useBatchDeleteIssues() {
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const parentIssueIds = new Set<string>();
if (prevList) {
for (const id of ids) {
const loc = findIssueLocation(prevList, id);
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
}
}
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const idSet = new Set(ids);
const parentIssueIds = new Set(
prevList?.issues
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
.map((i) => i.parent_issue_id!) ?? [],
);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = removeIssueFromBuckets(next, id);
return next;
const doneDeleted = old.issues.filter(
(i) => idSet.has(i.id) && i.status === "done",
).length;
return {
...old,
issues: old.issues.filter((i) => !idSet.has(i.id)),
total: old.total - ids.length,
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
};
});
return { prevList, parentIssueIds };
},

View File

@@ -1,7 +1,6 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
import { BOARD_STATUSES } from "./config";
import type { ListIssuesParams } from "../types";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
@@ -24,55 +23,33 @@ export const issueKeys = {
usage: (issueId: string) => ["issues", "usage", issueId] as const,
};
export type MyIssuesFilter = Pick<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
>;
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
/** Page size per status column. */
export const ISSUE_PAGE_SIZE = 50;
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
export function flattenIssueBuckets(data: ListIssuesCache) {
const out = [];
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) out.push(...bucket.issues);
}
return out;
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
),
);
const byStatus: ListIssuesCache["byStatus"] = {};
PAGINATED_STATUSES.forEach((status, i) => {
const res = responses[i]!;
byStatus[status] = { issues: res.issues, total: res.total };
});
return { byStatus };
}
export const CLOSED_PAGE_SIZE = 50;
/**
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
* by status, each with `{ issues, total }`), and `select` flattens it to
* `Issue[]` for consumers. Mutations and ws-updaters must use
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
*
* Fetches the first page of each paginated status in parallel. Use
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
* to paginate additional done items into the cache.
*/
export function issueListOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: () => fetchFirstPages(),
select: flattenIssueBuckets,
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true }),
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
});
}
@@ -87,8 +64,23 @@ export function myIssueListOptions(
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
select: flattenIssueBuckets,
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true, ...filter }),
api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: 0,
...filter,
}),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
});
}

View File

@@ -1,22 +1,22 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue } from "../types";
import type { ListIssuesCache } from "../types";
import type { ListIssuesResponse } from "../types";
export function onIssueCreated(
qc: QueryClient,
wsId: string,
issue: Issue,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? addIssueToBuckets(old, issue) : old,
);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
return {
...old,
issues: [...old.issues, issue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
@@ -32,20 +32,36 @@ export function onIssueUpdated(
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page).
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const oldParentId =
detailData?.parent_issue_id ??
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issue.id, issue) : old,
);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const prev = old.issues.find((i) => i.id === issue.id);
const wasDone = prev?.status === "done";
const isDone = issue.status === "done";
// Only adjust doneTotal when status field is present and actually changed
let doneDelta = 0;
if (issue.status !== undefined) {
if (!wasDone && isDone) doneDelta = 1;
else if (wasDone && !isDone) doneDelta = -1;
}
return {
...old,
issues: old.issues.map((i) =>
i.id === issue.id ? { ...i, ...issue } : i,
),
doneTotal: (old.doneTotal ?? 0) + doneDelta,
};
});
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
@@ -78,12 +94,19 @@ export function onIssueDeleted(
issueId: string,
) {
// Look up the issue before removing it to check for parent_issue_id
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = listData?.issues.find((i) => i.id === issueId);
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const del = old.issues.find((i) => i.id === issueId);
return {
...old,
issues: old.issues.filter((i) => i.id !== issueId),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
};
});
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });

View File

@@ -1,15 +0,0 @@
export type {
OnboardingStep,
OnboardingCompletionPath,
QuestionnaireAnswers,
TeamSize,
Role,
UseCase,
} from "./types";
export {
saveQuestionnaire,
completeOnboarding,
joinCloudWaitlist,
} from "./store";
export { ONBOARDING_STEP_ORDER } from "./step-order";
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";

View File

@@ -1,117 +0,0 @@
import { describe, expect, it } from "vitest";
import { recommendTemplate } from "./recommend-template";
import type { Role, UseCase } from "./types";
const ALL_USE_CASES: UseCase[] = [
"coding",
"planning",
"writing_research",
"explore",
"other",
];
describe("recommendTemplate", () => {
describe("identity fallbacks — role alone decides", () => {
it.each(ALL_USE_CASES)(
"role=other (use_case=%s) → assistant",
(use_case) => {
expect(recommendTemplate({ role: "other", use_case })).toBe(
"assistant",
);
},
);
it.each(ALL_USE_CASES)(
"role=founder (use_case=%s) → assistant",
(use_case) => {
expect(recommendTemplate({ role: "founder", use_case })).toBe(
"assistant",
);
},
);
it.each(ALL_USE_CASES)(
"role=writer (use_case=%s) → writing",
(use_case) => {
expect(recommendTemplate({ role: "writer", use_case })).toBe(
"writing",
);
},
);
});
describe("developer × use_case tiebreaker", () => {
it("developer × planning → planning", () => {
expect(
recommendTemplate({ role: "developer", use_case: "planning" }),
).toBe("planning");
});
it.each<UseCase>([
"coding",
"writing_research",
"explore",
"other",
])("developer × %s → coding", (use_case) => {
expect(recommendTemplate({ role: "developer", use_case })).toBe(
"coding",
);
});
it("developer × null use_case → coding (default)", () => {
expect(
recommendTemplate({ role: "developer", use_case: null }),
).toBe("coding");
});
});
describe("product_lead × use_case tiebreaker", () => {
it("product_lead × coding → coding", () => {
expect(
recommendTemplate({ role: "product_lead", use_case: "coding" }),
).toBe("coding");
});
it.each<UseCase>([
"planning",
"writing_research",
"explore",
"other",
])("product_lead × %s → planning", (use_case) => {
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
"planning",
);
});
it("product_lead × null use_case → planning (default)", () => {
expect(
recommendTemplate({ role: "product_lead", use_case: null }),
).toBe("planning");
});
});
describe("unanswered questionnaire", () => {
it("null role → assistant regardless of use_case", () => {
expect(recommendTemplate({ role: null, use_case: null })).toBe(
"assistant",
);
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
"assistant",
);
});
});
describe("exhaustive role coverage", () => {
const roles: Role[] = [
"developer",
"product_lead",
"writer",
"founder",
"other",
];
it.each(roles)("role=%s returns a valid template id", (role) => {
const result = recommendTemplate({ role, use_case: null });
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
});
});
});

View File

@@ -1,41 +0,0 @@
import type { QuestionnaireAnswers } from "./types";
/**
* Identifier for the four agent templates offered during onboarding Step 4.
* Keep in sync with the template registry inside StepAgent in
* `packages/views/onboarding/steps/step-agent.tsx`.
*/
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
/**
* Pick a recommended agent template for a user based on their
* questionnaire answers. Role is treated as the primary signal (who the
* user is); use_case is only a tiebreaker for roles that legitimately
* split between templates (developer / product_lead).
*
* `role = other` and `role = founder` both fall back to the generic
* Assistant: "other" means the user declined to claim a role, and
* "founder" means they wear every hat, so a single specialized agent is
* a poor default.
*
* Pure / deterministic — safe to call on every render.
*/
export function recommendTemplate(
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
): AgentTemplateId {
const { role, use_case } = answers;
if (role === "other" || role === "founder") return "assistant";
if (role === "writer") return "writing";
if (role === "developer") {
return use_case === "planning" ? "planning" : "coding";
}
if (role === "product_lead") {
return use_case === "coding" ? "coding" : "planning";
}
// Unknown / null role — user hasn't answered Q2 yet.
return "assistant";
}

View File

@@ -1,23 +0,0 @@
import type { OnboardingStep } from "./types";
/**
* Canonical order of the persisted onboarding steps.
*
* Single source of truth for "what step comes after what" — consumed
* by the UI progress indicator to compute `index of current_step` and
* `total step count`. Inserting, reordering, or removing a step only
* requires changing this array; every call site that reads it updates
* automatically.
*
* Intentionally excludes "welcome": welcome is a first-entry product
* intro, not a persisted step. It doesn't show a progress indicator
* for the same reason — users shouldn't think of reading the intro
* as progress toward completing setup.
*/
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
"questionnaire",
"workspace",
"runtime",
"agent",
"first_issue",
] as const;

View File

@@ -1,66 +0,0 @@
import { api } from "../api";
import { useAuthStore } from "../auth";
import { setPersonProperties } from "../analytics";
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
/**
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
* store. Source of truth is `user.onboarding_questionnaire` (JSONB on
* the server). No client-side cache here.
*
* Resume-by-step is intentionally not persisted: every onboarding
* entry starts at Welcome. The questionnaire is the only piece of
* progress that survives a re-entry — it pre-fills Step 1 so the
* user doesn't re-answer.
*/
export async function saveQuestionnaire(
answers: Partial<QuestionnaireAnswers>,
): Promise<void> {
const user = await api.patchOnboarding({ questionnaire: answers });
useAuthStore.getState().setUser(user);
// Mirror the three cohort signals into person properties so every
// PostHog event on this user can be broken down by role / use_case /
// team_size without re-joining the DB. Matches the $set block the
// server writes alongside `onboarding_questionnaire_submitted`.
if (answers.team_size || answers.role || answers.use_case) {
setPersonProperties({
...(answers.team_size ? { team_size: answers.team_size } : {}),
...(answers.role ? { role: answers.role } : {}),
...(answers.use_case ? { use_case: answers.use_case } : {}),
});
}
}
/**
* Finalize onboarding. POST /complete marks `onboarded_at` atomically
* (COALESCE-guarded for idempotency). We then refresh the auth store
* so every gate sees the updated user.
*
* `completionPath` is the client's view of which Step-3 exit the user
* took; the server funnel-splits `onboarding_completed` on this value.
* Legacy callers that don't pass a path get recorded as `unknown`.
*/
export async function completeOnboarding(
completionPath?: OnboardingCompletionPath,
): Promise<void> {
await api.markOnboardingComplete(
completionPath ? { completion_path: completionPath } : undefined,
);
await useAuthStore.getState().refreshMe();
}
/**
* Records interest in cloud runtimes. Pure side effect — does NOT
* complete onboarding; the user still has to pick a real Step 3
* path (CLI with a detected runtime) or Skip to move on.
*
* Returned user object is not synced into the auth store because no
* user-visible field (`onboarded_at`, anything in `UserResponse`)
* actually changes here.
*/
export async function joinCloudWaitlist(
email: string,
reason: string,
): Promise<void> {
await api.joinCloudWaitlist({ email, reason });
}

Some files were not shown because too many files have changed in this diff Show More