Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
815a84bb55 fix(agent): surface codex turn errors instead of reporting empty output
When codex emits `turn/completed` with `status="failed"` or a terminal
top-level `error` notification, the daemon previously treated the turn
as successfully completed, saw no accumulated text, and surfaced the
generic "codex returned empty output" — hiding the real reason (auth,
sandbox, API error, etc.).

Capture `turn.error.message` on failed turns and the `error.message`
from non-retrying top-level error notifications, then propagate them
through `Result.Error` with `finalStatus="failed"` so the daemon's
default branch reports the actual cause.
2026-04-16 16:35:25 +08:00
550 changed files with 15838 additions and 49274 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

@@ -30,7 +30,7 @@ jobs:
run: pnpm install
- name: Build, type check, and test
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
run: pnpm build && pnpm typecheck && pnpm test
backend:
runs-on: ubuntu-latest
@@ -48,22 +48,8 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
# (which would flip the server binary itself onto the Redis-backed
# realtime relay + request stores); the tests talk to this Redis
# directly so they run alongside the Postgres-backed suite.
REDIS_TEST_URL: redis://localhost:6379/1
steps:
- name: Checkout
uses: actions/checkout@v6

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,48 +3,20 @@ 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*"
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
with:
fetch-depth: 0
- name: Validate tag name
id: release_meta
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
exit 1
fi
if [[ "$tag" == *-dirty* ]]; then
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
if [[ "$tag" == *-* ]]; then
echo "is_stable=false" >> "$GITHUB_OUTPUT"
else
echo "is_stable=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
uses: actions/setup-go@v5
with:
@@ -54,21 +26,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,298 +34,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# Multi-arch images are built natively per platform on dedicated runners
# (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm) and merged into a
# manifest list. This avoids QEMU emulation, which was making the Next.js
# arm64 build run for 30+ minutes per release.
docker-backend-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute backend image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
labels: |
org.opencontainers.image.title=Multica Backend
org.opencontainers.image.description=Multica self-hosted backend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-backend-${{ env.PLATFORM_PAIR }}
build-args: |
VERSION=${{ needs.verify.outputs.tag_name }}
COMMIT=${{ github.sha }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-backend,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-backend-merge:
needs: [verify, docker-backend-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-backend-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute backend image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-backend@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-backend:${{ steps.meta.outputs.version }}
docker-web-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute web image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
labels: |
org.opencontainers.image.title=Multica Web
org.opencontainers.image.description=Multica self-hosted web frontend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-web-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-web-${{ env.PLATFORM_PAIR }}
build-args: |
REMOTE_API_URL=http://backend:8080
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-web,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-web-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-web-merge:
needs: [verify, docker-web-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-web-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-web-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute web image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-web@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it
# can be signed + notarized with Apple Developer credentials that are
# not (yet) wired into CI.
desktop:
needs: release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# electron-builder's GitHub publisher reads this:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable code signing on Linux/Windows for now — the public
# release is unsigned for these platforms, the CLI carries the
# trust boundary. Set CSC_LINK in repo secrets to enable
# Windows signing later.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always

1
.gitignore vendored
View File

@@ -57,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

@@ -1,85 +0,0 @@
# Deploy the frontend apps from the monorepo root.
# Keep apps/web, apps/docs, shared packages, and root workspace metadata.
# Exclude unrelated workspaces and local artifacts that can make
# `vercel deploy` upload far more than the app needs.
.agent_context
.claude
.context
.env*
.envrc
.tool-versions
_features
.kilo
.idea
.DS_Store
.husky
.vscode
/.dockerignore
/.goreleaser.yml
/AGENTS.md
/CLAUDE.md
/CLI_AND_DAEMON.md
/CLI_INSTALL.md
/CONTRIBUTING.md
/Dockerfile
/Dockerfile.web
/HANDOFF_ARCHITECTURE_AUDIT.md
/Makefile
/README.md
/README.zh-CN.md
/SELF_HOSTING.md
/SELF_HOSTING_ADVANCED.md
/SELF_HOSTING_AI.md
/docker-compose*.yml
/playwright.config.ts
/skills-lock.json
/.github/
/docker/
/docs/
/e2e/
/server/
/apps/desktop/
/scripts/
*.log
*.pid
*.tsbuildinfo
.cache
.next
.pnpm-store
.turbo
.vercel
coverage
test-results
playwright-report
data
node_modules
bin
dist
out
build
dist-electron
# Deployment-only trims: tests and lint configs are not used by `next build`.
**/__tests__/**
**/test/**
**/*.test.*
**/*.spec.*
/packages/eslint-config/
/apps/web/components.json
/apps/web/eslint.config.mjs
/apps/web/vitest.config.ts
# Root repo metadata not needed in the deployment source.
/.env.example
/.gitattributes
/.gitignore
/LICENSE
*.app
*.dmg

View File

@@ -106,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
@@ -134,7 +133,6 @@ make start-worktree # Start using .env.worktree
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
### Package Boundary Rules
@@ -163,7 +161,7 @@ When the two apps need different behavior for the same concept (e.g., different
When adding a new page or feature:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
@@ -177,43 +175,6 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace context
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list.
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS)
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.

View File

@@ -143,9 +143,6 @@ The daemon auto-detects these AI CLIs on your PATH:
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
| Gemini | `gemini` | Google's coding agent |
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -186,12 +183,6 @@ Agent-specific overrides:
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
### Self-Hosted Server
@@ -278,7 +269,7 @@ multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
@@ -293,7 +284,7 @@ multica issue get <id> --output json
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
@@ -332,27 +323,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
### Subscribers
```bash
# List subscribers of an issue
multica issue subscriber list <issue-id>
# Subscribe yourself to an issue
multica issue subscriber add <issue-id>
# Subscribe another member or agent by name
multica issue subscriber add <issue-id> --user "Lambda"
# Unsubscribe yourself
multica issue subscriber remove <issue-id>
# Unsubscribe another member or agent
multica issue subscriber remove <issue-id> --user "Lambda"
```
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
### Execution History
```bash
@@ -370,70 +340,6 @@ multica issue run-messages <task-id> --since 42 --output json
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
## Setup
```bash
@@ -470,63 +376,6 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --status active --output json
```
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
```
### Run History
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Schedule Triggers
```bash
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
multica autopilot trigger-delete <autopilot-id> <trigger-id>
```
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
## Other Commands
```bash

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
@@ -166,12 +165,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
---
@@ -185,12 +184,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

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)

166
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
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
daemon:
@$(MAKE) multica MULTICA_ARGS="daemon"
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

@@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -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.
---
@@ -98,7 +97,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
### 2. Verify your runtime
@@ -108,7 +107,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -159,10 +158,10 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
Pi, Cursor Agent)
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
| Layer | Stack |
@@ -170,7 +169,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
## Development

View File

@@ -30,7 +30,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -99,7 +99,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode``hermes``gemini``pi``cursor-agent`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。
### 2. 确认运行时已连接
@@ -109,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClawOpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClawOpenCode并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -141,10 +141,10 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、OpenCode、
OpenClaw、Hermes、Gemini、
Pi、Cursor Agent
│ Agent Daemon │ 运行在你的机器上
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
| 层级 | 技术栈 |
@@ -152,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClawOpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClawOpenCode |
## 开发

View File

@@ -24,9 +24,9 @@ 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.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -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
@@ -67,15 +63,9 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
### Step 3 — Install CLI & Start Daemon
@@ -95,9 +85,6 @@ You also need at least one AI agent CLI installed:
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
- Gemini (`gemini` on PATH)
- [Pi](https://pi.dev/) (`pi` on PATH)
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
### b) One-command setup
@@ -162,15 +149,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 +179,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).
@@ -32,7 +23,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
### Google OAuth (Optional)
@@ -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
@@ -108,12 +80,6 @@ Agent-specific overrides:
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
## Database Setup
@@ -246,7 +212,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,31 +228,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
```
### WebSocket for LAN / Non-localhost Access
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.
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
```bash
# In .env
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
# Rebuild the web image so the build-time value is baked in
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
```
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **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
@@ -302,9 +252,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,34 +21,25 @@ 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
repo: multica
# Align with our CLI release flow which pre-creates a *published* GitHub
# Release via `gh release create`. The electron-builder default of
# `releaseType: draft` conflicts with `existingType=release` and causes
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
# which breaks electron-updater auto-update on installed clients.
releaseType: release
npmRebuild: false

View File

@@ -12,10 +12,7 @@ export default defineConfig({
},
renderer: {
server: {
// Allow parallel worktrees to run `pnpm dev:desktop` side-by-side
// (e.g. Multica Canary alongside a primary checkout) by overriding
// the renderer port via env. Falls back to 5173 for the common case.
port: Number(process.env.DESKTOP_RENDERER_PORT) || 5173,
port: 5173,
strictPort: true,
},
plugins: [react(), tailwindcss()],

View File

@@ -10,28 +10,4 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell must
// flow through openExternalSafely in src/main/external-url.ts (scheme
// allowlist). Enforce it statically so a direct shell.openExternal call
// cannot silently regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"CallExpression[callee.object.name='shell'][callee.property.name='openExternal']",
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},
{
files: ["src/main/external-url.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
];

View File

@@ -2,31 +2,16 @@
"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",
"dev": "pnpm run bundle-cli && electron-vite dev",
"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 +24,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

@@ -1,73 +0,0 @@
#!/usr/bin/env node
// Rebrand the bundled Electron.app's Info.plist so `pnpm dev:desktop`
// shows "Multica Canary" in the menu bar, Cmd+Tab switcher, and
// Activity Monitor. On macOS these titles come from CFBundleName at
// launch time — `app.setName()` cannot override them at runtime, so
// patching the plist in node_modules is the only working fix.
//
// Idempotent: runs on every dev launch and no-ops once the plist already
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable
// (.../Electron.app/Contents/MacOS/Electron). Walk up to Contents/Info.plist.
const electronBin = require("electron");
const plistPath = resolve(electronBin, "../../Info.plist");
function plistGet(key) {
try {
return execFileSync(
"/usr/libexec/PlistBuddy",
["-c", `Print :${key}`, plistPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
).trim();
} catch {
return "";
}
}
function plistSet(key, value) {
try {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Set :${key} ${value}`,
plistPath,
]);
} catch {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Add :${key} string ${value}`,
plistPath,
]);
}
}
if (
plistGet("CFBundleName") === DESIRED_NAME &&
plistGet("CFBundleDisplayName") === DESIRED_NAME
) {
process.exit(0);
}
// Break any pnpm hardlink to the global store: read, unlink, rewrite.
// PlistBuddy would otherwise write through the hardlink and mutate the
// shared store file (and every other project's Electron.app with it).
const original = readFileSync(plistPath);
unlinkSync(plistPath);
writeFileSync(plistPath, original);
plistSet("CFBundleName", DESIRED_NAME);
plistSet("CFBundleDisplayName", DESIRED_NAME);
console.log(`[brand-dev-electron] ${plistPath} → CFBundleName="${DESIRED_NAME}"`);

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 {
@@ -78,18 +39,6 @@ function sh(cmd) {
}
}
/**
* Strip the leading `--` that npm/pnpm insert to separate their own
* flags from the ones meant for the underlying script. Without this,
* `pnpm package -- --mac --arm64 --publish always` forwards the bare
* `--` into electron-builder's argv, which terminates option parsing
* and turns `--publish always` into ignored positional arguments.
*/
export function stripLeadingSeparator(argv) {
if (argv.length > 0 && argv[0] === "--") return argv.slice(1);
return argv;
}
/**
* Pure transformation from the `git describe --tags --always --dirty`
* output to the value we feed into electron-builder's extraMetadata.version.
@@ -116,231 +65,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 +91,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 +101,43 @@ function main() {
);
}
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
if (disableMacNotarize) {
// Step 4: assemble electron-builder args.
const passthrough = 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 } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -45,229 +37,3 @@ describe("normalizeGitVersion", () => {
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
});
});
describe("stripLeadingSeparator", () => {
it("removes the leading -- inserted by npm/pnpm", () => {
expect(stripLeadingSeparator(["--", "--mac", "--arm64", "--publish", "always"])).toEqual([
"--mac", "--arm64", "--publish", "always",
]);
});
it("leaves args untouched when there is no leading --", () => {
expect(stripLeadingSeparator(["--mac", "--arm64"])).toEqual(["--mac", "--arm64"]);
});
it("does not strip a -- that appears mid-argv", () => {
expect(stripLeadingSeparator(["--mac", "--", "--arm64"])).toEqual([
"--mac", "--", "--arm64",
]);
});
it("handles an empty array", () => {
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

@@ -1,73 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("electron", () => ({
shell: { openExternal: vi.fn().mockResolvedValue(undefined) },
}));
import { shell } from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
describe("isSafeExternalHttpUrl", () => {
it("allows http and https URLs", () => {
expect(isSafeExternalHttpUrl("https://multica.ai")).toBe(true);
expect(isSafeExternalHttpUrl("http://localhost:3000/auth")).toBe(true);
});
it("allows https URLs with embedded credentials", () => {
// WHATWG URL parses these as https; OS-level handling is the shell's concern.
expect(isSafeExternalHttpUrl("https://user:pass@example.com")).toBe(true);
});
it("normalizes scheme casing so uppercase variants can't bypass", () => {
expect(isSafeExternalHttpUrl("HTTPS://example.com")).toBe(true);
expect(isSafeExternalHttpUrl("FILE:///etc/passwd")).toBe(false);
});
it("rejects dangerous pseudo-schemes", () => {
expect(isSafeExternalHttpUrl("javascript:alert(1)")).toBe(false);
expect(
isSafeExternalHttpUrl("data:text/html,<script>alert(1)</script>"),
).toBe(false);
});
it("rejects filesystem and network transport schemes", () => {
expect(isSafeExternalHttpUrl("file:///etc/passwd")).toBe(false);
expect(isSafeExternalHttpUrl("ftp://example.com/x")).toBe(false);
expect(isSafeExternalHttpUrl("smb://share/x")).toBe(false);
});
it("rejects local-handler schemes used in past RCE chains", () => {
expect(isSafeExternalHttpUrl("vscode://file/test")).toBe(false);
expect(isSafeExternalHttpUrl("ms-msdt:/id%20PCWDiagnostic")).toBe(false);
});
it("rejects mailto and other non-web schemes", () => {
expect(isSafeExternalHttpUrl("mailto:test@example.com")).toBe(false);
expect(isSafeExternalHttpUrl("tel:+15551234567")).toBe(false);
});
it("rejects empty, whitespace, and malformed input", () => {
expect(isSafeExternalHttpUrl("")).toBe(false);
expect(isSafeExternalHttpUrl(" ")).toBe(false);
expect(isSafeExternalHttpUrl("not a url")).toBe(false);
expect(isSafeExternalHttpUrl("http://")).toBe(false);
});
});
describe("openExternalSafely", () => {
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear();
});
it("forwards http/https URLs to shell.openExternal", () => {
openExternalSafely("https://multica.ai");
expect(shell.openExternal).toHaveBeenCalledWith("https://multica.ai");
});
it("does not call shell.openExternal for rejected schemes", () => {
openExternalSafely("file:///etc/passwd");
openExternalSafely("javascript:alert(1)");
openExternalSafely("not a url");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});

View File

@@ -1,38 +0,0 @@
import { shell } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
// URL parser lowercases the protocol field.
export function isSafeExternalHttpUrl(url: string): boolean {
return getHttpProtocol(url) !== null;
}
// Canonical wrapper around shell.openExternal. All renderer-controlled URLs
// that eventually reach the OS shell MUST flow through here; direct calls
// to `shell.openExternal` elsewhere in the main process are banned by the
// no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
export function openExternalSafely(url: string): Promise<void> | void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked openExternal: ${describeScheme(url)}`);
return;
}
return shell.openExternal(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);
if (protocol === "http:" || protocol === "https:") return protocol;
return null;
} catch {
return null;
}
}
function describeScheme(url: string): string {
try {
return `scheme=${new URL(url).protocol}`;
} catch {
return "invalid URL";
}
}

View File

@@ -1,16 +1,10 @@
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
@@ -49,19 +43,6 @@ function handleDeepLink(url: string): void {
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
return;
}
// multica://invite/<invitationId>
// Dispatched from the web invite page when the user chooses "Open in
// desktop app". The renderer opens the invite overlay — no tab, no
// route persistence, so deep-linking the same invite twice stays safe.
if (parsed.hostname === "invite") {
const id = parsed.pathname.replace(/^\//, "");
if (id && mainWindow) {
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
}
return;
}
} catch {
// Ignore malformed URLs
@@ -80,9 +61,6 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
@@ -105,7 +83,7 @@ function createWindow(): void {
});
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
shell.openExternal(details.url);
return { action: "deny" };
});
@@ -116,27 +94,6 @@ function createWindow(): void {
}
}
// --- Dev / production isolation -------------------------------------------
// Give dev mode a separate app name and userData path so it gets its own
// single-instance lock file and doesn't conflict with the packaged production
// app. Must run BEFORE requestSingleInstanceLock() because the lock location
// is derived from the userData path. (Same approach VS Code uses for
// Stable / Insiders coexistence.)
// DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side
// without fighting for the shared single-instance lock. The suffix is
// appended to the app name + userData path, so each worktree gets its own
// lock file. Default (no env var) keeps behavior unchanged — the common
// single-worktree case still lands at "Multica Canary".
const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
if (is.dev) {
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
}
// --- Protocol registration -----------------------------------------------
if (process.defaultApp) {
@@ -168,43 +125,19 @@ if (!gotTheLock) {
});
app.whenReady().then(() => {
electronApp.setAppUserModelId(
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
// macOS: replace the default Electron dock icon with the bundled logo
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
electronApp.setAppUserModelId("ai.multica.desktop");
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login).
// All scheme-allowlist enforcement lives in openExternalSafely — this
// is the single audit point for renderer-controlled URLs reaching the
// OS shell under the app's intentional webSecurity: false + sandbox:
// false configuration.
// IPC: open URL in default browser (used by renderer for Google login)
ipcMain.handle("shell:openExternal", (_event, url: string) => {
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 };
return shell.openExternal(url);
});
// 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
// modals (create-workspace, onboarding) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;

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,15 +1,8 @@
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. */
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
@@ -58,10 +51,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) =>
@@ -36,15 +11,6 @@ const desktopAPI = {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Listen for invitation IDs delivered via deep link */
onInviteOpen: (callback: (invitationId: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
callback(invitationId);
ipcRenderer.on("invite:open", handler);
return () => {
ipcRenderer.removeListener("invite:open", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
@@ -121,10 +87,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,20 +1,15 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { 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 { workspaceKeys } 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";
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -25,8 +20,8 @@ function AppContent() {
// as soon as getMe resolves, which would cause DesktopShell to mount
// before the workspace list is hydrated and briefly see `!workspace`.
// This local flag keeps the loading screen up until the whole chain
// finishes, so IndexRedirect gets a definitive workspace state on
// first render.
// finishes, so the shell's "needs onboarding?" check gets a definitive
// workspace state on first render.
const [bootstrapping, setBootstrapping] = useState(false);
// Tell the main process which backend URL we talk to, so daemon-manager
@@ -35,17 +30,6 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
// in, InvitePage's queries will fail and render the "not found" state,
// which is acceptable; the expected pre-flight happens in the web app
// (login + next=/invite/... dance) before the deep link is ever dispatched.
useEffect(() => {
return window.desktopAPI.onInviteOpen((invitationId) => {
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
});
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
// daemonAPI.syncToken is handled separately by the [user] effect below, which
// fires whenever a user logs in (deep link, session restore, account switch).
@@ -85,80 +69,6 @@ function AppContent() {
})();
}, [user]);
// When a user who started the session with zero workspaces creates their
// first one, restart the daemon so it picks up the new workspace
// immediately (otherwise workspaceSyncLoop's next 30s tick would be the
// earliest pickup point). Specifically scoped to "started empty" because
// 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({
...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]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
// (synchronously after render, before paint) rather than the render
// phase — the original render-phase pattern triggered React's
// "Cannot update a component while rendering a different component"
// warning because `switchWorkspace` is a Zustand setState that the
// TabBar is subscribed to. useLayoutEffect flushes both renders before
// the user sees anything, so there's no visible flicker.
//
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
// the first fetch, so without this guard we'd run validation against an
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
// back to `workspaces[0]` once the real list arrives — losing the user's
// last-opened workspace on every app start.
useLayoutEffect(() => {
if (!workspaceListFetched) return;
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (!activeWorkspaceSlug && workspaces.length > 0) {
switchWorkspace(workspaces[0].slug);
}
}, [workspaces, workspaceListFetched]);
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
const sessionStartedEmptyRef = useRef<boolean | null>(null);
useEffect(() => {
if (!user) {
sessionStartedEmptyRef.current = null;
return;
}
if (!workspaceListFetched) return;
if (sessionStartedEmptyRef.current === null) {
sessionStartedEmptyRef.current = wsCount === 0;
return;
}
if (sessionStartedEmptyRef.current && wsCount >= 1) {
void window.daemonAPI.restart();
sessionStartedEmptyRef.current = false;
}
}, [user, workspaceListFetched, wsCount]);
if (isLoading || bootstrapping) {
return (
<div className="flex h-screen items-center justify-center">
@@ -167,29 +77,17 @@ 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.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
// useLogout clears the storage key, but the live stores stay populated until
// we explicitly reset them here.
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
try {
await window.daemonAPI.clearToken();
} catch {
@@ -203,20 +101,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,13 +13,13 @@ 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 { StepWorkspace } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { OnboardingGate } from "./onboarding-gate";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -109,35 +109,39 @@ export function DesktopShell() {
return (
<DesktopNavigationProvider>
{/* WorkspaceSlugProvider accepts null — components that need slug
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
(throws). TabContent MUST always render so the tab router can
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
<OnboardingGate
onboarding={(onComplete) => (
<div className="flex min-h-screen items-center justify-center overflow-auto bg-background px-6 py-12">
<StepWorkspace onNext={onComplete} />
</div>
)}
>
{/* WorkspaceSlugProvider accepts null — components that need slug
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
(throws). TabContent MUST always render so the tab router can
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
</WorkspaceSlugProvider>
</OnboardingGate>
</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

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { OnboardingGate } from "./onboarding-gate";
// Prevent actual API calls — the tests seed data via setQueryData.
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
},
}));
function createTestQueryClient(
workspaces: Array<{ id: string; slug: string }> = [],
) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Seed the workspace list so the gate can read it synchronously.
qc.setQueryData(workspaceKeys.list(), workspaces);
return qc;
}
function renderGate(
qc: QueryClient,
onboarding?: (onComplete: () => void) => React.ReactNode,
) {
return render(
<QueryClientProvider client={qc}>
<OnboardingGate
onboarding={
onboarding ??
((onComplete) => (
<button type="button" data-testid="finish" onClick={onComplete}>
wizard
</button>
))
}
>
<div data-testid="main">main shell</div>
</OnboardingGate>
</QueryClientProvider>,
);
}
describe("OnboardingGate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders children when workspaces exist in cache", () => {
const qc = createTestQueryClient([{ id: "ws-1", slug: "my-team" }]);
renderGate(qc);
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByText("wizard")).not.toBeInTheDocument();
});
it("renders onboarding when workspace list is empty", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByText("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("keeps the wizard mounted even after workspaces appear in cache mid-flow", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByText("wizard")).toBeInTheDocument();
// Simulate the onboarding wizard creating a workspace mid-flow.
act(() => {
qc.setQueryData(workspaceKeys.list(), [
{ id: "ws-new", slug: "new-team" },
]);
});
// Wizard should still be visible — only onComplete dismisses it.
expect(screen.getByText("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("transitions to children after the wizard calls onComplete", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByTestId("finish")).toBeInTheDocument();
act(() => {
screen.getByTestId("finish").click();
});
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByTestId("finish")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@multica/core/workspace/queries";
/**
* Renders `onboarding` as a full-screen takeover when the user has no
* workspaces, otherwise renders `children`.
*
* Reads the workspace list directly from React Query — this works regardless
* of whether a WorkspaceSlugProvider is mounted, unlike useCurrentWorkspace()
* which depends on slug context from the router tree.
*
* The onboarding decision is frozen at first mount via the lazy useState
* initializer: this way the onboarding wizard controls its own exit by
* calling the `onComplete` callback, instead of being unmounted the moment
* the workspace list updates mid-flow (e.g. after the user creates their
* first workspace in step 1 but still has steps 2-3 to complete).
*
* The frozen decision only triggers when the initial query has settled AND
* the list is empty. While the list is loading, children are rendered
* (the shell shows its own loading state).
*/
export function OnboardingGate({
onboarding,
children,
}: {
onboarding: (onComplete: () => void) => ReactNode;
children: ReactNode;
}) {
const { data: workspaces, isFetched } = useQuery(workspaceListOptions());
const hasWorkspaces = !isFetched || (workspaces?.length ?? 0) > 0;
const [initialNeedsOnboarding] = useState(() => !hasWorkspaces);
const [onboardingDone, setOnboardingDone] = useState(false);
if (initialNeedsOnboarding && !onboardingDone) {
return <>{onboarding(() => setOnboardingDone(true))}</>;
}
return <>{children}</>;
}

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

@@ -29,8 +29,7 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -67,13 +66,16 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
// No navigate() — Activity handles visibility
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
// No navigate() — store handles activeTabId switch
};
// Stop pointer down on close so it doesn't start a drag on the parent button.
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
@@ -122,13 +124,10 @@ function NewTabButton() {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
// New tab opens in the currently active workspace — tabs are scoped
// per workspace, so there is no cross-workspace ambiguity to resolve.
const activeSlug = useTabStore.getState().activeWorkspaceSlug;
if (!activeSlug) return;
const path = paths.workspace(activeSlug).issues();
const path = "/issues";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
if (tabId) setActiveTab(tabId);
setActiveTab(tabId);
// No navigate() — new tab's router starts at /issues automatically
};
return (
@@ -143,17 +142,17 @@ function NewTabButton() {
}
export function TabBar() {
const group = useActiveGroup();
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const moveTab = useTabStore((s) => s.moveTab);
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const handleDragEnd = (event: DragEndEvent) => {
@@ -183,7 +182,7 @@ export function TabBar() {
))}
</SortableContext>
</DndContext>
{group && <NewTabButton />}
<NewTabButton />
</div>
);
}

View File

@@ -1,52 +1,40 @@
import { Activity, useEffect } from "react";
import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { useTabStore } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
import type { Tab } from "@/stores/tab-store";
/**
* Inner wrapper rendered inside each tab's RouterProvider. The router
* reference is stable for a tab's lifetime, so passing it in directly
* (instead of re-deriving from the store) avoids needless re-renders.
*/
function TabRouterInner({ tab }: { tab: Tab }) {
useTabRouterSync(tab.id, tab.router);
/** Inner wrapper rendered inside each tab's RouterProvider. */
function TabRouterInner({ tabId }: { tabId: string }) {
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
useTabRouterSync(tabId, tab!.router);
return null;
}
/**
* Renders the active workspace's tabs using Activity for state preservation.
* Renders all tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
*
* When switching workspaces, the previous workspace's tabs unmount entirely
* and the new workspace's tabs mount fresh — cross-workspace state
* preservation is an explicit non-goal (keeping all workspaces' tabs warm
* simultaneously would bloat memory and make workspace switching feel
* anything but "switching").
*/
export function TabContent() {
const group = useActiveGroup();
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
// Sync document.title when switching tabs within the active workspace.
// Sync document.title when switching tabs
useEffect(() => {
if (!group) return;
const tab = group.tabs.find((t) => t.id === group.activeTabId);
const tab = tabs.find((t) => t.id === activeTabId);
if (tab) document.title = tab.title;
}, [group?.activeTabId, group?.tabs]);
if (!group) return null;
}, [activeTabId, tabs]);
return (
<>
{group.tabs.map((tab) => (
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
mode={tab.id === activeTabId ? "visible" : "hidden"}
>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
<TabRouterInner tabId={tab.id} />
</TabNavigationProvider>
</Activity>
))}

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,79 +0,0 @@
import { useQuery } from "@tanstack/react-query";
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";
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).
*
* This component is intentionally thin — just a fixed positioning shell
* that covers the tab system. It does NOT hide traffic lights or provide
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
* native macOS traffic lights stay visible and the page content can fill
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
* pre-dashboard flows and keeps platform chrome consistent across every
* "not-in-dashboard" surface.
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared view components under `packages/views/`,
* so web and desktop render identical content.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
if (!overlay) return null;
return <WindowOverlayInner />;
}
function WindowOverlayInner() {
const overlay = useWindowOverlayStore((s) => s.overlay);
const close = useWindowOverlayStore((s) => s.close);
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
// has at least one workspace. Zero-workspace users can only Log out or
// complete the flow.
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>
);
}

View File

@@ -1,32 +1,21 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import {
workspaceBySlugOptions,
workspaceListOptions,
} from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
setCurrentWorkspace,
rehydrateAllWorkspaceStores,
} from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { useTabStore } from "@/stores/tab-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
*
* Resolves the URL slug → workspace UUID via the React Query list cache
* (seeded by AuthInitializer). Children do not render until the workspace
* is fully resolved — useWorkspaceId() inside child pages is therefore
* guaranteed non-null when called. Two industry-standard identities are
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
*
* Unlike web, desktop never renders a "workspace not available" page: the
* app has no URL bar and no clickable links from outside the session, so
* landing on an inaccessible slug can only mean stale state (a persisted
* tab group for a workspace the current user no longer has access to, or
* active eviction). Both cases resolve by dropping the stale tab group
* from the tab store — the TabBar then renders a different workspace or
* the WindowOverlay takes over (zero valid workspaces).
* Reads :workspaceSlug from react-router params, resolves it to a Workspace
* object via the React Query list cache, and syncs the URL-derived workspace
* into the platform singleton (slug + UUID). Children (DashboardGuard +
* dashboard layout) handle auth check, loading, and workspace-not-found.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
@@ -34,51 +23,34 @@ export function WorkspaceRouteLayout() {
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
const { data: workspace, isFetched: listFetched } = useQuery({
...workspaceBySlugOptions(workspaceSlug ?? ""),
enabled: !!user && !!workspaceSlug,
});
const { data: wsList } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Feed the URL slug into the platform singleton so the API client's
// X-Workspace-Slug header and persist namespace follow the active tab.
// setCurrentWorkspace self-dedupes on slug equality.
if (workspace && workspaceSlug) {
// Render-phase sync (same pattern as web layout).
const syncedSlugRef = useRef<string | null>(null);
if (workspace && workspaceSlug && syncedSlugRef.current !== workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
rehydrateAllWorkspaceStores();
// Double-write legacy localStorage key for rollback compatibility — see
// apps/web/app/[workspaceSlug]/layout.tsx for the full rationale.
try {
localStorage.setItem("multica_workspace_id", workspace.id);
} catch {
// non-critical
}
syncedSlugRef.current = workspaceSlug;
}
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// Stale-slug auto-heal: when this tab's slug fails to resolve, drop the
// whole workspace group from the tab store. Per-workspace tab grouping
// means the cleanup is a single validator call — the TabContent will
// unmount this tab (and all siblings in the stale group) once the store
// updates. We don't navigate this tab's router because the tab's path
// is scoped to the stale slug; navigating to "/" would create an
// inconsistent "tab in group X with path /" state.
// Slug doesn't resolve → onboarding. Skip when user is null.
useEffect(() => {
if (!user) return;
if (!listFetched) return;
if (workspace) return;
if (hasBeenSeen) return; // active eviction in flight — let the other path win
if (!wsList) return;
const validSlugs = new Set(wsList.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}, [user, listFetched, workspace, hasBeenSeen, wsList]);
if (listFetched && !workspace) navigate(paths.onboarding(), { replace: true });
}, [user, listFetched, workspace, navigate]);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
if (!listFetched) return null;
if (!workspace) return null; // auto-heal effect above handles the cleanup
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

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

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import type { DataRouter } from "react-router-dom";
import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
import { useTabStore } from "@/stores/tab-store";
/**
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
@@ -9,32 +9,32 @@ import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
/**
* Per-tab back/forward navigation derived from the active workspace's
* active tab.
*
* Subscribed via primitive selectors so this hook only re-renders when
* the numeric history state actually changes — path ticks on the active
* tab (which don't shift historyIndex) don't churn the back/forward
* buttons.
* Per-tab back/forward navigation derived from the active tab's history state.
* Replaces the old global useNavigationHistory() hook.
*/
export function useTabHistory() {
const router = useActiveTabRouter();
const { historyIndex, historyLength } = useActiveTabHistory();
// Return the actual tab object from the store — stable reference.
// Do NOT create a new object in the selector (causes infinite re-renders).
const activeTab = useTabStore((s) =>
s.tabs.find((t) => t.id === s.activeTabId),
);
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyLength - 1;
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
const canGoForward =
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
const goBack = useCallback(() => {
if (!router || historyIndex <= 0) return;
popDirectionHints.set(router, "back");
router.navigate(-1);
}, [router, historyIndex]);
if (!activeTab || activeTab.historyIndex <= 0) return;
popDirectionHints.set(activeTab.router, "back");
activeTab.router.navigate(-1);
}, [activeTab]);
const goForward = useCallback(() => {
if (!router || historyIndex >= historyLength - 1) return;
popDirectionHints.set(router, "forward");
router.navigate(1);
}, [router, historyIndex, historyLength]);
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
return;
popDirectionHints.set(activeTab.router, "forward");
activeTab.router.navigate(1);
}, [activeTab]);
return { canGoBack, canGoForward, goBack, goForward };
}

View File

@@ -2,23 +2,20 @@ import { useEffect } from "react";
import { useTabStore } from "@/stores/tab-store";
/**
* Watches document.title via MutationObserver and updates the active tab's
* title. Pages set document.title via TitleSync (route handle.title) or
* useDocumentTitle(). This observer picks up the change and syncs it to
* the tab store.
* Watches document.title via MutationObserver and updates the active tab's title.
*
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
* This observer picks up the change and syncs it to the tab store.
*/
export function useActiveTitleSync() {
useEffect(() => {
const observer = new MutationObserver(() => {
const title = document.title;
if (!title) return;
const state = useTabStore.getState();
if (!state.activeWorkspaceSlug) return;
const group = state.byWorkspace[state.activeWorkspaceSlug];
if (!group) return;
const activeTab = group.tabs.find((t) => t.id === group.activeTabId);
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.title !== title) {
state.updateTab(activeTab.id, { title });
useTabStore.getState().updateTab(activeTabId, { title });
}
});

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

@@ -5,101 +5,16 @@ import {
type NavigationAdapter,
} from "@multica/views/navigation";
import { useAuthStore } from "@multica/core/auth";
import { isReservedSlug } from "@multica/core/paths";
import {
useTabStore,
resolveRouteIcon,
useActiveTabIdentity,
useActiveTabRouter,
getActiveTab,
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-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
* workspace-scoped (root, login, any reserved prefix).
*/
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
}
/**
* Intercept navigation to "transition" paths — pre-workspace flows that on
* desktop are rendered as a window-level overlay instead of a tab route.
* Returns `true` if the navigation was handled (caller should NOT proceed).
*
* Side effect: when opening the new-workspace overlay, the tab router is
* ALSO reset to "/". Rationale — the only way a push lands on
* /workspaces/new is that the workspace context is gone (fresh install,
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
* path would keep those components mounted under the overlay; the next
* render after the list cache updates would then throw (useWorkspaceId
* etc) because the slug no longer resolves.
*/
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
const overlay = useWindowOverlayStore.getState();
if (path === "/workspaces/new") {
overlay.open({ type: "new-workspace" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
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 {
id = decodeURIComponent(path.slice("/invite/".length));
} catch {
return true;
}
if (id) {
overlay.open({ type: "invite", invitationId: id });
return true;
}
}
// Any other navigation cancels a live overlay.
if (overlay.overlay) overlay.close();
return false;
}
/**
* Intercept pushes that change workspace. Returns `true` if the navigation
* was delegated to the tab store (caller should NOT proceed).
*
* This is the entry point that makes shared code platform-agnostic:
* sidebar dropdown, cmd+k "switch workspace", post-delete redirects,
* invite-accept flow — they all call `useNavigation().push(path)` with a
* full workspace URL, and on desktop we translate "target slug differs
* from active" into "switch the tab-group that's visible in the TabBar".
*/
function tryRouteToOtherWorkspace(path: string): boolean {
const targetSlug = extractWorkspaceSlug(path);
if (!targetSlug) return false;
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (targetSlug === activeWorkspaceSlug) return false;
switchWorkspace(targetSlug, path);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
*
* Reads from the active tab's memory router via router.subscribe().
* Does NOT use any react-router hooks — it's above all RouterProviders.
@@ -109,88 +24,59 @@ export function DesktopNavigationProvider({
}: {
children: React.ReactNode;
}) {
// Primitive-only subscriptions so this component doesn't re-render on
// unrelated store updates (e.g. an inactive tab's router tick). We
// resolve the active router here only to subscribe once per tab switch.
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
// Mirror the active tab router's full location (pathname + search) so
// shell-level consumers of useNavigation() — ChatWindow in particular —
// can read URL search params. Must stay in sync with TabNavigationProvider
// below; a partial shape here (just pathname) silently broke focus-mode
// anchor resolution on `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",
search: router?.state.location.search ?? "",
}),
);
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
// Subscribe to the active tab's router for pathname updates
useEffect(() => {
if (!router) {
setLocation({ pathname: "/", search: "" });
return;
}
setLocation({
pathname: router.state.location.pathname,
search: router.state.location.search,
if (!activeTab) return;
setPathname(activeTab.router.state.location.pathname);
return activeTab.router.subscribe((state) => {
setPathname(state.location.pathname);
});
return router.subscribe((state) => {
setLocation({
pathname: state.location.pathname,
search: state.location.search,
});
});
}, [activeTabId, router]);
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (path === "/login") {
// DashboardGuard token expired — force back to login screen
useAuthStore.getState().logout();
return;
}
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
active?.router.navigate(path);
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path);
},
replace: (path: string) => {
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
active?.router.navigate(path, { replace: true });
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path, { replace: true });
},
back: () => {
currentActiveTab()?.router.navigate(-1);
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(-1);
},
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
pathname,
searchParams: new URLSearchParams(),
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
store.switchWorkspace(slug, path);
return;
}
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
[location],
[pathname],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
function currentActiveTab() {
return getActiveTab(useTabStore.getState());
}
/**
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
* Subscribes to the tab's own router for up-to-date pathname.
@@ -215,29 +101,16 @@ export function TabNavigationProvider({
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
router.navigate(path);
},
replace: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
router.navigate(path, { replace: true });
},
push: (path: string) => router.navigate(path),
replace: (path: string) => router.navigate(path, { replace: true }),
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
store.switchWorkspace(slug, path);
return;
}
const icon = resolveRouteIcon(path);
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
const store = useTabStore.getState();
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),

View File

@@ -6,6 +6,7 @@ import {
useMatches,
} from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
@@ -13,14 +14,19 @@ 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 { OnboardingWizard } from "@multica/views/onboarding";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
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";
/**
@@ -53,28 +59,76 @@ function PageShell() {
);
}
function OnboardingRoute() {
const nav = useNavigation();
return (
<OnboardingWizard
onComplete={(ws) => nav.push(paths.workspace(ws.slug).issues())}
/>
);
}
/**
* Root index route: resolves the URL-less `/` path to a concrete destination.
*
* Runs both on first login (App.tsx seeded the cache) and on app reopen
* (AuthInitializer seeded the cache). Reading from React Query avoids
* duplicate fetches across tabs — each tab's memory router hits this
* component independently but the query is deduped.
*
* Sends first-time users without any workspace to onboarding, everyone
* else to their first workspace's issues page. Persisted tab paths that
* already carry a workspace slug bypass this component entirely.
*/
function IndexRedirect() {
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
// Wait for the query to settle so we don't redirect to onboarding on
// the initial render before the seeded/fetched data arrives.
if (!isFetched) return null;
const firstWorkspace = wsList?.[0];
if (firstWorkspace) {
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
}
return <Navigate to={paths.onboarding()} replace />;
}
function InviteRoute() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/**
* Route definitions shared by all tabs.
*
* Every tab path is workspace-scoped: `/{slug}/{route}/...`. Pre-workspace
* flows (create workspace, accept invite) are NOT routes — they render as a
* window-level overlay via `WindowOverlay`, dispatched by the navigation
* adapter's transition-path interception. The `activeWorkspaceSlug` in the
* tab store decides which workspace's tabs are visible in the TabBar;
* workspace-less state (zero-workspace user) shows the overlay instead.
*
* The root index route stays as a harmless safety net. With per-workspace
* tabs, nothing should construct a tab at `/` — but if one ever slips
* through (malformed persisted state that dodges the migration, direct
* router.navigate from unforeseen code), the index falls back to null
* rather than 404; App.tsx's bootstrap repoints activeWorkspaceSlug on the
* next render pass.
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
* slug to a workspace and syncing side-effects (api client, persist namespace,
* Zustand mirror). Global (pre-workspace) routes — onboarding and invite —
* sit at the top level alongside the workspace wrapper.
*/
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
children: [
{ index: true, element: null },
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
// list from React Query cache (seeded by AuthInitializer on reopen
// or App.tsx on deep-link login) and bounces to the first
// workspace's issues page — or onboarding if the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "onboarding",
element: <OnboardingRoute />,
handle: { title: "Get Started" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,
@@ -113,7 +167,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 +184,6 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -1,224 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// createTabRouter transitively pulls in route modules that expect a browser
// router context. For pure store tests we stub it to a minimal disposable.
const createTabRouterMock = vi.hoisted(() =>
vi.fn(() => ({
dispose: vi.fn(),
state: { location: { pathname: "/" } },
navigate: vi.fn(),
subscribe: vi.fn(() => () => {}),
})),
);
vi.mock("../routes", () => ({
createTabRouter: createTabRouterMock,
}));
import {
sanitizeTabPath,
migrateV1ToV2,
useTabStore,
} from "./tab-store";
beforeEach(() => {
createTabRouterMock.mockClear();
useTabStore.getState().reset();
});
describe("sanitizeTabPath", () => {
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
expect(sanitizeTabPath("/")).toBeNull();
expect(sanitizeTabPath("")).toBeNull();
});
it("silently rejects transition paths (no warn — navigation adapter intercepts them)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/workspaces/new")).toBeNull();
expect(sanitizeTabPath("/invite/abc")).toBeNull();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("passes through valid workspace-scoped paths", () => {
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/issues")).toBeNull();
expect(sanitizeTabPath("/settings")).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
});
});
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
{ id: "t3", path: "/butter/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t2",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace).sort()).toEqual(["acme", "butter"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(2);
expect(v2.byWorkspace.butter.tabs).toHaveLength(1);
expect(v2.byWorkspace.acme.activeTabId).toBe("t2");
expect(v2.byWorkspace.butter.activeTabId).toBe("t3"); // first tab in group
expect(v2.activeWorkspaceSlug).toBe("acme"); // contained v1.activeTabId
});
it("drops tabs at root / transition / reserved-slug paths", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/workspaces/new", title: "New", icon: "Plus" },
{ id: "t3", path: "/invite/abc", title: "Invite", icon: "Mail" },
{ id: "t4", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t1",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace)).toEqual(["acme"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(1);
// v1.activeTabId was dropped; active falls back to first group's first tab.
expect(v2.activeWorkspaceSlug).toBe("acme");
expect(v2.byWorkspace.acme.activeTabId).toBe("t4");
});
it("handles empty v1 state gracefully", () => {
const v2 = migrateV1ToV2({ tabs: [], activeTabId: "" });
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
it("handles v1 with no tabs field (corrupted state)", () => {
const v2 = migrateV1ToV2({});
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
});
describe("useTabStore actions", () => {
it("switchWorkspace creates a new group with a default tab on first entry", () => {
useTabStore.getState().switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
});
it("switchWorkspace without openPath restores the group's last active tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const acmeProjectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
store.setActiveTab(acmeProjectsId);
// Enter a different workspace then come back
store.switchWorkspace("butter");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("butter");
store.switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.activeTabId).toBe(acmeProjectsId);
});
it("switchWorkspace with openPath dedupes into an existing tab with same path", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default /acme/issues
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.switchWorkspace("acme", "/acme/issues");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2); // no duplicate created
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues");
});
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("acme", "/acme/issues/bug-42");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues/bug-42");
});
it("openTab dedupes by path within the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const id1 = store.openTab("/acme/projects", "Projects", "FolderKanban");
const id2 = store.openTab("/acme/projects", "Projects", "FolderKanban");
expect(id1).toBe(id2);
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
});
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeTab(onlyTabId);
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.switchWorkspace("acme");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
// Admin removed the user from acme
store.validateWorkspaceSlugs(new Set(["butter"]));
const s = useTabStore.getState();
expect(Object.keys(s.byWorkspace)).toEqual(["butter"]);
expect(s.activeWorkspaceSlug).toBe("butter");
});
it("validateWorkspaceSlugs sets activeWorkspaceSlug to null when all groups are dropped", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.validateWorkspaceSlugs(new Set());
const s = useTabStore.getState();
expect(s.byWorkspace).toEqual({});
expect(s.activeWorkspaceSlug).toBeNull();
});
it("reset wipes the whole store", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.reset();
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBeNull();
expect(s.byWorkspace).toEqual({});
});
it("setActiveTab across workspaces also flips the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
const acmeTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.setActiveTab(acmeTabId);
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});

