Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
6cd6b1ee6b fix(server): trigger agent on comments regardless of issue status
Previously shouldEnqueueOnComment suppressed agent triggers on done/
cancelled issues, requiring an explicit @mention to resume the
conversation. The gate was non-obvious and confused users who expected
a regular reply to wake the agent up.

Drop the status check — comments are conversational and should wake
the agent up at any status. @mention already bypasses all gates, so
behavior for mentions is unchanged.

Refs multica-ai/multica#1205
2026-04-17 00:53:53 +08:00
515 changed files with 15424 additions and 45171 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
@@ -163,7 +162,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 +176,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

@@ -278,7 +278,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 +293,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 +332,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 +349,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 +385,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

View File

@@ -592,19 +592,6 @@ If you want to stop PostgreSQL and keep your local databases:
make db-down
```
If you want a fresh database for the current checkout only (drops the
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
```bash
make stop # stop backend/frontend first
make db-reset
make start
```
- only affects the current env's database; other worktree databases are untouched
- refuses to run if `DATABASE_URL` points at a remote host
- pass `ENV_FILE=.env.worktree` to target a specific worktree
If you want to wipe all local PostgreSQL data for this repo:
```bash

View File

@@ -36,11 +36,11 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_APP_VERSION=dev
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

164
Makefile
View File