View File

@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import { isReservedSlug } from "@multica/core/paths";
import { isGlobalPath } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -13,7 +13,6 @@ import { createTabRouter } from "../routes";
export interface Tab {
id: string;
/** Every tab path is workspace-scoped: `/{workspaceSlug}/{route}/...`. */
path: string;
title: string;
icon: string;
@@ -22,77 +21,24 @@ export interface Tab {
historyLength: number;
}
export interface WorkspaceTabGroup {
tabs: Tab[];
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
activeTabId: string;
}
interface TabStore {
/**
* The workspace currently visible in the TabBar / TabContent. Null in three
* cases:
* - Fresh install, before any workspace exists or is selected.
* - Logged-out state (reset() wipes it).
* - Every workspace the user had access to got deleted / revoked.
* When null, TabContent renders nothing and the WindowOverlay takes over.
*/
activeWorkspaceSlug: string | null;
tabs: Tab[];
activeTabId: string;
/**
* Tab groups keyed by workspace slug. Each slug maps to an independent
* (tabs, activeTabId) pair; switching workspaces swaps the visible set
* without affecting any other group. Cross-workspace tab leakage — the
* bug that drove this refactor — is impossible by construction because
* there is no global tab array anymore.
*/
byWorkspace: Record<string, WorkspaceTabGroup>;
/**
* Switch to a workspace.
* - If the group doesn't exist yet, create it with a single default tab.
* - If `openPath` is given, find a tab with that exact path and activate
* it; otherwise add a new tab and activate it.
* - If `openPath` is omitted, restore the group's last active tab
* (VSCode / Slack behavior — workspaces resume where you left off).
*/
switchWorkspace: (slug: string, openPath?: string) => void;
/** Open-or-activate (dedupes by path) a tab in the active workspace. */
/** Open a background tab. Deduplicates by path. Returns the tab id. */
openTab: (path: string, title: string, icon: string) => string;
/** Always creates a new tab (no dedupe) in the active workspace. */
/** Always create a new tab (no dedup). Returns the tab id. */
addTab: (path: string, title: string, icon: string) => string;
/**
* Close a tab. Finds it across all workspaces (callers like the X button
* only know the tab id, not the owning workspace). If this is the last
* tab in its workspace, reseed a default tab so the invariant
* "every live workspace has at least one tab" holds.
*/
/** Close a tab. Disposes router. */
closeTab: (tabId: string) => void;
/**
* Activate a tab. Finds it across all workspaces. Sets both the owning
* workspace as active and that group's activeTabId; needed for any code
* path that "jumps" to a tab belonging to a non-active workspace.
*/
/** Switch to a tab by id. */
setActiveTab: (tabId: string) => void;
/** Patch metadata of a tab (router-sync, title-sync). Finds across groups. */
/** Update a tab's metadata (path, title, icon — partial). */
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
/** Update a tab's history tracking. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder within the active workspace's group only. */
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
* `activeWorkspaceSlug` if it pointed at one of the dropped groups.
*/
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
/**
* Wipe everything. Called from logout so the next user doesn't inherit
* the prior user's tabs. Zustand persist only writes to localStorage;
* clearing the storage key alone would leave this live store intact
* until app restart.
*/
reset: () => void;
}
// ---------------------------------------------------------------------------
@@ -112,70 +58,38 @@ const ROUTE_ICONS: Record<string, string> = {
};
/**
* Resolve a route icon from a pathname.
* Resolve a route icon from a pathname. Title is NOT determined here — it
* comes from document.title.
*
* Tab paths are always workspace-scoped: `/{slug}/{route}/...`, so the route
* segment lives at index 1. Pre-workspace flows (create, invite) are rendered
* by the window overlay, never as tabs.
* Path shape after the workspace URL refactor:
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
* - global (onboarding/invite/auth/login): `/{route}/...` → use segment index 0
*
* Title is NOT determined here — it comes from document.title.
* `isGlobalPath` is the single source of truth for which prefixes are global.
*/
export function resolveRouteIcon(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
}
/** Extract the leading workspace slug from a path, or null if the path
* isn't workspace-scoped (global path, root, or empty). */
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Path sanitization (defensive)
// Store
// ---------------------------------------------------------------------------
/**
* Defensive: catch paths that don't belong in the tab store.
* Sentinel path for new tabs with no explicit destination. The tab store is
* workspace-implicit — it doesn't know which workspace is active, so it can't
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
* matches the top-level index route, which redirects to the workspace default
* (slug-aware redirect lives in routes.tsx / App.tsx).
*
* Two kinds of rejects:
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
* pre-workspace flows rendered by the window overlay on desktop, not
* tab routes. The navigation adapter normally intercepts these before
* they reach the store; this guard catches older persisted state.
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
* was constructed without the workspace prefix. The router would
* interpret `issues` as a workspace slug → NoAccessPage.
*
* Returns null for rejects (caller decides how to recover — usually by
* dropping the tab or substituting a default). Unlike the prior design,
* there is no root "/" sentinel — tabs are always scoped.
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
export function sanitizeTabPath(path: string): string | null {
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (!firstSegment) return null;
if (isReservedSlug(firstSegment)) {
// Don't log for known transition paths — these are legitimate inputs
// at the interception boundary (older persisted state or stale callers).
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
if (!isTransition) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Dropping.`,
);
}
return null;
}
return path;
}
// ---------------------------------------------------------------------------
// Tab factory
// ---------------------------------------------------------------------------
const DEFAULT_PATH = "/";
function createId(): string {
return createSafeId();
@@ -193,513 +107,128 @@ function makeTab(path: string, title: string, icon: string): Tab {
};
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
}
function defaultTabFor(slug: string): Tab {
const path = defaultPathFor(slug);
return makeTab(path, "Issues", resolveRouteIcon(path));
}
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
function findTabLocation(
byWorkspace: Record<string, WorkspaceTabGroup>,
tabId: string,
): { slug: string; group: WorkspaceTabGroup; index: number } | null {
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const index = group.tabs.findIndex((t) => t.id === tabId);
if (index >= 0) return { slug, group, index };
}
return null;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
export const useTabStore = create<TabStore>()(
persist(
(set, get) => ({
activeWorkspaceSlug: null,
byWorkspace: {},
tabs: [initialTab],
activeTabId: initialTab.id,
switchWorkspace(slug, openPath) {
// Defensive no-op if slug is empty/invalid — callers like the
// NavigationAdapter's path-parser should already have filtered
// these, but belt-and-braces keeps garbage out of the store.
if (!slug) return;
const { byWorkspace } = get();
const existing = byWorkspace[slug];
openTab(path, title, icon) {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path);
if (existing) return existing.id;
// Decide the desired active path for this workspace.
const desiredPath = openPath ?? (existing ? null : defaultPathFor(slug));
const tab = makeTab(path, title, icon);
set({ tabs: [...tabs, tab] });
return tab.id;
},
if (!existing) {
// First time entering this workspace — create the group.
const seedPath =
desiredPath && sanitizeTabPath(desiredPath) === desiredPath
? desiredPath
: defaultPathFor(slug);
const tab = makeTab(seedPath, "Issues", resolveRouteIcon(seedPath));
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { tabs: [tab], activeTabId: tab.id },
},
});
return;
}
addTab(path, title, icon) {
const tab = makeTab(path, title, icon);
set((s) => ({ tabs: [...s.tabs, tab] }));
return tab.id;
},
// Workspace already has tabs. Either dedupe into an existing tab or
// add a new one (when openPath was supplied and no tab matches it).
if (desiredPath) {
const clean = sanitizeTabPath(desiredPath);
if (clean) {
const match = existing.tabs.find((t) => t.path === clean);
if (match) {
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { ...existing, activeTabId: match.id },
},
});
return;
}
const tab = makeTab(clean, "Issues", resolveRouteIcon(clean));
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: {
tabs: [...existing.tabs, tab],
activeTabId: tab.id,
},
},
});
return;
}
}
closeTab(tabId) {
const { tabs, activeTabId } = get();
// No openPath (or openPath was rejected) — just restore the group.
set({ activeWorkspaceSlug: slug });
},
const closingTab = tabs.find((t) => t.id === tabId);
openTab(path, title, icon) {
const { activeWorkspaceSlug, byWorkspace } = get();
const clean = sanitizeTabPath(path);
if (!activeWorkspaceSlug || !clean) return "";
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return "";
// Never close the last tab — replace with default
if (tabs.length === 1) {
closingTab?.router.dispose();
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
set({ tabs: [fresh], activeTabId: fresh.id });
return;
}
const existing = group.tabs.find((t) => t.path === clean);
if (existing) {
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, activeTabId: existing.id },
},
});
return existing.id;
}
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const tab = makeTab(clean, title, icon);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
tabs: [...group.tabs, tab],
activeTabId: group.activeTabId,
},
},
});
return tab.id;
},
closingTab?.router.dispose();
const next = tabs.filter((t) => t.id !== tabId);
addTab(path, title, icon) {
const { activeWorkspaceSlug, byWorkspace } = get();
const clean = sanitizeTabPath(path);
if (!activeWorkspaceSlug || !clean) return "";
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return "";
if (tabId === activeTabId) {
const newActive = next[Math.min(idx, next.length - 1)];
set({ tabs: next, activeTabId: newActive.id });
} else {
set({ tabs: next });
}
},
const tab = makeTab(clean, title, icon);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
tabs: [...group.tabs, tab],
activeTabId: group.activeTabId,
},
},
});
return tab.id;
},
setActiveTab(tabId) {
set({ activeTabId: tabId });
},
closeTab(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
updateTab(tabId, patch) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, ...patch } : t,
),
}));
},
const closing = group.tabs[index];
closing.router.dispose();
updateTabHistory(tabId, historyIndex, historyLength) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
),
}));
},
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
// always has at least one tab. Closing a workspace as an explicit
// action is a separate concern (Leave/Delete in Settings).
const fresh = defaultTabFor(slug);
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
return;
}
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
const nextActiveTabId =
group.activeTabId === tabId
? nextTabs[Math.min(index, nextTabs.length - 1)].id
: group.activeTabId;
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
},
setActiveTab(tabId) {
const { byWorkspace, activeWorkspaceSlug } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group } = hit;
if (slug === activeWorkspaceSlug && group.activeTabId === tabId) return;
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { ...group, activeTabId: tabId },
},
});
},
updateTab(tabId, patch) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
updateTabHistory(tabId, historyIndex, historyLength) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, toIndex),
},
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
const nextByWorkspace: Record<string, WorkspaceTabGroup> = {};
for (const slug of Object.keys(byWorkspace)) {
if (validSlugs.has(slug)) {
nextByWorkspace[slug] = byWorkspace[slug];
} else {
changed = true;
for (const t of byWorkspace[slug].tabs) t.router.dispose();
}
}
let nextActive = activeWorkspaceSlug;
if (nextActive && !validSlugs.has(nextActive)) {
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
changed = true;
}
if (!changed) return;
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
},
reset() {
const { byWorkspace } = get();
for (const slug of Object.keys(byWorkspace)) {
for (const t of byWorkspace[slug].tabs) t.router.dispose();
}
set({ activeWorkspaceSlug: null, byWorkspace: {} });
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
}),
{
name: "multica_tabs",
version: 2,
version: 1,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
}
return persistedState as V2Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
byWorkspace: Object.fromEntries(
Object.entries(state.byWorkspace).map(([slug, group]) => [
slug,
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
),
},
]),
tabs: state.tabs.map(
({ router, historyIndex, historyLength, ...rest }) => rest,
),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<V2Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const persisted = persistedState as
| Pick<TabStore, "tabs" | "activeTabId">
| undefined;
if (!persisted?.tabs?.length) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
for (const [slug, pGroup] of Object.entries(persisted.byWorkspace)) {
const tabs: Tab[] = [];
for (const pTab of pGroup.tabs) {
const clean = sanitizeTabPath(pTab.path);
// Persisted path may have come from a stale version or a
// manual edit. Drop rather than rewrite so we never silently
// put users on a path that doesn't match the group's slug.
if (!clean || extractWorkspaceSlug(clean) !== slug) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] dropping persisted tab "${pTab.path}" from ` +
`group "${slug}" — path/slug mismatch`,
);
continue;
const tabs: Tab[] = persisted.tabs.map((tab) => {
// Migration: pre-refactor tab paths like "/issues/abc" lack a
// workspace slug prefix. These would 404 in the new router.
// Reset to "/" so IndexRedirect picks the right workspace.
let path = tab.path;
if (path !== "/" && !isGlobalPath(path)) {
const segments = path.split("/").filter(Boolean);
const firstSegment = segments[0] ?? "";
// If the first segment IS a known route name (e.g. "issues",
// "projects"), it's an old-format path missing the slug prefix.
if (ROUTE_ICONS[firstSegment]) {
path = "/";
}
tabs.push({
id: pTab.id,
path: clean,
title: pTab.title,
icon: pTab.icon,
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
});
}
if (tabs.length === 0) continue;
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
byWorkspace[slug] = { tabs, activeTabId };
}
return {
...tab,
path,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
});
const activeWorkspaceSlug =
persisted.activeWorkspaceSlug && byWorkspace[persisted.activeWorkspaceSlug]
? persisted.activeWorkspaceSlug
: (Object.keys(byWorkspace)[0] ?? null);
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
? persisted.activeTabId
: tabs[0].id;
return { ...currentState, byWorkspace, activeWorkspaceSlug };
return { ...currentState, tabs, activeTabId };
},
},
),
);
// ---------------------------------------------------------------------------
// Persisted shapes (for migration)
// ---------------------------------------------------------------------------
interface V1Tab {
id: string;
path: string;
title: string;
icon: string;
}
interface V1Persisted {
tabs: V1Tab[];
activeTabId: string;
}
interface V2PersistedTab {
id: string;
path: string;
title: string;
icon: string;
}
interface V2PersistedGroup {
tabs: V2PersistedTab[];
activeTabId: string;
}
interface V2Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V2PersistedGroup>;
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];
for (const tab of oldTabs) {
const slug = extractWorkspaceSlug(tab.path);
if (!slug) continue; // drop root / global-path tabs
if (!byWorkspace[slug]) byWorkspace[slug] = { tabs: [], activeTabId: "" };
byWorkspace[slug].tabs.push({
id: tab.id,
path: tab.path,
title: tab.title,
icon: tab.icon,
});
}
// Each group needs a valid activeTabId. Prefer the one from v1 if it
// landed in this group; otherwise fall back to the first tab.
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const hasOldActive = group.tabs.some((t) => t.id === v1.activeTabId);
group.activeTabId = hasOldActive
? (v1.activeTabId as string)
: group.tabs[0].id;
}
// Active workspace: whichever group inherited the v1 activeTab, falling
// back to the first group we created (arbitrary but deterministic given
// Object.keys iteration order on string keys).
let activeWorkspaceSlug: string | null = null;
for (const slug of Object.keys(byWorkspace)) {
if (byWorkspace[slug].activeTabId === v1.activeTabId) {
activeWorkspaceSlug = slug;
break;
}
}
if (!activeWorkspaceSlug) {
activeWorkspaceSlug = Object.keys(byWorkspace)[0] ?? null;
}
return { activeWorkspaceSlug, byWorkspace };
}
// ---------------------------------------------------------------------------
// Selectors (convenience hooks)
// ---------------------------------------------------------------------------
/**
* Pure non-hook helper — useful from event handlers / effects that already
* need `.getState()`. For React subscriptions prefer the stable selectors
* below.
*/
export function getActiveTab(s: TabStore): Tab | null {
if (!s.activeWorkspaceSlug) return null;
const group = s.byWorkspace[s.activeWorkspaceSlug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
}
/**
* The active workspace's tab group, or null when no workspace is active.
*
* Zustand compares selector returns with `Object.is`. Because `updateTab`
* / `updateTabHistory` replace the group object on every router tick
* (immutable update), this selector returns a new reference on every
* router event — that's fine for TabBar which needs to observe tab-list
* changes, but don't use this selector from components that only care
* about one primitive (use `useActiveTabHistory` / `useActiveTabRouter`
* instead).
*/
export function useActiveGroup(): WorkspaceTabGroup | null {
return useTabStore((s) =>
s.activeWorkspaceSlug ? (s.byWorkspace[s.activeWorkspaceSlug] ?? null) : null,
);
}
/**
* Active tab id + active workspace slug as a compact pair. Both primitives
* are stable across unrelated store updates — e.g. an inactive tab's
* router tick doesn't churn these, so consumers don't re-render.
*
* Useful anywhere you'd previously have reached for `useActiveTab()` and
* only needed the identity (for memoization, effect deps, ipc).
*/
export function useActiveTabIdentity(): { slug: string | null; tabId: string | null } {
const slug = useTabStore((s) => s.activeWorkspaceSlug);
const tabId = useTabStore((s) =>
s.activeWorkspaceSlug
? (s.byWorkspace[s.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
);
return { slug, tabId };
}
/**
* Active tab's router — a stable reference across tab updates, because
* routers are created once per tab and never replaced by `updateTab`.
* Subscribers only re-render when the active tab *changes*, not on
* router events within the current tab.
*/
export function useActiveTabRouter(): DataRouter | null {
return useTabStore((s) => getActiveTab(s)?.router ?? null);
}
/**
* History tracking for the active tab as primitives. Subscribers re-render
* only when the numeric index / length change (i.e. on actual navigations),
* not on unrelated store updates.
*/
export function useActiveTabHistory(): {
historyIndex: number;
historyLength: number;
} {
const historyIndex = useTabStore((s) => getActiveTab(s)?.historyIndex ?? 0);
const historyLength = useTabStore((s) => getActiveTab(s)?.historyLength ?? 1);
return { historyIndex, historyLength };
}

View File

@@ -1,30 +0,0 @@
import { create } from "zustand";
/**
* Window-level transition overlay: pre-workspace flows that are NOT pages
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
* auto-redirect, or deep link; rendered above the tab system as a full-window
* takeover.
*
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
* desktop the URL is invisible to users — routes are an implementation detail
* of the tab system. Representing transitions as routes meant tabs tried to
* persist them, TabBar rendered on top, and invite deep-linking had no clean
* dispatch target. Modeling them as application state removes all three.
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string }
| { type: "onboarding" };
interface WindowOverlayStore {
overlay: WindowOverlay | null;
open: (overlay: WindowOverlay) => void;
close: () => void;
}
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
overlay: null,
open: (overlay) => set({ overlay }),
close: () => set({ overlay: null }),
}));

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/app/layout.config";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Multica Documentation
</h1>
<p className="max-w-2xl text-lg text-fd-muted-foreground">
The open-source managed agents platform. Turn coding agents into real
teammates assign tasks, track progress, compound skills.
</p>
<div className="flex gap-4">
<Link
href="/docs"
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Get Started
</Link>
<Link
href="https://github.com/multica-ai/multica"
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
>
GitHub
</Link>
</div>
</main>
);
}

View File

@@ -10,7 +10,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default async function Page(props: {
params: Promise<{ slug: string[] }>;
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
@@ -29,12 +29,12 @@ export default async function Page(props: {
);
}
export function generateStaticParams() {
return source.generateParams().filter((p) => p.slug.length > 0);
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug: string[] }>;
params: Promise<{ slug?: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);

View File

@@ -0,0 +1,12 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

View File

@@ -1,4 +1,5 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
export const baseOptions: BaseLayoutProps = {
nav: {
@@ -7,6 +8,11 @@ export const baseOptions: BaseLayoutProps = {
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "GitHub",
url: "https://github.com/multica-ai/multica",

View File

@@ -1,10 +1,7 @@
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export const metadata: Metadata = {
title: {
@@ -19,11 +16,7 @@ export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<RootProvider>
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
</RootProvider>
<RootProvider>{children}</RootProvider>
</body>
</html>
);

View File

@@ -1,18 +0,0 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-24 text-center">
<h1 className="text-3xl font-semibold">Page not found</h1>
<p className="text-fd-muted-foreground">
The page you are looking for doesn&apos;t exist.
</p>
<Link
href="/"
className="inline-flex items-center rounded-md bg-fd-primary px-4 py-2 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Back to docs
</Link>
</main>
);
}

View File

@@ -1,37 +0,0 @@
import { source } from "@/lib/source";
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default function Page() {
const page = source.getPage([]);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export function generateMetadata(): Metadata {
const page = source.getPage([]);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -68,7 +68,7 @@ multica setup
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/getting-started/self-hosting) for details.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
## Verify
@@ -78,7 +78,7 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:

View File

@@ -212,7 +212,7 @@ multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
@@ -227,7 +227,7 @@ multica issue get <id> --output json
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
@@ -281,70 +281,6 @@ multica issue run-messages <task-id> --output json
multica issue run-messages <task-id> --since 42 --output json
```
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
## Configuration
### View Config

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

@@ -45,7 +45,7 @@ Then configure, authenticate, and start the daemon:
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime

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 log in with any email + code **`888888`**.
<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,31 +53,21 @@ 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
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
<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`.
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
</Callout>
### Step 3 — Install CLI & Start Daemon
@@ -157,15 +147,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 +187,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 +198,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,7 +11,7 @@ Once you have the CLI installed (or signed up for [Multica Cloud](https://multic
multica setup # Configure, authenticate, and start the daemon
```
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) available on your PATH.
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) available on your PATH.
## 2. Verify your runtime

View File

@@ -41,7 +41,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
## Next Steps
- [Cloud Quickstart](/getting-started/cloud-quickstart)
- [Self-Hosting](/getting-started/self-hosting)
- [CLI Installation](/cli/installation)
- [Contributing](/developers/contributing)
- [Cloud Quickstart](/docs/getting-started/cloud-quickstart)
- [Self-Hosting](/docs/getting-started/self-hosting)
- [CLI Installation](/docs/cli/installation)
- [Contributing](/docs/developers/contributing)

View File

@@ -2,6 +2,6 @@ import { docs } from "@/.source";
import { loader } from "fumadocs-core/source";
export const source = loader({
baseUrl: "/",
baseUrl: "/docs",
source: docs.toFumadocsSource(),
});

View File

@@ -5,7 +5,6 @@ const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
basePath: "/docs",
};
export default withMDX(config);

View File

@@ -2,10 +2,8 @@
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
@@ -13,10 +11,6 @@ export default function InviteAcceptPage() {
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
@@ -29,8 +23,5 @@ export default function InviteAcceptPage() {
if (isLoading || !user) return null;
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return <InvitePage invitationId={params.id} onBack={onBack} />;
return <InvitePage invitationId={params.id} />;
}

View File

@@ -11,51 +11,32 @@ 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,
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
// than silently drifting behind a mock reimplementation.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
authStateRef.state.sendCode = mockSendCode;
authStateRef.state.verifyCode = mockVerifyCode;
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
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 };
return { useAuthStore };
});
// Mock auth-cookie
@@ -70,7 +51,6 @@ vi.mock("@multica/core/api", () => ({
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
issueCliToken: mockIssueCliToken,
},
}));
@@ -79,9 +59,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 +131,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 { useAuthStore } from "@multica/core/auth";
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,67 +22,39 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
// `next` carries a protected URL the user was originally headed to
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
// "/issues" default — if `next` is absent we decide after login based on
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
// cannot bounce the user off-origin after a successful login.
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
// the user's workspace list.
const nextUrl = 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
// the user arrived to authorize the CLI.
// Already authenticated — honor ?next= or fall back to first workspace /
// onboarding. 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.onboarding(),
);
}, [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.onboarding(),
);
};
// Build Google OAuth state: encode platform + next URL so the callback
@@ -110,52 +66,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 +84,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

@@ -2,71 +2,25 @@
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";
import { paths } from "@multica/core/paths";
import { OnboardingWizard } 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,
});
// Redirect to login if not authenticated
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) router.replace(paths.login());
}, [isLoading, user, router]);
if (isLoading || !user || hasOnboarded) return null;
if (isLoading || !user) 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>
<OnboardingWizard
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
/>
);
}

View File

@@ -1,38 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
export default function Page() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
}, [isLoading, user, router]);
if (isLoading || !user) return null;
// Back goes to the root path — the workspace layout redirects from
// there to the user's default workspace. Only show Back when there's
// somewhere to go back to (user already has at least one workspace).
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return (
<NewWorkspacePage
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
);
}

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

@@ -67,7 +67,7 @@ export default async function LandingLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} landing-light h-full overflow-x-hidden overflow-y-auto bg-white`}>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
</div>
</>

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

@@ -0,0 +1,28 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-4 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1 max-w-md" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -1,15 +1,15 @@
"use client";
import { use, useEffect } from "react";
import { use, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
import {
setCurrentWorkspace,
rehydrateAllWorkspaceStores,
} from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
export default function WorkspaceLayout({
children,
@@ -23,14 +23,6 @@ export default function WorkspaceLayout({
const isAuthLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
// Workspace routes require auth. If user is unauthenticated (initial visit
// without a session, token expired, another tab logged out, etc.), bounce
// to /login. Without this, the layout renders null and the user sees a
// blank page stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) router.replace(paths.login());
}, [isAuthLoading, user, router]);
// Resolve workspace by slug from the React Query list cache.
// Enabled only when user is authenticated — otherwise the list query isn't seeded.
const { data: workspace, isFetched: listFetched } = useQuery({
@@ -38,50 +30,46 @@ export default function WorkspaceLayout({
enabled: !!user,
});
// Render-phase sync: feed the URL slug into the platform singleton so
// the first child query's X-Workspace-Slug header is already correct.
// setCurrentWorkspace self-dedupes + runs rehydrate as a side effect;
// safe to call on every render.
if (workspace) {
// Render-phase sync: set the current workspace slug + UUID into the
// platform singleton BEFORE children render. This ensures the first
// child query's X-Workspace-Slug header is already correct.
// The ref guard prevents re-running on every render.
const syncedSlugRef = useRef<string | null>(null);
if (workspace && syncedSlugRef.current !== workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
rehydrateAllWorkspaceStores();
syncedSlugRef.current = workspaceSlug;
}
// Cookie write (last_workspace_slug) — proxy reads it on next page load
// to redirect unauthenticated-URL hits to the user's last workspace.
// Cookie write (last_workspace_slug) — proxy reads it on next page load.
// ALSO write legacy localStorage["multica_workspace_id"] for forward/back
// compatibility: if this version ever gets reverted to the pre-refactor
// build, the legacy code reads that localStorage key to know which
// workspace to attach to API requests. Without double-writing, a rollback
// would leave returning users with empty data (API calls would have no
// X-Workspace-ID header). Forward compatible — new code ignores this key.
useEffect(() => {
if (!workspace || typeof document === "undefined") return;
const oneYear = 60 * 60 * 24 * 365;
const secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie = `last_workspace_slug=${encodeURIComponent(workspaceSlug)}; path=/; max-age=${oneYear}; SameSite=Lax${secure}`;
try {
localStorage.setItem("multica_workspace_id", workspace.id);
} catch {
// localStorage may be unavailable in restricted contexts; non-critical.
}
}, [workspace, workspaceSlug]);
// Remember whether this slug has resolved before. Used below to avoid
// flashing NoAccessPage during active workspace removal (delete, leave,
// or realtime eviction) — in those cases the caller is navigating away
// and we just need to hold null briefly.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// Slug doesn't match any workspace the user has access to → onboarding.
// Wait for the list query to settle so we don't bounce on first render.
// Skip when user is null — DashboardGuard handles the /login redirect.
useEffect(() => {
if (!user) return;
if (listFetched && !workspace) router.replace(paths.onboarding());
}, [user, listFetched, workspace, router]);
const loadingIndicator = (
<div className="flex h-svh items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
if (isAuthLoading) return loadingIndicator;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the list hasn't populated or the slug is unknown — gating
// here makes that invariant hold for every descendant.
if (!listFetched) return loadingIndicator;
if (!workspace) {
// If we've resolved this slug before in this session, it was just
// removed from our list (deleted/left/evicted). A navigate is almost
// certainly in flight — render null to avoid a NoAccessPage flash.
if (hasBeenSeen) return null;
// Otherwise: the URL points at a workspace the user never had access
// to. Show explicit feedback instead of silently redirecting. Doesn't
// distinguish 404 vs 403 to avoid letting attackers enumerate slugs.
return <NoAccessPage />;
}
// Auth still loading → render nothing (let DashboardGuard show its loader).
if (isAuthLoading) return null;
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -1,112 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { paths } from "@multica/core/paths";
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
id: "user-1",
name: "Test",
email: "test@multica.ai",
avatar_url: null,
onboarded_at: null,
onboarding_questionnaire: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
// exercised rather than silently diverging from the source of truth.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
return {
...actual,
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ loginWithGoogle: mockLoginWithGoogle }),
};
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
googleLogin: vi.fn(),
},
}));
import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
});
it("unonboarded user lands on /onboarding regardless of next=", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
});
it("unonboarded user with no next= also lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
mockSearchParams.set("state", "next:https://evil.example");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
});
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
});
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
});

View File

@@ -3,9 +3,9 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { 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,
@@ -42,9 +42,7 @@ function CallbackContent() {
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
// Strip "next:" prefix, then drop anything that isn't a safe relative path
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
const redirectUri = `${window.location.origin}/auth/callback`;
@@ -62,17 +60,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 /onboarding if the user has none.
const [first] = wsList;
const defaultDest = first
? paths.workspace(first.slug).issues()
: paths.onboarding();
router.push(nextUrl || defaultDest);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -3,44 +3,3 @@
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
* @multica/ui/styles/base.css
* ============================================================================= */
/* The landing route tree is intentionally always-light (hero/cli/cloud
* sections use hardcoded dark/light palettes). Shared components rendered
* inside (e.g. CloudWaitlistExpand on /download) use semantic tokens that
* otherwise flip to dark values under the `.dark` class set by next-themes,
* producing a palette mismatch against the hardcoded section. Re-declare
* tokens to their light values so nested token-driven components stay in
* lockstep with the surrounding design. */
.landing-light,
.landing-light * {
color-scheme: light;
}
.landing-light {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Inter, Geist_Mono, 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,91 +1,8 @@
"use client";
import {
type MouseEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { LandingHeader } from "./landing-header";
import { LandingFooter } from "./landing-footer";
import { useLocale } from "../i18n";
import type { Locale } from "../i18n/types";
const MONTHS_EN = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
type ParsedDate = { year: number; month: number; day: number };
function parseDate(dateStr: string): ParsedDate {
const parts = dateStr.split("-");
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
};
}
function monthYearLabel(year: number, month: number, locale: Locale) {
if (!year || !month) return "";
if (locale === "zh") return `${year}\u5e74${month}\u6708`;
return `${MONTHS_EN[month - 1]} ${year}`;
}
function fullDateLabel(dateStr: string, locale: Locale) {
const { year, month, day } = parseDate(dateStr);
if (!year || !month || !day) return dateStr;
if (locale === "zh") return `${year}\u5e74${month}\u6708${day}\u65e5`;
return `${MONTHS_EN[month - 1]} ${day}, ${year}`;
}
type Release = {
version: string;
date: string;
title: string;
changes: string[];
features?: string[];
improvements?: string[];
fixes?: string[];
};
type MonthGroup = {
key: string;
year: number;
month: number;
entries: Release[];
};
function groupByMonth(entries: readonly Release[]): MonthGroup[] {
const groups: MonthGroup[] = [];
for (const entry of entries) {
const { year, month } = parseDate(entry.date);
const key = `${year}-${month}`;
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.entries.push(entry);
} else {
groups.push({ key, year, month, entries: [entry] });
}
}
return groups;
}
function anchorId(version: string) {
return `release-${version.replace(/\./g, "-")}`;
}
function ChangeList({ items }: { items: string[] }) {
return (
@@ -104,222 +21,74 @@ function ChangeList({ items }: { items: string[] }) {
}
export function ChangelogPageClient() {
const { t, locale } = useLocale();
const { t } = useLocale();
const categoryLabels = t.changelog.categories;
const entries = t.changelog.entries;
const groups = useMemo(() => groupByMonth(entries), [entries]);
const [activeVersion, setActiveVersion] = useState<string>(
entries[0]?.version ?? ""
);
const navLockRef = useRef<number | null>(null);
useEffect(() => {
if (entries.length === 0) return;
const visible = new Set<string>();
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((e) => {
const v = (e.target as HTMLElement).dataset.version;
if (!v) return;
if (e.isIntersecting) visible.add(v);
else visible.delete(v);
});
// Ignore observer updates while we're programmatically scrolling
// to a clicked target — otherwise the active indicator flickers
// through each passing entry.
if (navLockRef.current !== null) return;
const firstVisible = entries.find((r) => visible.has(r.version));
if (firstVisible) {
setActiveVersion(firstVisible.version);
return;
}
const scrollY = window.scrollY;
let best = entries[0]?.version ?? "";
for (const r of entries) {
const el = document.getElementById(anchorId(r.version));
if (!el) continue;
if (el.getBoundingClientRect().top + scrollY <= scrollY + 160) {
best = r.version;
}
}
setActiveVersion(best);
},
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
);
entries.forEach((r) => {
const el = document.getElementById(anchorId(r.version));
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [entries]);
const jumpTo =
(version: string) => (e: MouseEvent<HTMLAnchorElement>) => {
const el = document.getElementById(anchorId(version));
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${anchorId(version)}`);
setActiveVersion(version);
if (navLockRef.current !== null) {
window.clearTimeout(navLockRef.current);
}
navLockRef.current = window.setTimeout(() => {
navLockRef.current = null;
}, 800);
};
return (
<>
<LandingHeader variant="light" />
<main className="bg-white text-[#0a0d12]">
<div className="mx-auto max-w-[1080px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<div className="lg:grid lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16">
<aside className="hidden lg:block">
<nav
aria-label={t.changelog.toc}
className="sticky top-28 max-h-[calc(100vh-8rem)] overflow-y-auto pb-8 pr-2"
>
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#0a0d12]/50">
{t.changelog.toc}
</h3>
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="relative mt-5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-[4px] top-7 bottom-2 w-px bg-[#0a0d12]/10"
/>
<div className="mt-16 space-y-16">
{t.changelog.entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
<ol className="space-y-5">
{groups.map((group) => (
<li key={group.key}>
<p className="ml-6 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#0a0d12]/45">
{monthYearLabel(group.year, group.month, locale)}
</p>
return (
<div key={release.version} className="relative">
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{release.date}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
<ol className="mt-1.5">
{group.entries.map((release) => {
const isActive =
release.version === activeVersion;
const { day } = parseDate(release.date);
return (
<li key={release.version}>
<a
href={`#${anchorId(release.version)}`}
onClick={jumpTo(release.version)}
aria-current={isActive ? "true" : undefined}
className={[
"group relative flex items-center gap-3 rounded-md py-1 pr-2 text-[13px] transition-colors",
isActive
? "text-[#0a0d12]"
: "text-[#0a0d12]/55 hover:text-[#0a0d12]/80",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"relative z-10 block size-[9px] shrink-0 rounded-full border transition-all duration-200",
isActive
? "border-[#0a0d12] bg-[#0a0d12] ring-4 ring-[#0a0d12]/8"
: "border-[#0a0d12]/25 bg-white group-hover:border-[#0a0d12]/60",
].join(" ")}
/>
<span
className={[
"w-[1.25rem] shrink-0 text-right tabular-nums",
isActive
? "font-semibold"
: "font-medium",
].join(" ")}
>
{day}
</span>
<span className="tabular-nums text-[11px] text-[#0a0d12]/35">
v{release.version}
</span>
</a>
</li>
);
})}
</ol>
</li>
))}
</ol>
</div>
</nav>
</aside>
<div className="mx-auto min-w-0 max-w-[720px] lg:mx-0">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="mt-16 space-y-16">
{entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
return (
<section
key={release.version}
id={anchorId(release.version)}
data-version={release.version}
className="relative scroll-mt-28"
>
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{fullDateLabel(release.date, locale)}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
)}
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
) : (
<ChangeList items={release.changes} />
)}
</section>
);
})}
</div>
</div>
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
</div>
) : (
<ChangeList items={release.changes} />
)}
</div>
);
})}
</div>
</div>
</main>

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

@@ -1,10 +1,9 @@
"use client";
import { useCallback, useState } from "react";
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,
@@ -12,6 +11,8 @@ import {
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
@@ -44,21 +45,24 @@ export function LandingHero() {
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href="/download"
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
onClick={() => captureDownloadIntent("landing_hero")}
>
<Download className="size-4" aria-hidden />
{t.hero.downloadDesktop}
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<div className="mt-10 flex items-center justify-center gap-8">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
@@ -91,6 +95,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
@@ -98,6 +160,7 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -113,7 +176,6 @@ function ProductImage({ alt }: { alt: string }) {
alt={alt}
width={3532}
height={2382}
priority
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}

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 onboarding 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.onboarding());
return;
}
router.replace(paths.workspace(first.slug).issues());
}, [isLoading, user, list, router]);
return null;
}

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