@@ -1,4 +1,4 @@
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,23 +36,10 @@ define REQUIRE_ENV
fi
endef
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
##@ Help
help: ## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
makehelp: help ## Alias for `make help`
# ---------- Self-hosting (Docker Compose) ----------
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -64,16 +51,8 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
echo " make selfhost-build"; \
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -87,11 +66,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -102,57 +77,16 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
# Stop all Docker Compose self-host services
selfhost-stop:
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
##@ One-click
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
# First-time setup: install deps, start DB, run migrations
setup:
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
@@ -163,7 +97,8 @@ setup: ## Prepare the current checkout from its env file: install deps, ensure D
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
start: ## Start backend and frontend for the current checkout and run migrations first
# Start all services (backend + frontend)
start:
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@@ -177,7 +112,8 @@ start: ## Start backend and frontend for the current checkout and run migrations
pnpm dev:web & \
wait
stop: ## Stop backend and frontend processes for the current checkout
# Stop all services
stop:
$(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@@ -189,52 +125,33 @@ stop: ## Stop backend and frontend processes for the current checkout
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
# Full verification: typecheck + unit tests + Go tests + E2E
check:
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up: ## Start the shared PostgreSQL container used by main and worktrees
db-up:
@$(COMPOSE) up -d postgres
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
db-down:
@$(COMPOSE) down
# Drop + recreate the current env's database, then run all migrations.
# Use for a clean slate in local dev. Only affects the DB named in
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
# worktree DBs are untouched. Refuses to run against a remote host.
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
$(REQUIRE_ENV)
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
esac
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
setup-main: ## Prepare the main checkout using .env
setup-main:
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main: ## Start the main checkout using .env
start-main:
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main: ## Stop the main checkout processes defined by .env
stop-main:
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main: ## Run the full verification pipeline for the main checkout
check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
setup-worktree:
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
@@ -243,68 +160,65 @@ setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree: ## Start this worktree using .env.worktree
start-worktree:
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree: ## Stop this worktree's backend and frontend processes
stop-worktree:
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree: ## Run the full verification pipeline for this worktree
check-worktree:
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
##@ Individual commands
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
# One-command dev: auto-setup env/deps/db/migrations, then start all services
dev:
@bash scripts/dev.sh
server: ## Run only the Go server for the current checkout
# Go server only
server:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
daemon:
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
cli:
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
multica: ## Run the multica CLI entrypoint directly from the Go source tree
multica:
cd server && go run ./cmd/multica $(MULTICA_ARGS)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build: ## Build the server, CLI, and migrate binaries into server/bin
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database
##@ Database
migrate-up: ## Create the target DB if needed, then apply database migrations
migrate-up:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down: ## Create the target DB if needed, then roll back database migrations
migrate-down:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc: ## Regenerate sqlc code
sqlc:
cd server && sqlc generate
# Cleanup
##@ Cleanup
clean: ## Remove generated server binaries and temp files
clean:
rm -rf server/bin server/tmp

View File

@@ -85,8 +85,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---
@@ -185,3 +184,13 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -172,3 +172,13 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -24,9 +24,9 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This 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
@@ -162,15 +152,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 +182,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
@@ -246,7 +218,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -262,31 +234,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then restart the stack:
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d --build
```
### 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 +258,8 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

@@ -21,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 {
@@ -116,231 +77,20 @@ function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function uniqueOrdered(values) {
return [...new Set(values)];
}
export function envWithLocalBins(env = process.env, root = desktopRoot) {
const pathKey =
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
const existingPath = env[pathKey] ?? "";
const localBins = uniqueOrdered([
resolve(root, "node_modules", ".bin"),
resolve(root, "..", "..", "node_modules", ".bin"),
]);
const mergedPath = uniqueOrdered([
...localBins,
...String(existingPath)
.split(delimiter)
.filter(Boolean),
]).join(delimiter);
return { ...env, [pathKey]: mergedPath };
}
function hostPlatformKey(platform = process.platform) {
if (platform === "darwin") return "mac";
if (platform === "win32") return "win";
if (platform === "linux") return "linux";
throw new Error(`[package] unsupported host platform: ${platform}`);
}
function hostArchKey(arch = process.arch) {
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
throw new Error(
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
);
}
function expandPlatformShorthand(token) {
if (!/^-[mwl]{2,}$/.test(token)) return null;
const expanded = [];
for (const char of token.slice(1)) {
if (char === "m") expanded.push("mac");
if (char === "w") expanded.push("win");
if (char === "l") expanded.push("linux");
}
return uniqueOrdered(expanded);
}
function platformKeyForToken(token) {
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
if (config.aliases.has(token)) return platform;
}
return null;
}
function platformTargetsTemplate() {
return { mac: [], win: [], linux: [] };
}
export function parsePackageArgs(argv) {
const sharedArgs = [];
const platformTargets = platformTargetsTemplate();
const requestedPlatforms = [];
const requestedArchs = [];
let allPlatforms = false;
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--all-platforms") {
allPlatforms = true;
continue;
}
const expandedPlatforms = expandPlatformShorthand(token);
if (expandedPlatforms) {
requestedPlatforms.push(...expandedPlatforms);
continue;
}
const platform = platformKeyForToken(token);
if (platform) {
requestedPlatforms.push(platform);
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
platformTargets[platform].push(argv[i + 1]);
i += 1;
}
continue;
}
const arch = ARCH_FLAGS.get(token);
if (arch) {
requestedArchs.push(arch);
continue;
}
sharedArgs.push(token);
}
return {
allPlatforms,
sharedArgs,
platformTargets,
requestedPlatforms: uniqueOrdered(requestedPlatforms),
requestedArchs: uniqueOrdered(requestedArchs),
};
}
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
if (parsed.allPlatforms) {
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
throw new Error(
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
);
}
if (platform !== "darwin") {
throw new Error(
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
);
}
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
}
const platforms =
parsed.requestedPlatforms.length > 0
? parsed.requestedPlatforms
: [hostPlatformKey(platform)];
const archs =
parsed.requestedArchs.length > 0
? parsed.requestedArchs
: [hostArchKey(arch)];
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
if (unsupported.length > 0) {
throw new Error(
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
"Use --x64 or --arm64.",
);
}
return platforms.flatMap((targetPlatform) =>
archs.map((targetArch) => ({
platform: targetPlatform,
arch: targetArch,
})),
);
}
function formatTarget(target) {
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
}
export function builderArgsForTarget(
target,
parsed,
version,
{
disableMacNotarize = false,
hostPlatform = process.platform,
useScopedOutputDir = false,
} = {},
) {
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
const requestedTargets = parsed.platformTargets[target.platform];
if (
target.platform === "linux" &&
hostPlatform !== "linux" &&
requestedTargets.length === 0
) {
// electron-builder only guarantees AppImage/Snap when cross-building
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
// to AppImage unless the caller explicitly requests Linux targets.
builderArgs.push("AppImage");
} else {
builderArgs.push(...requestedTargets);
}
builderArgs.push(`--${target.arch}`);
builderArgs.push(...parsed.sharedArgs);
if (useScopedOutputDir) {
builderArgs.push(
`-c.directories.output=dist/${target.platform}-${target.arch}`,
);
}
// electron-builder's update metadata file is `latest.yml` for Windows
// regardless of arch (only Linux gets an arch suffix automatically — see
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
// channel override, building Windows x64 and arm64 in two invocations
// makes both publish `latest.yml` to the same GitHub Release, so the
// second upload overwrites the first and one of the two architectures
// ends up with no auto-update metadata. Route Windows arm64 to its own
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
// the renderer-side updater pins the matching channel per arch.
if (target.platform === "win" && target.arch === "arm64") {
builderArgs.push("-c.publish.channel=latest-arm64");
}
return builderArgs;
}
function main() {
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const parsed = parsePackageArgs(passthrough);
const buildMatrix = resolveBuildMatrix(parsed);
console.log(
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
);
// Step 1: build + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
// Step 1: build the Electron main/preload/renderer bundles. Without
// Step 2: build the Electron main/preload/renderer bundles. Without
// this step electron-builder silently packages whatever is already in
// out/, which on a fresh checkout (or after a partial build) ships an
// app that white-screens because the renderer bundle is missing.
//
// CI invokes this script via `node scripts/package.mjs`, so we cannot
// rely on pnpm/npm to inject package-local binaries into PATH.
//
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
// PATHEXT when spawning a bare command without a shell — it would fail
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
// through the shell is harmless. See
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
const viteResult = spawnSync("electron-vite", ["build"], {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (viteResult.error) {
console.error(
@@ -353,7 +103,7 @@ function main() {
process.exit(viteResult.status ?? 1);
}
// Step 2: derive the version that should be written into the app.
// Step 3: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -363,62 +113,43 @@ function main() {
);
}
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
if (disableMacNotarize) {
// Step 4: assemble electron-builder args.
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
// credentials, and would otherwise hit a hard failure at the notarize
// step. Detect the missing env and flip notarize off for this run only.
if (!process.env.APPLE_TEAM_ID) {
console.warn(
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
);
builderArgs.push("-c.mac.notarize=false");
}
const useScopedOutputDir = buildMatrix.length > 1;
builderArgs.push(...passthrough);
// Step 3: for each requested target, build the matching CLI into
// resources/bin/ and package that target in isolation.
for (const target of buildMatrix) {
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
execFileSync(
"node",
[
bundleCliScript,
"--target-platform",
PLATFORM_CONFIG[target.platform].runtimePlatform,
"--target-arch",
target.arch,
],
{
stdio: "inherit",
cwd: desktopRoot,
},
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
const builderArgs = builderArgsForTarget(target, parsed, version, {
disableMacNotarize,
hostPlatform: process.platform,
useScopedOutputDir,
});
// Step 4: invoke electron-builder for the current target only.
// `shell: true` for the same Windows `.cmd` shim reason as the
// electron-vite invocation above.
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
process.exit(1);
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.

View File

@@ -1,13 +1,5 @@
import { delimiter, resolve } from "node:path";
import { describe, it, expect } from "vitest";
import {
builderArgsForTarget,
envWithLocalBins,
normalizeGitVersion,
parsePackageArgs,
resolveBuildMatrix,
stripLeadingSeparator,
} from "./package.mjs";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -67,207 +59,3 @@ describe("stripLeadingSeparator", () => {
expect(stripLeadingSeparator([])).toEqual([]);
});
});
describe("parsePackageArgs", () => {
it("collects per-platform targets and shared args", () => {
expect(
parsePackageArgs([
"--win", "nsis",
"--mac", "dmg", "zip",
"--arm64",
"--publish", "never",
]),
).toEqual({
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: {
mac: ["dmg", "zip"],
win: ["nsis"],
linux: [],
},
requestedPlatforms: ["win", "mac"],
requestedArchs: ["arm64"],
});
});
it("expands combined short flags", () => {
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
"mac",
"win",
]);
});
it("tracks the all-platforms shortcut", () => {
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
});
});
describe("resolveBuildMatrix", () => {
it("defaults to the current host platform and arch", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([{ platform: "mac", arch: "arm64" }]);
});
it("expands all-platforms on macOS", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: true,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
]);
});
it("rejects unsupported architectures", () => {
expect(() =>
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["universal"],
},
"darwin",
"arm64",
),
).toThrow(/unsupported Desktop CLI architecture/);
});
});
describe("builderArgsForTarget", () => {
it("adds scoped output directories for multi-target builds", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "arm64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["arm64"],
},
"1.2.3",
{
disableMacNotarize: true,
hostPlatform: "darwin",
useScopedOutputDir: true,
},
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"-c.mac.notarize=false",
"--win",
"nsis",
"--arm64",
"--publish",
"never",
"-c.directories.output=dist/win-arm64",
"-c.publish.channel=latest-arm64",
]);
});
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "always"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "win32", useScopedOutputDir: true },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--win",
"nsis",
"--x64",
"--publish",
"always",
"-c.directories.output=dist/win-x64",
]);
});
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
expect(
builderArgsForTarget(
{ platform: "linux", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["linux"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "darwin" },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--linux",
"AppImage",
"--x64",
"--publish",
"never",
]);
});
});
describe("envWithLocalBins", () => {
it("prepends desktop-local binary directories to PATH", () => {
const desktopRoot = "/repo/apps/desktop";
const result = envWithLocalBins(
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
desktopRoot,
);
expect(result.PATH.split(delimiter)).toEqual([
resolve(desktopRoot, "node_modules", ".bin"),
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
"/usr/local/bin",
"/usr/bin",
]);
});
it("preserves an existing Path key and avoids duplicate entries", () => {
const desktopRoot = "/repo/apps/desktop";
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
const result = envWithLocalBins(
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
desktopRoot,
);
expect(result).not.toHaveProperty("PATH");
expect(result.Path.split(delimiter)).toEqual([
desktopBin,
workspaceBin,
"runner-bin",
]);
});
});

View File

@@ -8,15 +8,35 @@ import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
// Desktop prefers the bundled `multica` CLI shipped inside the app for
// same-repo builds, but it can also repair or bootstrap a managed copy in
// userData on first launch when the bundled binary is missing or unusable.
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
@@ -72,8 +92,14 @@ async function sha256OfFile(path: string): Promise<string> {
async function verifyChecksum(
archivePath: string,
assetName: string,
expected: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
@@ -92,14 +118,7 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
async function installFresh(): Promise<string> {
const target = managedCliPath();
const checksums = await fetchChecksums();
const assetName = selectPlatformReleaseAssetName(checksums.keys());
const expectedChecksum = checksums.get(assetName);
if (!expectedChecksum) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const assetName = platformAssetName();
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
@@ -111,7 +130,7 @@ async function installFresh(): Promise<string> {
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName, expectedChecksum);
await verifyChecksum(archivePath, assetName);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
@@ -124,7 +143,6 @@ async function installFresh(): Promise<string> {
}
await mkdir(dirname(target), { recursive: true });
await rm(target, { force: true }).catch(() => {});
await rename(extractedBin, target);
await chmod(target, 0o755);
@@ -148,10 +166,8 @@ async function installFresh(): Promise<string> {
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(
options: { forceInstall?: boolean } = {},
): Promise<string> {
export async function ensureManagedCli(): Promise<string> {
const target = managedCliPath();
if (existsSync(target) && !options.forceInstall) return target;
if (existsSync(target)) return target;
return installFresh();
}

View File

@@ -1,59 +0,0 @@
import { describe, expect, it } from "vitest";
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
describe("selectPlatformReleaseAssetName", () => {
it("prefers the versioned archive name when both exist", () => {
const assetNames = [
"checksums.txt",
"multica_darwin_amd64.tar.gz",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("falls back to the legacy archive name when only legacy is present", () => {
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica_darwin_amd64.tar.gz",
);
});
it("matches the renamed darwin archive from release assets", () => {
const assetNames = [
"checksums.txt",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
"multica-cli-1.2.3-darwin-arm64.tar.gz",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("matches the renamed windows zip archive", () => {
const assetNames = [
"multica-cli-1.2.3-windows-amd64.zip",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
"multica-cli-1.2.3-windows-amd64.zip",
);
});
it("fails when the current platform asset is missing", () => {
expect(() =>
selectPlatformReleaseAssetName(
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
"darwin",
"arm64",
),
).toThrow(/no release asset found/);
});
});

View File

@@ -1,62 +0,0 @@
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
function platformArchiveDescriptor(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): { os: string; arch: string; ext: string } {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[platform];
const mappedArch = archMap[arch];
if (!os || !mappedArch) {
throw new Error(
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
);
}
const ext = platform === "win32" ? "zip" : "tar.gz";
return { os, arch: mappedArch, ext };
}
export function selectPlatformReleaseAssetName(
assetNames: Iterable<string>,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): string {
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
platform,
arch,
);
const names = [...assetNames];
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
// only ship the legacy archive keep working.
const suffix = `-${os}-${mappedArch}.${ext}`;
const matches = names.filter(
(name) =>
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
);
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
throw new Error(
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
);
}
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
if (names.includes(legacyName)) {
return legacyName;
}
throw new Error(`no release asset found for current platform: ${suffix}`);
}

View File

@@ -1,33 +0,0 @@
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
// Electron ships with no default right-click menu, so a user selecting text
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
// menu using `roles`, which keeps i18n + accelerator handling native.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable } = params;
const hasSelection = selectionText.trim().length > 0;
const menu = new Menu();
if (isEditable && editFlags.canCut) {
menu.append(new MenuItem({ role: "cut" }));
}
if (hasSelection && editFlags.canCopy) {
menu.append(new MenuItem({ role: "copy" }));
}
if (isEditable && editFlags.canPaste) {
menu.append(new MenuItem({ role: "paste" }));
}
if (isEditable && editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(new MenuItem({ role: "selectAll" }));
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}

View File

@@ -316,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,17 +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";
import { installContextMenu } from "./context-menu";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// 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.).
@@ -50,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
@@ -81,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,
@@ -106,12 +83,10 @@ function createWindow(): void {
});
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
shell.openExternal(details.url);
return { action: "deny" };
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
@@ -126,18 +101,9 @@ function createWindow(): void {
// 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));
app.setName("Multica Dev");
app.setPath("userData", join(app.getPath("appData"), "Multica Dev"));
}
// --- Protocol registration -----------------------------------------------
@@ -175,35 +141,13 @@ if (!gotTheLock) {
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);
}
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

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,16 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { PageviewTracker } from "./components/pageview-tracker";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -35,17 +31,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).
@@ -92,53 +77,28 @@ function AppContent() {
// account switches (user A logout → user B login) should not trigger a
// daemon restart here — daemon-manager already restarts on user change
// via syncToken.
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const wsCount = workspaces.length;
const hasOnboarded = useHasOnboarded();
const wsCount = workspaces?.length ?? 0;
// 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.
// Validate persisted tab paths against the current user's workspace list.
// Tabs survive across app restarts and account switches (persisted to
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
// reference a workspace the current user can't access — showing
// NoAccessPage every time they open the app.
//
// 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;
// Run synchronously in render phase rather than in useEffect so the first
// render already sees validated tabs. useEffect runs AFTER commit, which
// means the initial render would briefly show NoAccessPage before the
// effect resets the tab. Zustand supports render-phase setState; the
// validator is idempotent (exits early if nothing changed) so this
// doesn't loop.
if (workspaces) {
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
@@ -167,29 +127,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 +151,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

@@ -12,13 +12,12 @@ import {
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -114,8 +113,7 @@ export function DesktopShell() {
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. */}
users are routed to /workspaces/new by IndexRedirect. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
@@ -123,17 +121,17 @@ export function DesktopShell() {
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling */}
{/* 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>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>
);

View File

@@ -1,39 +0,0 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so its empty
* state can distinguish "no runtime registered" from "runtime is on its
* way" — without the bundled daemon's status, the page shows a
* misleading "Run multica daemon start" hint during the few seconds
* between page load and the daemon's first registration.
*
* `bootstrapping` is true while the daemon is installing, starting, or
* already running but hasn't surfaced as a server-side runtime yet.
* RuntimeList only shows the spinner when the runtime list is also
* empty, so once the daemon registers (and the list fills) the flag
* has no visible effect.
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
useEffect(() => {
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
status.state === "installing_cli" ||
status.state === "starting" ||
status.state === "running";
return (
<RuntimesPage
topSlot={<DaemonRuntimeCard />}
bootstrapping={bootstrapping}
/>
);
}

View File

@@ -1,69 +0,0 @@
import { useEffect } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes.
*
* Desktop has three layers that can own the visible page:
*
* 1. Logged-out state → `/login`. No workspace context, no tabs.
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
* that match the equivalent web routes. Overlays are NOT tab routes on
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
* tab path alone would either miss them or mislabel them as "/".
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
*
* The overlay takes precedence over the tab path because it is visually in
* front of the tab system; the logged-out state shadows both because the
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
* with what the user actually sees.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
* implementation in `apps/web/components/pageview-tracker.tsx`.
*/
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const activeTabPath = useTabStore((s) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
});
const path = resolvePath(user, overlay, activeTabPath);
useEffect(() => {
if (!path) return;
capturePageview(path);
}, [path]);
return null;
}
function resolvePath(
user: unknown,
overlay: WindowOverlay | null,
activeTabPath: string | null,
): string | null {
if (!user) return "/login";
if (overlay) return overlayPath(overlay);
return activeTabPath;
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":
return "/workspaces/new";
case "onboarding":
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
}
}

View File

@@ -5,7 +5,6 @@ import {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
X,
Plus,
@@ -30,8 +29,8 @@ 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";
import { isGlobalPath, paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -40,7 +39,6 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
};
@@ -69,13 +67,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();
};
@@ -124,13 +125,22 @@ 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();
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
// opens in the same context as the active one. Read the slug from the
// active tab's path directly rather than from getCurrentSlug(), because
// that singleton is "last tab to render" (non-deterministic with N tabs
// mounted under <Activity>), while activeTabId is the unambiguous truth.
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
// tab is on a global route (e.g. /workspaces/new, /login).
const { tabs, activeTabId } = useTabStore.getState();
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
let slug: string | null = null;
if (activePath !== "/" && !isGlobalPath(activePath)) {
slug = activePath.split("/").filter(Boolean)[0] ?? null;
}
const path = slug ? paths.workspace(slug).issues() : "/";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
if (tabId) setActiveTab(tabId);
setActiveTab(tabId);
};
return (
@@ -145,17 +155,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) => {
@@ -185,7 +195,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

@@ -2,14 +2,11 @@ import { useEffect } 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,
workspaceListOptions,
} from "@multica/core/workspace";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { useTabStore } from "@/stores/tab-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -20,13 +17,9 @@ import { useTabStore } from "@/stores/tab-store";
* 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).
* If the slug doesn't resolve to any workspace the user has access to,
* we render NoAccessPage instead of silently redirecting — users get
* explicit feedback for stale bookmarks or revoked access.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
@@ -34,7 +27,10 @@ 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.
// Workspace routes require auth. If user is unauthenticated (token
// expired, logged out from another tab, etc.), bounce to /login.
// Without this, the layout renders null and the user sees a blank page
// stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
@@ -44,41 +40,36 @@ export function WorkspaceRouteLayout() {
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.
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
// every render (matters on desktop, where N tabs each mount their own
// layout). Rehydrate is the singleton's internal side effect.
if (workspace && workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Remember whether this slug has resolved before (see hook docs). Gates
// the NoAccessPage render below so active workspace removal doesn't
// flash "Workspace not available" before the navigate lands.
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.
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 (isAuthLoading) return null;
if (!workspaceSlug) return null;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the workspace list hasn't populated or the slug is
// unknown — gating here is the single point where that invariant is
// enforced, so every descendant can call useWorkspaceId() safely.
if (!listFetched) return null;
if (!workspace) return null; // auto-heal effect above handles the cleanup
if (!workspace) {
// Active workspace just removed (delete/leave/realtime eviction) —
// navigate is in flight; hold null briefly instead of flashing
// NoAccessPage.
if (hasBeenSeen) return null;
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
// link from a former teammate's workspace) → explicit feedback.
return <NoAccessPage />;
}
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() 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,15 +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 { ChatPage } from "@multica/views/chat";
import { SettingsPage } from "@multica/views/settings";
import { Download, Server } from "lucide-react";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
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";
/**
@@ -54,28 +59,77 @@ function PageShell() {
);
}
function NewWorkspaceRoute() {
const nav = useNavigation();
return (
<NewWorkspacePage
onSuccess={(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 /workspaces/new,
* 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 /workspaces/new
// 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.newWorkspace()} 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 — workspaces/new 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 /workspaces/new if the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "workspaces/new",
element: <NewWorkspaceRoute />,
handle: { title: "Create Workspace" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,
@@ -114,13 +168,12 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <DesktopRuntimesPage />,
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
{
path: "settings",
element: (
@@ -132,12 +185,6 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -1,42 +1,23 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } 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(() => () => {}),
})),
);
// router context. For pure-function tests we stub it out.
vi.mock("../routes", () => ({
createTabRouter: createTabRouterMock,
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
}));
import {
sanitizeTabPath,
migrateV1ToV2,
useTabStore,
} from "./tab-store";
beforeEach(() => {
createTabRouterMock.mockClear();
useTabStore.getState().reset();
});
import { sanitizeTabPath } from "./tab-store";
describe("sanitizeTabPath", () => {
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
expect(sanitizeTabPath("/")).toBeNull();
expect(sanitizeTabPath("")).toBeNull();
it("passes through root sentinel", () => {
expect(sanitizeTabPath("/")).toBe("/");
});
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 global paths", () => {
expect(sanitizeTabPath("/login")).toBe("/login");
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
});
it("passes through valid workspace-scoped paths", () => {
@@ -44,181 +25,21 @@ describe("sanitizeTabPath", () => {
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
it("rejects paths whose first segment is a reserved slug", () => {
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/issues")).toBeNull();
expect(sanitizeTabPath("/settings")).toBeNull();
expect(sanitizeTabPath("/issues")).toBe("/");
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
expect(sanitizeTabPath("/settings")).toBe("/");
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
// A workspace owner could legitimately pick "acme-issues" or
// "project-x" as their slug — sanitize must not touch these.
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, isReservedSlug } 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,33 @@ 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.
* Reset any tab whose first path segment references a workspace slug the
* current user doesn't have access to. Called after login + workspace list
* is populated (and on every subsequent list change, e.g. realtime
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
* are untouched.
*/
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;
}
// ---------------------------------------------------------------------------
@@ -101,7 +56,6 @@ interface TabStore {
const ROUTE_ICONS: Record<string, string> = {
inbox: "Inbox",
chat: "MessageSquare",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",
@@ -113,594 +67,232 @@ 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 (workspaces/new, 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;
}
// ---------------------------------------------------------------------------
// Path sanitization (defensive)
// ---------------------------------------------------------------------------
/**
* Defensive: catch paths that don't belong in the tab store.
*
* 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.
*/
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
// ---------------------------------------------------------------------------
function createId(): string {
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {
return {
id: createId(),
path,
title,
icon,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
}
/** 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;
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
}
// ---------------------------------------------------------------------------
// 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).
*
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
const DEFAULT_PATH = "/";
function createId(): string {
return createSafeId();
}
/**
* Defensive: catch tab paths that were constructed without a workspace slug
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
* paths would get matched as `workspaceSlug="issues"` by the router and
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
* a valid workspace).
*
* Passes through:
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
* - workspace-scoped paths whose first segment is not a reserved word
*
* Rejects (and rewrites to "/"):
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
* means the caller forgot to prefix the workspace. Logs a warning so the
* buggy call site is easy to find.
*/
export function sanitizeTabPath(path: string): string {
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (isReservedSlug(firstSegment)) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Falling back to "/".`,
);
return DEFAULT_PATH;
}
return path;
}
function makeTab(path: string, title: string, icon: string): Tab {
const safePath = sanitizeTabPath(path);
return {
id: createId(),
path: safePath,
title,
icon,
router: createTabRouter(safePath),
historyIndex: 0,
historyLength: 1,
};
}
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;
}
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
const nextActiveTabId =
group.activeTabId === tabId
? nextTabs[Math.min(index, nextTabs.length - 1)].id
: group.activeTabId;
validateWorkspaceSlugs(validSlugs) {
const { tabs } = get();
let changed = false;
const nextTabs = tabs.map((t) => {
// Skip tabs on non-workspace-scoped paths — nothing to validate.
if (t.path === "/" || isGlobalPath(t.path)) return t;
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
},
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
if (validSlugs.has(firstSegment)) return t;
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 },
},
});
},
// Stale slug: dispose the old router and replace with a fresh one
// pointing at `/`. IndexRedirect will send the tab to a valid
// workspace (or /workspaces/new if the user now has none).
changed = true;
t.router.dispose();
return {
...t,
path: DEFAULT_PATH,
title: "Issues",
icon: resolveRouteIcon(DEFAULT_PATH),
router: createTabRouter(DEFAULT_PATH),
historyIndex: 0,
historyLength: 1,
};
});
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: {} });
},
if (!changed) return;
set({ tabs: nextTabs });
},
}),
{
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;
}
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 };
}
const tabs: Tab[] = persisted.tabs.map((tab) => {
// Sanitize persisted paths against reserved-slug rules. Catches
// both pre-refactor paths like "/issues/abc" (missing workspace
// slug) and any other malformed paths that slipped past the
// write-time guard. The defense across makeTab + merge + runtime
// validate ensures stale or malformed paths never reach the
// router.
const path = sanitizeTabPath(tab.path);
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

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

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

@@ -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,40 @@ 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"));
const [desktopToken, setDesktopToken] = useState<string | null>(null);
const [desktopError, setDesktopError] = useState("");
const hasOnboarded = useHasOnboarded();
// the user's workspace list.
const nextUrl = searchParams.get("next");
// Already authenticated — honor ?next= or fall back to first workspace
// (or /onboarding if the user has none). Skip this entire path when
// (or /workspaces/new if the user has none). Skip this entire path when
// the user arrived to authorize the CLI.
useEffect(() => {
if (isLoading || !user || cliCallbackRaw) return;
if (isDesktopHandoff) {
// Desktop opened the browser for login but the web session is already
// authenticated — mint a bearer token from the cookie session and hand
// it off via deep link instead of silently redirecting to the workspace.
api
.issueCliToken()
.then(({ token }) => {
setDesktopToken(token);
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
})
.catch((err) => {
setDesktopError(
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
);
});
return;
}
if (!hasOnboarded) {
router.replace(paths.onboarding());
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
const [first] = list;
router.replace(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
const handleSuccess = () => {
// Read the latest user snapshot directly — the closure's `hasOnboarded`
// was captured before login completed and would be stale here.
const currentUser = useAuthStore.getState().user;
const onboarded = currentUser?.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
if (nextUrl) {
router.push(nextUrl);
return;
}
// The LoginPage view populates the workspace list cache before calling
// onSuccess, so it's safe to read here.
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
router.push(resolvePostAuthDestination(list, onboarded));
const [first] = list;
router.push(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
};
// Build Google OAuth state: encode platform + next URL so the callback
@@ -110,52 +67,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 +85,6 @@ function LoginPageContent() {
: undefined
}
onTokenObtained={setLoggedInCookie}
extra={
// Web-only nudge toward the desktop app. Copy is hardcoded EN
// for now because the login route sits outside the landing
// group's LocaleProvider — if this page ever becomes
// locale-aware, the strings live in positioning doc §3.3.
<span className="text-xs text-muted-foreground">
Prefer the desktop app?{" "}
<Link
href="/download"
onClick={() => captureDownloadIntent("login")}
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
>
Download
</Link>
</span>
}
/>
);
}

View File

@@ -1,72 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import {
paths,
resolvePostAuthDestination,
useHasOnboarded,
} from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboarding";
/**
* Web shell for the onboarding flow. The route is the platform chrome on
* web (matching `WindowOverlay` on desktop); content is the shared
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
*
* On complete: if a workspace was just created, navigate into it;
* otherwise fall back to root (proxy / landing picks the user's first ws
* or bounces to onboarding if still zero).
*
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
* slot so the flow can render it inside the CLI dialog. The commands it
* shows are hardcoded — nothing environmental to thread through.
*/
export default function OnboardingPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user && hasOnboarded,
});
useEffect(() => {
if (isLoading || !user) {
if (!isLoading && !user) router.replace(paths.login());
return;
}
if (hasOnboarded && workspacesFetched) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
if (isLoading || !user || hasOnboarded) return null;
// Layout: page owns its own scroll (root layout sets `body {
// overflow: hidden }` for the app-shell convention). OnboardingFlow
// owns the per-step width constraint internally — Welcome renders a
// wide two-column hero, all other steps wrap themselves at max-w-xl.
return (
<div className="h-full overflow-y-auto bg-background">
<OnboardingFlow
onComplete={(ws) => {
// No more firstIssueId handoff — the welcome issue is created
// inside the workspace via StarterContentPrompt, not during
// onboarding. Always land on the workspace issues list (or
// root if the flow never produced a workspace).
if (ws) {
router.push(paths.workspace(ws.slug).issues());
} else {
router.push(paths.root());
}
}}
runtimeInstructions={<CliInstallInstructions />}
/>
</div>
);
}

View File

@@ -2,20 +2,14 @@
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());
@@ -23,16 +17,9 @@ export default function Page() {
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

@@ -1 +0,0 @@
export { ChatPage as default } from "@multica/views/chat";

View File

@@ -3,19 +3,14 @@
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { ChatFab, ChatWindow } from "@multica/views/chat";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={
<>
<SearchCommand />
<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

@@ -8,7 +8,6 @@ import { workspaceBySlugOptions } from "@multica/core/workspace";
import { setCurrentWorkspace } 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({
@@ -61,17 +60,11 @@ export default function WorkspaceLayout({
// and we just need to hold null briefly.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
const loadingIndicator = (
<div className="flex h-svh items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
if (isAuthLoading) return loadingIndicator;
if (isAuthLoading) return null;
// 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 (!listFetched) return null;
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

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 /workspaces/new for zero-workspace users.
const [first] = wsList;
const defaultDest = first
? paths.workspace(first.slug).issues()
: paths.newWorkspace();
router.push(nextUrl || defaultDest);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

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

@@ -701,9 +701,14 @@ const mockUsageData = USAGE_SEEDS.map((s, i) => ({
/* Heatmap color helper — same as real ActivityHeatmap */
function getHeatmapColor(level: number): string {
if (level === 0) return "var(--color-muted)";
const opacities = ["25%", "45%", "68%", "90%"];
return `color-mix(in oklch, var(--color-foreground) ${opacities[level - 1]}, transparent)`;
const colors = [
"var(--color-muted, hsl(var(--muted)))",
"hsl(var(--chart-3) / 0.3)",
"hsl(var(--chart-3) / 0.5)",
"hsl(var(--chart-3) / 0.75)",
"hsl(var(--chart-3) / 1)",
];
return colors[level] ?? colors[0]!;
}
/* Generate heatmap cells — simplified version of real ActivityHeatmap */
@@ -761,7 +766,7 @@ function DailyCostBars({ data }: { data: typeof mockUsageData }) {
width={8}
height={Math.max(h, 2)}
rx={1}
fill="var(--color-chart-1)"
fill="hsl(var(--chart-1))"
/>
);
})}

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,46 @@ export function LandingHero() {
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href="/download"
href="https://github.com/multica-ai/multica/releases/latest"
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
onClick={() => captureDownloadIntent("landing_hero")}
>
<Download className="size-4" aria-hidden />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
{t.hero.downloadDesktop}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<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 +117,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 +182,7 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -113,7 +198,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 /workspaces/new if they have none).
*
* Renders nothing. Uses `router.replace` so the landing page never enters
* browser history for authenticated users.
@@ -25,17 +25,21 @@ export function RedirectIfAuthenticated() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hasOnboarded = useHasOnboarded();
const { data: list = [], isFetched } = useQuery({
const { data: list } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (isLoading || !user || !isFetched) return;
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, isFetched, list, hasOnboarded, router]);
if (isLoading || !user || !list) return;
const [first] = list;
if (!first) {
router.replace(paths.newWorkspace());
return;
}
router.replace(paths.workspace(first.slug).issues());
}, [isLoading, user, list, router]);
return null;
}

View File

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

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