Compare commits
1 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557ac45e93 |
85
.env.example
@@ -4,28 +4,9 @@ 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 production safety checks. Docker self-host pins APP_ENV to
|
||||
# "production" by default. Local dev can leave it unset.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
# Optional local/testing shortcut. Empty by default, so there is no fixed
|
||||
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
|
||||
# If you need deterministic local automation, set a 6-digit value such as
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
# HTTP request metrics start accumulating only when this listener is enabled.
|
||||
# METRICS_ADDR=127.0.0.1:9090
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
@@ -40,45 +21,25 @@ 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 — generated codes print to stdout.
|
||||
# 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 — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
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)
|
||||
@@ -91,23 +52,6 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
# callers with no forwarding headers and returns 404 to everything else —
|
||||
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
|
||||
# terminating TLS in front of localhost:8080) MUST set this token, since
|
||||
# proxied requests look like loopback at the Go layer; with no token, those
|
||||
# requests are refused with 404. Pass the token as
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → Integrations "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
@@ -119,28 +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
|
||||
# Optional override for the `environment` PostHog event property.
|
||||
# Defaults from APP_ENV and normalizes to production / staging / dev.
|
||||
ANALYTICS_ENVIRONMENT=
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,8 +40,6 @@ Closes #
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
|
||||
27
.github/workflows/ci.yml
vendored
@@ -29,17 +29,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Verify reserved-slugs.ts is up to date
|
||||
# Re-runs the generator and fails on any drift from the
|
||||
# checked-in TypeScript output. The Go side embeds the JSON
|
||||
# source directly, so a passing diff here proves both sides
|
||||
# share one source of truth.
|
||||
run: |
|
||||
pnpm generate:reserved-slugs
|
||||
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
|
||||
- name: Build, type check, and test
|
||||
run: pnpm build && pnpm typecheck && pnpm test
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,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
|
||||
|
||||
59
.github/workflows/desktop-smoke.yml
vendored
@@ -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
|
||||
348
.github/workflows/release.yml
vendored
@@ -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,27 +26,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
# Only run on the canonical upstream repo. Forks don't have the
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
|
||||
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
|
||||
# tag push fails this job (401 against the upstream tap), which makes
|
||||
# downstream CI go red without affecting the actual artifact pipeline.
|
||||
if: github.repository_owner == 'multica-ai'
|
||||
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:
|
||||
@@ -83,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
@@ -57,4 +57,3 @@ _features/
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
85
CLAUDE.md
@@ -2,21 +2,6 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Conventions reference
|
||||
|
||||
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
|
||||
|
||||
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
|
||||
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
|
||||
|
||||
Read that page before:
|
||||
|
||||
- Writing or editing translations (`packages/views/locales/`)
|
||||
- Naming a new route, package, file, DB column, or TS type
|
||||
- Writing Chinese product copy (UI strings, error messages, docs)
|
||||
|
||||
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
|
||||
|
||||
## Project Context
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
@@ -121,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
|
||||
@@ -146,38 +130,10 @@ make start-worktree # Start using .env.worktree
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
|
||||
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
|
||||
|
||||
### API Response Compatibility
|
||||
|
||||
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
|
||||
|
||||
When writing code that consumes an API response, follow these rules:
|
||||
|
||||
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
|
||||
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
|
||||
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
|
||||
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
|
||||
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
|
||||
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
|
||||
|
||||
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
@@ -206,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.
|
||||
@@ -220,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.
|
||||
|
||||
@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
|
||||
### Token Login
|
||||
|
||||
```bash
|
||||
multica login --token <mul_...>
|
||||
multica login --token
|
||||
```
|
||||
|
||||
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
|
||||
Authenticate by pasting a personal access token directly. Useful for headless environments.
|
||||
|
||||
### Check Status
|
||||
|
||||
@@ -140,15 +140,12 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| [GitHub Copilot CLI](https://docs.github.com/en/copilot) | `copilot` | GitHub's coding agent (model routed by your GitHub entitlement) |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Gemini | `gemini` | Google's coding agent |
|
||||
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -169,28 +166,11 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
|
||||
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
|
||||
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
|
||||
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
|
||||
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
|
||||
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
|
||||
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
|
||||
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
|
||||
|
||||
#### Workspace garbage collection
|
||||
|
||||
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
|
||||
|
||||
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
|
||||
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
|
||||
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
|
||||
|
||||
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
@@ -198,12 +178,8 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
|
||||
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -216,12 +192,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
@@ -305,12 +275,10 @@ multica workspace members <workspace-id>
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -323,10 +291,9 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -338,12 +305,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
@@ -368,109 +332,22 @@ 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
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --full-id
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <short-task-id> --issue <issue-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
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. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It 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>
|
||||
```
|
||||
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.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -508,66 +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 --full-id
|
||||
multica autopilot list --status active --output json
|
||||
```
|
||||
|
||||
Autopilot table IDs are short UUID prefixes; follow-up autopilot commands accept copied prefixes when they are unique in the current workspace. Use `--full-id` to print canonical UUIDs.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
@@ -140,7 +139,7 @@ multica auth status
|
||||
Expected output should show the authenticated user and server URL.
|
||||
|
||||
**If login fails:**
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
|
||||
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
|
||||
|
||||
---
|
||||
@@ -166,12 +165,12 @@ Wait 3 seconds, then verify:
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -185,12 +184,12 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -373,8 +373,7 @@ done
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
|
||||
in your env file before starting the backend:
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
@@ -477,9 +476,7 @@ This automatically:
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and the generated code from the
|
||||
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
|
||||
the backend, you can use `888888` instead.
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
@@ -595,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
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
166
Makefile
@@ -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 read the generated code from backend logs when Resend is unset."; \
|
||||
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 read the generated code from backend logs when Resend is unset."; \
|
||||
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
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
|
||||
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
|
||||
|
||||
59
README.md
@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
@@ -30,32 +30,17 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Why "Multica"?
|
||||
|
||||
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
|
||||
|
||||
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
|
||||
|
||||
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
|
||||
|
||||
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
|
||||
|
||||
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
@@ -100,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -113,7 +97,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -123,7 +107,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -131,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
@@ -160,9 +159,9 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
|
||||
OpenCode, OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent, Kimi, Kiro CLI)
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -170,7 +169,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
@@ -30,32 +30,17 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
|
||||
面向更大的团队,Squads(小队)提供稳定的路由层:把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 为什么叫 "Multica"?
|
||||
|
||||
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
|
||||
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统,让多个用户能够共享同一台机器,同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
|
||||
|
||||
我们认为,类似的转折点正在再次出现。几十年来,软件团队一直处于一种单线程的工作模式,一个工程师处理一个任务,一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
|
||||
|
||||
在 Multica 中,agents 是一级团队成员。它们会被分配 issue,汇报进展,提出阻塞,并交付代码,就像人类同事一样。任务分配、活动时间线、任务生命周期,以及运行时基础设施,Multica 从第一天起就是围绕这一理念构建的。
|
||||
|
||||
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents,就能发挥出二十人团队的推进速度。
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **Squads(小队)** — 把多个 Agent(以及人类成员)组合成由 leader agent 带队的小队,直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
@@ -114,7 +99,7 @@ multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`copilot`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`、`kimi`、`kiro-cli`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -124,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -134,6 +119,19 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -144,9 +142,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ 运行在你的机器上
|
||||
└──────────────┘ (Claude Code、Codex、GitHub Copilot CLI、
|
||||
OpenCode、OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent、Kimi、Kiro CLI)
|
||||
└──────────────┘ (Claude Code、Codex、OpenCode、
|
||||
OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent)
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
@@ -154,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -174,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>
|
||||
|
||||
@@ -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 leave Resend unset and copy the generated code from the backend logs. 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`), and there is no fixed verification code by default. 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).
|
||||
- **Without email configured:** 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.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
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 `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
> 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
|
||||
|
||||
@@ -92,15 +82,12 @@ brew install multica-ai/tap/multica
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [GitHub Copilot CLI](https://docs.github.com/en/copilot) (`copilot` on PATH)
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
- Kimi (`kimi` on PATH)
|
||||
- Kiro CLI (`kiro-cli` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
@@ -165,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -196,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
|
||||
```
|
||||
|
||||
|
||||
@@ -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:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -42,46 +33,24 @@ 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 (optionally) CloudFront:
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `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
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
@@ -105,8 +74,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
|
||||
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -186,47 +153,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
@@ -282,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
|
||||
@@ -298,80 +234,32 @@ 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
|
||||
|
||||
The backend exposes public health endpoints:
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```text
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The backend can expose Prometheus metrics on a separate management listener:
|
||||
|
||||
```bash
|
||||
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
|
||||
public API port does not serve `/metrics`; keep it that way for internet-facing
|
||||
deployments. HTTP request metrics start accumulating only after the metrics
|
||||
listener is enabled. Metrics can reveal internal routes, traffic volume,
|
||||
dependency state, and runtime health.
|
||||
|
||||
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
|
||||
metrics listener to an internal interface and protect it with private
|
||||
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
|
||||
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
|
||||
trusted network, for example a host-local mapping such as
|
||||
`127.0.0.1:9090:9090`.
|
||||
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.
|
||||
|
||||
@@ -37,7 +37,7 @@ multica setup self-host
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
|
||||
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 491 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 782 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -21,81 +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:
|
||||
# Override the Linux executable name to avoid leaking the scoped npm
|
||||
# package name (`@multica/desktop`) into the installed binary, the
|
||||
# `.desktop` file, and the hicolor icon filename. Without this override
|
||||
# electron-builder defaults `executableName` to the package `name`,
|
||||
# which after slash-stripping becomes `@multicadesktop` — producing
|
||||
# `/usr/share/applications/@multicadesktop.desktop`,
|
||||
# `Icon=@multicadesktop`, and
|
||||
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
|
||||
# violates the freedesktop desktop-entry naming guidance, so GNOME /
|
||||
# Ubuntu fail to associate the running window with the `.desktop` entry
|
||||
# and fall back to the theme's default app icon (the Settings gear on
|
||||
# Yaru). Forcing `multica` makes every Linux identity slot agree and
|
||||
# matches `StartupWMClass=Multica` (productName-derived).
|
||||
executableName: multica
|
||||
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
|
||||
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
|
||||
# ASAR's `package.json` — `productName` if present, otherwise `name`.
|
||||
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
|
||||
# directly; it does not. With our source package.json carrying only
|
||||
# `name: "@multica/desktop"`, packaged Electron emitted
|
||||
# `WM_CLASS=@multica/desktop`, which broke association with this entry
|
||||
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
|
||||
# outside this file — `productName: "Multica"` on the source
|
||||
# package.json (so the ASAR carries it) and `app.setName("Multica")`
|
||||
# in the production branch of `src/main/index.ts` (belt-and-braces).
|
||||
# Keep `StartupWMClass: Multica` pinned here so any future drift in
|
||||
# those two anchors shows up as a diff against this declaration.
|
||||
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
|
||||
# window prints `Multica` for both fields.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
|
||||
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
|
||||
# auto-generation silently shipped only the 1024×1024 source in our
|
||||
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
|
||||
# with no usable size and falling back to the theme default. Shipping
|
||||
# the sizes from source removes the toolchain dependency entirely.
|
||||
icon: build/icons
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
rpm:
|
||||
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
|
||||
# binary, whose GNU build-id is identical across every app shipping the same
|
||||
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
|
||||
# would own /usr/lib/.build-id/<hash> paths and collide with any other
|
||||
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
|
||||
fpm:
|
||||
- "--rpm-rpmbuild-define=_build_id_links none"
|
||||
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
|
||||
|
||||
@@ -10,35 +10,4 @@ export default [
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
// Security: every renderer-controlled URL that reaches the OS shell or the
|
||||
// native download system must flow through the safe wrappers in
|
||||
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
|
||||
// direct shell.openExternal / webContents.downloadURL calls 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.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
|
||||
message:
|
||||
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/main/external-url.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"productName": "Multica",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
"homepage": "https://multica.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/multica-ai/multica.git",
|
||||
"directory": "apps/desktop"
|
||||
},
|
||||
"author": {
|
||||
"name": "Multica",
|
||||
"email": "support@multica.ai"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "node scripts/package.mjs",
|
||||
"package:all": "node scripts/package.mjs --all-platforms --publish never",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
@@ -40,7 +25,6 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -914,20 +874,6 @@ export function setupDaemonManager(
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { shell, type BrowserWindow } 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);
|
||||
}
|
||||
|
||||
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
|
||||
// URLs that trigger a native download MUST flow through here; direct calls
|
||||
// to `webContents.downloadURL` elsewhere in the main process are banned by
|
||||
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
|
||||
// Reuses the same http/https allowlist as openExternalSafely.
|
||||
export function downloadURLSafely(win: BrowserWindow, url: string): void {
|
||||
if (getHttpProtocol(url) === null) {
|
||||
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
|
||||
return;
|
||||
}
|
||||
win.webContents.downloadURL(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";
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,15 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage } 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, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
|
||||
// direct-launch doesn't register the .desktop entry, so GNOME has no path
|
||||
// from the running window to the hicolor icon and falls back to the
|
||||
// theme default. Consumed in createWindow() (all platforms in dev, Linux
|
||||
// in prod) and the macOS dev dock branch.
|
||||
//
|
||||
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
|
||||
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
|
||||
// Linux native window-icon code path expects a real filesystem path
|
||||
// (unlike Electron's nativeImage loader which transparently reads from
|
||||
// asar), so swap the segment — same pattern as bundledCliPath() in
|
||||
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
|
||||
// is a no-op.
|
||||
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
// 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.).
|
||||
@@ -54,10 +34,6 @@ if (process.platform !== "win32") {
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
};
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
@@ -72,19 +48,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
|
||||
@@ -93,25 +56,7 @@ function handleDeepLink(url: string): void {
|
||||
|
||||
// --- Window creation -----------------------------------------------------
|
||||
|
||||
// Tracks the OS-preferred language as last seen by the running process.
|
||||
// Updated on each window-focus check so we can emit a `locale:system-changed`
|
||||
// event to the renderer when the user changes their OS language without
|
||||
// quitting the app — without restart, app.getPreferredSystemLanguages()
|
||||
// would still report the boot value forever.
|
||||
let lastKnownSystemLocale = "en";
|
||||
|
||||
function getSystemLocale(): string {
|
||||
return app.getPreferredSystemLanguages()[0] ?? "en";
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Pass the OS-preferred language to the renderer via additionalArguments
|
||||
// instead of a sync IPC call. process.argv is available to the preload
|
||||
// script before the first network request, so the renderer's i18next
|
||||
// instance can initialize with the right locale on the very first paint.
|
||||
const systemLocale = getSystemLocale();
|
||||
lastKnownSystemLocale = systemLocale;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
@@ -121,40 +66,13 @@ function createWindow(): void {
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option.
|
||||
// On macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
// Linux production needs this explicitly because AppImage direct-launch
|
||||
// does not install a .desktop entry, so the WM has no other path to
|
||||
// the bundled icon; without it Ubuntu falls back to the theme default.
|
||||
...(is.dev || process.platform === "linux"
|
||||
? { icon: BUNDLED_ICON_PATH }
|
||||
: {}),
|
||||
// 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,
|
||||
webSecurity: false,
|
||||
// Required for the Chromium PDF viewer (PDFium) to activate inside
|
||||
// iframes — used by the attachment preview modal for application/pdf
|
||||
// files. Default is false in Electron; without it <iframe src=*.pdf>
|
||||
// renders blank.
|
||||
//
|
||||
// Security trade-off, accepted intentionally:
|
||||
// 1. This window already runs with `webSecurity: false` + `sandbox: false`,
|
||||
// so `plugins: true` does NOT meaningfully widen the renderer's
|
||||
// attack surface beyond what is already accepted.
|
||||
// 2. The only PDFs that reach an iframe here are signed CloudFront URLs
|
||||
// we ourselves issued (see useDownloadAttachment); user-supplied URLs
|
||||
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
|
||||
// cannot land in this renderer.
|
||||
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
|
||||
// and only handles the `application/pdf` MIME — it does not expose
|
||||
// Flash, Java, or other historical plugin surfaces.
|
||||
//
|
||||
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
|
||||
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
|
||||
// to that view, keeping the main renderer plugin-free.
|
||||
plugins: true,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -172,41 +90,11 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
// dedicated event for this on any platform, so we poll on focus regain —
|
||||
// catches the common case where users switch System Settings → Language
|
||||
// and bring the app back. The renderer decides whether to act (it ignores
|
||||
// the signal when the user has an explicit Settings choice).
|
||||
mainWindow.on("focus", () => {
|
||||
const current = getSystemLocale();
|
||||
if (current === lastKnownSystemLocale) return;
|
||||
lastKnownSystemLocale = current;
|
||||
mainWindow?.webContents.send("locale:system-changed", current);
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
@@ -233,14 +121,6 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
if (is.dev) {
|
||||
app.setName(DEV_APP_NAME);
|
||||
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
|
||||
} else {
|
||||
// Pin the production app name in code. Electron's Linux WM_CLASS is set
|
||||
// from app.getName() when the first BrowserWindow is realized; the
|
||||
// packaged ASAR's package.json `productName` already steers app.getName()
|
||||
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
|
||||
// (declared in electron-builder.yml) survive a regression in
|
||||
// productName / the build pipeline. Must run before requestSingleInstanceLock().
|
||||
app.setName("Multica");
|
||||
}
|
||||
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
@@ -273,25 +153,7 @@ if (!gotTheLock) {
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const viteEnv = import.meta.env as ImportMetaEnv & {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
};
|
||||
|
||||
runtimeConfigResult = await loadRuntimeConfig({
|
||||
isDev: is.dev,
|
||||
// electron-vite exposes VITE_* on import.meta.env for the main process;
|
||||
// keep dev URL overrides on the same source the renderer used before
|
||||
// runtime config moved endpoint resolution into main/preload.
|
||||
env: {
|
||||
apiUrl: viteEnv.VITE_API_URL,
|
||||
wsUrl: viteEnv.VITE_WS_URL,
|
||||
appUrl: viteEnv.VITE_APP_URL,
|
||||
},
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
@@ -300,7 +162,7 @@ if (!gotTheLock) {
|
||||
// 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(BUNDLED_ICON_PATH);
|
||||
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
|
||||
if (!icon.isEmpty()) app.dock.setIcon(icon);
|
||||
}
|
||||
|
||||
@@ -308,38 +170,9 @@ if (!gotTheLock) {
|
||||
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);
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
return;
|
||||
}
|
||||
downloadURLSafely(mainWindow, 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: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
ipcMain.on("runtime-config:get", (event) => {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
return shell.openExternal(url);
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
@@ -350,64 +183,6 @@ if (!gotTheLock) {
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
// IPC: show a native OS notification for a new inbox item. The renderer
|
||||
// only fires this when the app is unfocused (it gates on
|
||||
// `document.hasFocus()`), so we don't fight macOS foreground suppression
|
||||
// here. Clicking the banner focuses the main window and routes to the
|
||||
// inbox item via a renderer-side listener.
|
||||
ipcMain.on(
|
||||
"notification:show",
|
||||
(
|
||||
_event,
|
||||
{
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
},
|
||||
) => {
|
||||
if (!Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body });
|
||||
notification.on("click", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
// Ship the full context back — the renderer pins the route to the
|
||||
// source workspace (slug), marks the row read (itemId), and uses
|
||||
// issueKey as the ?issue=<…> selector.
|
||||
mainWindow.webContents.send("inbox:open", {
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
});
|
||||
});
|
||||
notification.show();
|
||||
},
|
||||
);
|
||||
|
||||
// IPC: update the dock / taskbar unread badge. Values above 99 render as
|
||||
// "99+". macOS is the primary target (user-visible dock badge); Linux
|
||||
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
|
||||
// needs a pre-rendered PNG and is deferred — the OS notification + the
|
||||
// in-app inbox sidebar cover the core UX there for now.
|
||||
ipcMain.on("badge:set", (_event, rawCount: number) => {
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
if (process.platform === "darwin") {
|
||||
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
|
||||
app.dock?.setBadge(label);
|
||||
} else {
|
||||
app.setBadgeCount(count);
|
||||
}
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mkdtemp, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
|
||||
describe("loadRuntimeConfig", () => {
|
||||
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: true,
|
||||
configPath,
|
||||
env: {
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cloud defaults when packaged config is absent", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: false,
|
||||
configPath: join(dir, "missing.json"),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a valid packaged desktop.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when packaged desktop.json is invalid", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(configPath, "{");
|
||||
|
||||
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain(configPath);
|
||||
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
type RuntimeConfig,
|
||||
type RuntimeConfigEnv,
|
||||
type RuntimeConfigResult,
|
||||
} from "../shared/runtime-config";
|
||||
|
||||
export async function loadRuntimeConfig(options: {
|
||||
isDev: boolean;
|
||||
env: RuntimeConfigEnv;
|
||||
configPath?: string;
|
||||
}): Promise<RuntimeConfigResult> {
|
||||
if (options.isDev) {
|
||||
try {
|
||||
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: { message: errorMessage(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = options.configPath ?? desktopConfigPath();
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
return { ok: true, config: parseRuntimeConfig(raw) };
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Invalid ${configPath}: ${errorMessage(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopConfigPath(): string {
|
||||
return join(app.getPath("home"), ".multica", "desktop.json");
|
||||
}
|
||||
|
||||
function isMissingFileError(err: unknown): boolean {
|
||||
return Boolean(
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export type { RuntimeConfig, RuntimeConfigResult };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
40
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,47 +1,12 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
appInfo: {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
|
||||
systemLocale: string;
|
||||
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** 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>;
|
||||
/** Download a file by URL through Electron's native download system.
|
||||
* Shows a native save dialog. On non-desktop platforms this is undefined. */
|
||||
downloadURL: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
/** Show a native OS notification for a new inbox item. */
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => void;
|
||||
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
|
||||
setUnreadBadge: (count: number) => void;
|
||||
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -78,7 +43,6 @@ interface DaemonAPI {
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
openLogFile: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
@@ -87,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 {
|
||||
|
||||
@@ -1,74 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
function fetchRuntimeConfig(): RuntimeConfigResult {
|
||||
try {
|
||||
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
|
||||
if (result && typeof result === "object" && "ok" in result) return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: "Runtime config unavailable" } };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
const runtimeConfig = fetchRuntimeConfig();
|
||||
|
||||
// Read the OS-preferred locale that main injected via additionalArguments.
|
||||
// Zero IPC, zero blocking — process.argv is populated before preload runs.
|
||||
function fetchSystemLocale(): string {
|
||||
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
|
||||
return arg?.split("=")[1] ?? "en";
|
||||
}
|
||||
|
||||
const systemLocale = fetchSystemLocale();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
|
||||
* Used by the renderer's LocaleAdapter as the system-preference signal. */
|
||||
systemLocale,
|
||||
/** Subscribe to OS language changes detected after boot. The renderer
|
||||
* decides whether to act (no-op when the user has an explicit Settings
|
||||
* choice). Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
|
||||
callback(locale);
|
||||
ipcRenderer.on("locale:system-changed", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("locale:system-changed", handler);
|
||||
};
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -78,69 +11,11 @@ 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),
|
||||
/** Download a file by URL through Electron's native download system.
|
||||
* Shows a save dialog and saves to disk. Unlike openExternal, this
|
||||
* avoids browser rendering of HTML files on Linux.
|
||||
* On non-desktop platforms this property is undefined. */
|
||||
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
/**
|
||||
* Show a native OS notification for a new inbox item. Fired from the
|
||||
* renderer only when the app is unfocused — in-focus feedback is the
|
||||
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
|
||||
* all round-tripped on click: slug pins routing to the source workspace
|
||||
* (the user may switch workspaces before clicking the banner), itemId
|
||||
* lets the renderer mark the row read, issueKey maps to the inbox URL
|
||||
* param.
|
||||
*/
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => ipcRenderer.send("notification:show", payload),
|
||||
/**
|
||||
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
|
||||
* above 99 render as "99+" (capping is handled in the main process).
|
||||
*/
|
||||
setUnreadBadge: (count: number) =>
|
||||
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
|
||||
/**
|
||||
* Subscribe to "open this inbox row" requests sent by the main process
|
||||
* when the user clicks an OS notification banner. Returns an unsubscribe
|
||||
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
|
||||
* were passed to `showNotification`.
|
||||
*/
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { slug: string; itemId: string; issueKey: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("inbox:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -192,8 +67,6 @@ const daemonAPI = {
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:open-log-file"),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
@@ -214,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) {
|
||||
|
||||
@@ -1,24 +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 { pickLocale } from "@multica/core/i18n";
|
||||
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 "@multica/ui/components/ui/sonner";
|
||||
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";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -33,26 +25,10 @@ function AppContent() {
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
|
||||
? window.desktopAPI.runtimeConfig.config
|
||||
: null;
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) return;
|
||||
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
|
||||
}, [runtimeConfig]);
|
||||
|
||||
// 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 });
|
||||
});
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
|
||||
@@ -101,100 +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;
|
||||
|
||||
// Bridge local daemon IPC status into the runtimes cache so this user's
|
||||
// own daemon flips to offline/online sub-second instead of waiting on the
|
||||
// server's 75s sweeper. Resolves wsId from the active tab so workspace
|
||||
// switches automatically rebind the subscription.
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const activeWsId = activeWorkspaceSlug
|
||||
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
// 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.
|
||||
//
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
// missed invites later once they're onboarded.
|
||||
let cancelled = false;
|
||||
void api
|
||||
.listMyInvitations()
|
||||
.then((invites) => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
latestOpen({ type: "invitations" });
|
||||
} else {
|
||||
latestOpen({ type: "onboarding" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
latestOpen({ type: "onboarding" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
open({ type: "new-workspace" });
|
||||
return undefined;
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
// (synchronously after render, before paint) rather than the render
|
||||
// phase — the original render-phase pattern triggered React's
|
||||
// "Cannot update a component while rendering a different component"
|
||||
// warning because `switchWorkspace` is a Zustand setState that the
|
||||
// TabBar is subscribed to. useLayoutEffect flushes both renders before
|
||||
// the user sees anything, so there's no visible flicker.
|
||||
//
|
||||
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
|
||||
// the first fetch, so without this guard we'd run validation against an
|
||||
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
|
||||
// back to `workspaces[0]` once the real list arrives — losing the user's
|
||||
// last-opened workspace on every app start.
|
||||
useLayoutEffect(() => {
|
||||
if (!workspaceListFetched) return;
|
||||
// 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
|
||||
@@ -223,41 +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 />;
|
||||
}
|
||||
|
||||
function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
|
||||
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
|
||||
</p>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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 {
|
||||
@@ -271,62 +151,15 @@ async function handleDaemonLogout() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
// 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],
|
||||
);
|
||||
// Locale resolution happens once at app boot. Switching language goes
|
||||
// through window.location.reload() to avoid hydration mismatch.
|
||||
const localeAdapter = useMemo(
|
||||
() => createDesktopLocaleAdapter(systemLocale),
|
||||
[systemLocale],
|
||||
);
|
||||
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
|
||||
const resources = useMemo(
|
||||
() => ({ [locale]: RESOURCES[locale] }),
|
||||
[locale],
|
||||
);
|
||||
|
||||
// React to OS-level language changes detected by main on focus regain.
|
||||
// Only act when the user is following the system signal (no explicit
|
||||
// Settings choice) — otherwise their preference wins. Cross-device sync
|
||||
// for the explicit-choice case is handled inside CoreProvider.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
|
||||
if (localeAdapter.getUserChoice()) return;
|
||||
const next = pickLocale({
|
||||
...localeAdapter,
|
||||
getSystemPreferences: () =>
|
||||
nextSystemLocale ? [nextSystemLocale] : [],
|
||||
});
|
||||
if (next === locale) return;
|
||||
localeAdapter.persist(next);
|
||||
window.location.reload();
|
||||
});
|
||||
}, [localeAdapter, locale]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{runtimeConfigResult.ok ? (
|
||||
<CoreProvider
|
||||
apiBaseUrl={runtimeConfigResult.config.apiUrl}
|
||||
wsUrl={runtimeConfigResult.config.wsUrl}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
localeAdapter={localeAdapter}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
) : (
|
||||
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
|
||||
)}
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,261 +1,150 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Copy as CopyIcon,
|
||||
Search,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
/** Number of runtimes this local daemon has registered (for the context badge). */
|
||||
runtimeCount: number;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
let logIdCounter = 0;
|
||||
|
||||
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
|
||||
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
|
||||
INFO: "border-foreground/15 text-foreground/80",
|
||||
WARN: "border-warning/40 text-warning",
|
||||
ERROR: "border-destructive/40 text-destructive",
|
||||
};
|
||||
|
||||
// What gets rendered in the viewport — a single line or a folded group of
|
||||
// consecutive lines that share the same `message`. The group form is what
|
||||
// turns a wall of `DBG poll: no tasks` into a single placeholder.
|
||||
type DisplayItem =
|
||||
| { kind: "line"; line: ParsedLogLine }
|
||||
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
|
||||
|
||||
export function DaemonPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
runtimeCount,
|
||||
}: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
// Each level chip is an independent toggle. DEBUG is off by default so
|
||||
// poll-loop noise doesn't drown out real events when the panel opens —
|
||||
// users opt in if they want to see it.
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
|
||||
);
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
|
||||
const idCounterRef = useRef(0);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// --- Log stream subscription ---
|
||||
// Active only while the modal is open. On open we replay the file's tail
|
||||
// (~200 lines) so users have context for "what just happened"; on close
|
||||
// we tear down the watcher so the main process isn't doing work for a
|
||||
// hidden UI.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
idCounterRef.current = 0;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const id = ++idCounterRef.current;
|
||||
const parsed = parseLogLine(line, id);
|
||||
const next =
|
||||
prev.length >= MAX_LOG_LINES
|
||||
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
|
||||
: [...prev, parsed];
|
||||
return next;
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// --- Derived: counts per level (for filter chip badges) ---
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
};
|
||||
for (const l of logs) {
|
||||
if (l.level) counts[l.level] += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
// --- Derived: filtered list (level toggle + search) ---
|
||||
// Lines that didn't parse (level = null) always pass — they're typically
|
||||
// panic stack traces / partial writes; never silently drop them.
|
||||
const filtered = useMemo(() => {
|
||||
let result = logs;
|
||||
result = result.filter((l) => {
|
||||
if (!l.level) return true;
|
||||
return enabledLevels.has(l.level);
|
||||
});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((l) => l.raw.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [logs, enabledLevels, search]);
|
||||
|
||||
// --- Derived: collapse runs of consecutive lines that share the same
|
||||
// message into a single group placeholder. The most common case is the
|
||||
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
|
||||
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
|
||||
// doesn't strand groups.
|
||||
const displayed = useMemo<DisplayItem[]>(() => {
|
||||
const out: DisplayItem[] = [];
|
||||
for (const line of filtered) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last) {
|
||||
out.push({ kind: "line", line });
|
||||
continue;
|
||||
}
|
||||
const lastMessage =
|
||||
last.kind === "line" ? last.line.message : last.first.message;
|
||||
if (lastMessage && lastMessage === line.message) {
|
||||
if (last.kind === "line") {
|
||||
out[out.length - 1] = {
|
||||
kind: "group",
|
||||
first: last.line,
|
||||
rest: [line],
|
||||
};
|
||||
} else {
|
||||
last.rest.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: "line", line });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
|
||||
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [displayed, autoScroll]);
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const handleLogScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
|
||||
// here. Re-enabling lives in the "Jump to latest" footer button so a
|
||||
// burst of lines doesn't yank a reading user back to the bottom.
|
||||
if (!atBottom && autoScroll) setAutoScroll(false);
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setAutoScroll(true);
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
setAutoScroll(atBottom);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
const toggleLevel = useCallback((lv: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lv)) next.delete(lv);
|
||||
else next.add(lv);
|
||||
return next;
|
||||
});
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleFields = useCallback((id: number) => {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((id: number) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Server className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Local daemon logs
|
||||
</DialogTitle>
|
||||
<ContextBadge status={status} runtimeCount={runtimeCount} />
|
||||
</div>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -264,412 +153,157 @@ export function DaemonPanel({
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level toggle chips. Each chip is independent — click to
|
||||
show/hide that level. DEBUG starts hidden because the
|
||||
poll-loop heartbeat dominates otherwise. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((lv) => (
|
||||
<FilterChip
|
||||
key={lv}
|
||||
active={enabledLevels.has(lv)}
|
||||
onClick={() => toggleLevel(lv)}
|
||||
label={lv}
|
||||
count={levelCounts[lv]}
|
||||
variant={lv}
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
<CopyIcon className="size-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs viewport */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{displayed.length === 0 ? (
|
||||
<EmptyState
|
||||
hasLogs={logs.length > 0}
|
||||
hasFilter={hasActiveFilter}
|
||||
isRunning={status.state === "running"}
|
||||
/>
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{displayed.map((item) =>
|
||||
item.kind === "line" ? (
|
||||
<LogLineRow
|
||||
key={item.line.id}
|
||||
line={item.line}
|
||||
expanded={expandedFields.has(item.line.id)}
|
||||
onToggle={() => toggleFields(item.line.id)}
|
||||
search={search}
|
||||
/>
|
||||
) : (
|
||||
<GroupRows
|
||||
key={item.first.id}
|
||||
first={item.first}
|
||||
rest={item.rest}
|
||||
expanded={expandedGroups.has(item.first.id)}
|
||||
onToggle={() => toggleGroup(item.first.id)}
|
||||
expandedFields={expandedFields}
|
||||
onToggleFields={toggleFields}
|
||||
search={search}
|
||||
/>
|
||||
),
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar — count only. The "is the user following" state is
|
||||
communicated implicitly by the presence of the Jump-to-latest
|
||||
button below; an explicit "Paused" word read as "log stream is
|
||||
paused" (it isn't — data keeps flowing into the buffer). */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
Showing {filtered.length} of {logs.length}
|
||||
{logs.length === MAX_LOG_LINES && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
(buffer full)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function ContextBadge({
|
||||
status,
|
||||
runtimeCount,
|
||||
}: {
|
||||
status: DaemonStatus;
|
||||
runtimeCount: number;
|
||||
}) {
|
||||
const isRunning = status.state === "running";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && runtimeCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
variant?: LogLevel;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
|
||||
active
|
||||
? variant
|
||||
? LEVEL_BADGE_CLASS[variant]
|
||||
: "bg-accent text-accent-foreground"
|
||||
: "border-dashed text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
active ? "text-current/80" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
|
||||
LEVEL_BADGE_CLASS[level],
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLineRow({
|
||||
line,
|
||||
expanded,
|
||||
onToggle,
|
||||
search,
|
||||
}: {
|
||||
line: ParsedLogLine;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
search: string;
|
||||
}) {
|
||||
const fieldEntries = Object.entries(line.fields);
|
||||
const hasFields = fieldEntries.length > 0;
|
||||
|
||||
// Unparseable line — render the raw text so nothing is hidden. Common
|
||||
// for panic stack traces and partial writes during log rotation.
|
||||
if (!line.timestamp || !line.level) {
|
||||
return (
|
||||
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
|
||||
{highlight(line.raw, search)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
|
||||
hasFields && "cursor-pointer",
|
||||
)}
|
||||
onClick={hasFields ? onToggle : undefined}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<LevelBadge level={line.level} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="break-words">{highlight(line.message, search)}</span>
|
||||
{hasFields && !expanded && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60">
|
||||
{fieldEntries
|
||||
.map(([k, v]) => `${k}=${truncateValue(v)}`)
|
||||
.join(" ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && hasFields && (
|
||||
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{fieldEntries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-muted-foreground/70">{k}</span>
|
||||
<span className="break-all text-foreground/85">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
first,
|
||||
rest,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedFields,
|
||||
onToggleFields,
|
||||
search,
|
||||
}: {
|
||||
first: ParsedLogLine;
|
||||
rest: ParsedLogLine[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedFields: Set<number>;
|
||||
onToggleFields: (id: number) => void;
|
||||
search: string;
|
||||
}) {
|
||||
// Folded: show the first occurrence so the user still sees a sample
|
||||
// (timestamp, level, message), then a click-to-expand placeholder for
|
||||
// the suppressed run. The placeholder uses a dashed border + italics
|
||||
// so the eye reads it as "not a real line".
|
||||
if (!expanded) {
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>
|
||||
{rest.length} more “{truncateValue(first.message, 48)}
|
||||
” — click to expand
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unfolded: render every line, then a small "collapse" affordance at
|
||||
// the end so the user can put the toothpaste back in the tube.
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
{rest.map((l) => (
|
||||
<LogLineRow
|
||||
key={l.id}
|
||||
line={l}
|
||||
expanded={expandedFields.has(l.id)}
|
||||
onToggle={() => onToggleFields(l.id)}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>collapse {rest.length + 1} repeated</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasLogs,
|
||||
hasFilter,
|
||||
isRunning,
|
||||
}: {
|
||||
hasLogs: boolean;
|
||||
hasFilter: boolean;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
if (hasFilter) {
|
||||
title = "No matching log lines";
|
||||
subtitle = "Try a different search or level toggle.";
|
||||
} else if (!isRunning) {
|
||||
title = "Daemon isn't running";
|
||||
subtitle = "Start the daemon to see logs here.";
|
||||
} else if (!hasLogs) {
|
||||
title = "Waiting for logs…";
|
||||
subtitle = "New entries will appear in real time.";
|
||||
} else {
|
||||
title = "";
|
||||
subtitle = "";
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
|
||||
<p className="text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function truncateValue(value: string, max = 32): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const q = query.toLowerCase();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(q);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,22 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
runtimes
|
||||
.filter((r) => r.daemon_id === status.daemonId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}, [runtimes, status.daemonId]);
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
(t) =>
|
||||
localRuntimeIds.has(t.runtime_id) &&
|
||||
(t.status === "running" || t.status === "dispatched"),
|
||||
),
|
||||
[snapshot, localRuntimeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
@@ -108,10 +36,7 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
@@ -119,214 +44,112 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
} else {
|
||||
setConfirmStop(true);
|
||||
}
|
||||
}, [affectedTasks.length, performStop]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRetryInstall = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
status={status}
|
||||
runtimeCount={runtimeCount}
|
||||
/>
|
||||
|
||||
<StopConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
affectedCount={affectedTasks.length}
|
||||
onConfirm={() => {
|
||||
setConfirmStop(false);
|
||||
void performStop();
|
||||
}}
|
||||
/>
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function StopConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
affectedCount,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
affectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const plural = affectedCount === 1 ? "" : "s";
|
||||
const verb = affectedCount === 1 ? "is" : "are";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Stop daemon with {affectedCount} active task{plural}?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{affectedCount} task{plural} {verb} currently running on this
|
||||
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
|
||||
— affected tasks get marked <strong>failed</strong> once the
|
||||
timeout hits. The daemon won't auto-restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Stop daemon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -16,7 +10,7 @@ function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
@@ -29,44 +23,14 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
// One row inside the diagnostics block. Values that are likely to be
|
||||
// long IDs / URLs render as monospaced + truncated with a tooltip.
|
||||
function DiagnosticsRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
@@ -134,68 +98,6 @@ export function DaemonSettingsTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics — moved out of the logs panel so the panel can focus
|
||||
on logs. These fields matter for support tickets and bug reports,
|
||||
not for everyday use. */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold">Diagnostics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Identification and connection details. Useful when filing a bug
|
||||
report or investigating why a runtime isn't showing up.
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
|
||||
<DiagnosticsRow
|
||||
label="State"
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Uptime"
|
||||
value={status.uptime ? formatUptime(status.uptime) : "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="PID"
|
||||
value={status.pid ?? "—"}
|
||||
mono={!!status.pid}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Daemon ID"
|
||||
value={status.daemonId ?? "—"}
|
||||
mono={!!status.daemonId}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Profile"
|
||||
value={status.profile || "default"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Server URL"
|
||||
value={status.serverUrl ?? "—"}
|
||||
mono={!!status.serverUrl}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Device name"
|
||||
value={status.deviceName ?? "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Workspaces"
|
||||
value={
|
||||
typeof status.workspaceCount === "number"
|
||||
? status.workspaceCount
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,11 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/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();
|
||||
@@ -98,38 +95,6 @@ function useInternalLinkHandler() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between the renderer and the Electron main process for inbox-level
|
||||
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
|
||||
* current workspace's id for the badge hook.
|
||||
*
|
||||
* Two responsibilities:
|
||||
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
|
||||
* 2. When the user clicks an OS notification, open the notified
|
||||
* workspace's inbox focused on that item. The route uses the `slug`
|
||||
* that the notification was *emitted* with — not the currently active
|
||||
* workspace — so a notification from workspace A always opens A's
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
@@ -148,10 +113,8 @@ 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}>
|
||||
<DesktopInboxBridge />
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
@@ -169,8 +132,6 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// vi.hoisted shared state — every store mock reads the same object so each
|
||||
// test can mutate it then re-render to drive the tracker.
|
||||
const state = vi.hoisted(() => ({
|
||||
user: null as { id: string } | null,
|
||||
overlay: null as { type: string; invitationId?: string } | null,
|
||||
activeWorkspaceSlug: null as string | null,
|
||||
byWorkspace: {} as Record<
|
||||
string,
|
||||
{ activeTabId: string; tabs: { id: string; path: string }[] }
|
||||
>,
|
||||
capturePageview: vi.fn<(path?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
capturePageview: state.capturePageview,
|
||||
}));
|
||||
|
||||
// Auth store — single selector pattern (`s => s.user`).
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
// Window overlay store — same shape.
|
||||
vi.mock("@/stores/window-overlay-store", () => {
|
||||
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useWindowOverlayStore };
|
||||
});
|
||||
|
||||
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
|
||||
// getState() for the seed pass and the helpers the tracker imports
|
||||
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
|
||||
// from the real store inside a mocked module.
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const useTabStore = Object.assign(
|
||||
(selector: (s: typeof state) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
);
|
||||
const getActiveTab = (s: typeof state) => {
|
||||
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) ?? null;
|
||||
};
|
||||
const useActiveTabIdentity = () => ({
|
||||
slug: state.activeWorkspaceSlug,
|
||||
tabId: state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
});
|
||||
return { useTabStore, getActiveTab, useActiveTabIdentity };
|
||||
});
|
||||
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
function reset() {
|
||||
state.user = { id: "u1" };
|
||||
state.overlay = null;
|
||||
state.activeWorkspaceSlug = null;
|
||||
state.byWorkspace = {};
|
||||
state.capturePageview.mockClear();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
describe("PageviewTracker", () => {
|
||||
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
// Initial mount on tA — seeded as observed, no pageview because both
|
||||
// tabs were already in the persisted store before the tracker mounted.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch to tB (already-known tab on its already-known path).
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tB",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch back to tA — still no pageview.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tC", path: "/acme/agents" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
|
||||
});
|
||||
|
||||
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
|
||||
// creates a fresh tab in the destination workspace and makes it active.
|
||||
state.byWorkspace = {
|
||||
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
|
||||
butter: {
|
||||
activeTabId: "tD",
|
||||
tabs: [{ id: "tD", path: "/butter/inbox" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "butter";
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
|
||||
});
|
||||
|
||||
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues/123" }],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
|
||||
});
|
||||
|
||||
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Open onboarding overlay.
|
||||
state.overlay = { type: "onboarding" };
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
|
||||
|
||||
// Close overlay back to the tab — the tab is already observed on
|
||||
// /acme/issues so this is a re-activation, no pageview.
|
||||
state.capturePageview.mockClear();
|
||||
state.overlay = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Logout fires /login.
|
||||
state.user = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
|
||||
});
|
||||
|
||||
it("suppresses on initial mount when the active tab was restored from persistence", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
render(<PageviewTracker />);
|
||||
// Restored tab — seeded, treated as a re-activation.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
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,
|
||||
* EXCEPT for re-activations of an already-known tab on its already-known
|
||||
* path.
|
||||
*
|
||||
* 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`.
|
||||
*
|
||||
* Tab-switch suppression: re-activating an already-open tab surfaces a
|
||||
* previously-visited path under a `(workspace, tabId)` we have already
|
||||
* seen — the pageview was emitted when the user originally navigated
|
||||
* there, so re-emitting on every switch just inflates PostHog billing
|
||||
* without adding signal (real-data audit: desktop tab switches were
|
||||
* ~50% of all `$pageview` events).
|
||||
*
|
||||
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
|
||||
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
|
||||
* because their key is not in the observed map yet. The map is seeded
|
||||
* from the persisted tab store on first render so tabs restored from a
|
||||
* previous session don't all re-emit on first activation.
|
||||
*
|
||||
* 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 { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
|
||||
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
|
||||
|
||||
// (slug:tabId) → last path observed while that tab was visible. Lets us
|
||||
// tell "re-activating a tab on a path we already saw" (suppress) apart
|
||||
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
|
||||
// synchronously on first render from the persisted tab store so
|
||||
// session-restored tabs don't re-emit on first click.
|
||||
const observedTabsRef = useRef<Map<string, string> | null>(null);
|
||||
if (observedTabsRef.current === null) {
|
||||
const seed = new Map<string, string>();
|
||||
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
|
||||
for (const tab of group.tabs) {
|
||||
seed.set(`${slug}:${tab.id}`, tab.path);
|
||||
}
|
||||
}
|
||||
observedTabsRef.current = seed;
|
||||
}
|
||||
const lastSurfaceRef = useRef<{
|
||||
kind: "login" | "overlay" | "tab" | null;
|
||||
key: string | null;
|
||||
path: string | null;
|
||||
}>({ kind: null, key: null, path: null });
|
||||
|
||||
useEffect(() => {
|
||||
let kind: "login" | "overlay" | "tab";
|
||||
let path: string;
|
||||
let key: string | null = null;
|
||||
|
||||
if (!user) {
|
||||
kind = "login";
|
||||
path = "/login";
|
||||
} else if (overlay) {
|
||||
kind = "overlay";
|
||||
path = overlayPath(overlay);
|
||||
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
|
||||
kind = "tab";
|
||||
key = `${activeWorkspaceSlug}:${activeTabId}`;
|
||||
path = activeTabPath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const observed = observedTabsRef.current!;
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
last.key !== key && knownPath !== undefined && knownPath === path;
|
||||
observed.set(key, path);
|
||||
if (isReactivation) {
|
||||
lastSurfaceRef.current = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const unchanged =
|
||||
last.kind === kind && last.key === key && last.path === path;
|
||||
if (unchanged) return;
|
||||
|
||||
capturePageview(path);
|
||||
lastSurfaceRef.current = next;
|
||||
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
return "/workspaces/new";
|
||||
case "onboarding":
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
case "invitations":
|
||||
return "/invitations";
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLogLine } from "./parse-daemon-log";
|
||||
|
||||
// All sample lines below are taken verbatim from real daemon output (Go
|
||||
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
|
||||
// stay aligned with what tint actually writes — not what we assume.
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses tint's 3-letter INF level", () => {
|
||||
const line =
|
||||
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.timestamp).toBe("17:52:35.587");
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("task completed");
|
||||
expect(r.fields).toEqual({
|
||||
component: "daemon",
|
||||
task: "c45266e5",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-letter DBG / WRN / ERR levels", () => {
|
||||
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
|
||||
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
|
||||
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("still accepts 4-letter level names (defensive against config changes)", () => {
|
||||
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("regular");
|
||||
});
|
||||
|
||||
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
|
||||
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
|
||||
// We treat the base level as canonical and drop the delta from the UI.
|
||||
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("unusual delta");
|
||||
});
|
||||
|
||||
it("preserves message text containing colons and special chars", () => {
|
||||
// Real sample: "tool #1: Skill component=daemon task=..."
|
||||
const r = parseLogLine(
|
||||
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("tool #1: Skill");
|
||||
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
|
||||
});
|
||||
|
||||
it("unquotes a double-quoted value containing escaped quotes", () => {
|
||||
// Real sample with escaped quotes inside the agent's emitted text.
|
||||
const line =
|
||||
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent");
|
||||
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
|
||||
expect(r.fields.task).toBe("8791b717");
|
||||
});
|
||||
|
||||
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
|
||||
// Real sample: error="Post \"http://...\": dial tcp ..."
|
||||
const line =
|
||||
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.level).toBe("WARN");
|
||||
expect(r.message).toBe("claim task failed");
|
||||
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
|
||||
expect(r.fields.error).toBe(
|
||||
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
|
||||
const line =
|
||||
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent command");
|
||||
expect(r.fields.exec).toBe("claude");
|
||||
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
|
||||
});
|
||||
|
||||
it("handles message words ending with characters before the field block", () => {
|
||||
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
|
||||
const r = parseLogLine(
|
||||
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("execenv: prepared env");
|
||||
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
|
||||
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
|
||||
expect(r.fields).toEqual({});
|
||||
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for unrecognised level tokens", () => {
|
||||
// If tint ever emits something we don't know, never crash; show raw.
|
||||
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
|
||||
});
|
||||
|
||||
it("attaches an id to every parsed line for stable React keys", () => {
|
||||
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
|
||||
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
|
||||
expect(a.id).toBe(7);
|
||||
expect(b.id).toBe(8);
|
||||
});
|
||||
|
||||
it("returns empty fields object when there are no key=value pairs", () => {
|
||||
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
|
||||
expect(r.message).toBe("a bare message with no fields");
|
||||
expect(r.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
// Pure parser for daemon log lines. The daemon writes via Go's slog with
|
||||
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
|
||||
// has a stable shape:
|
||||
//
|
||||
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
|
||||
//
|
||||
// We split it into structured pieces so the UI can render timestamp,
|
||||
// level, message and structured fields in separate columns and let users
|
||||
// filter / search across them. Anything that doesn't match (panic stack
|
||||
// traces, third-party prints, partial writes during log rotation) falls
|
||||
// back to a raw view — we never drop input.
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ParsedLogLine {
|
||||
/** Monotonic id assigned at receive time; stable across re-renders. */
|
||||
id: number;
|
||||
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
|
||||
timestamp: string | null;
|
||||
level: LogLevel | null;
|
||||
/** Human-readable message body, with structured fields stripped off. */
|
||||
message: string;
|
||||
/** key/value pairs trailing the message. Empty if there were none. */
|
||||
fields: Record<string, string>;
|
||||
/** The original line, kept for fallback rendering and copy-to-clipboard. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
|
||||
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
|
||||
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
|
||||
// against future config changes) and normalize them to a canonical
|
||||
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
|
||||
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
|
||||
// and complicates the level filter chips.
|
||||
const HEADER_RE =
|
||||
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
|
||||
|
||||
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
|
||||
DEBUG: "DEBUG",
|
||||
DBG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
INF: "INFO",
|
||||
WARN: "WARN",
|
||||
WRN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
ERR: "ERROR",
|
||||
};
|
||||
// Anchored to the END of the remaining string so we peel one field at a
|
||||
// time from the right. `value` is either a double-quoted string (which may
|
||||
// contain escaped chars) or any non-whitespace run.
|
||||
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractTrailingFields(rest: string): {
|
||||
message: string;
|
||||
fields: Record<string, string>;
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
let work = rest;
|
||||
while (true) {
|
||||
const match = work.match(TRAILING_FIELD_RE);
|
||||
if (!match || match.index === undefined) break;
|
||||
fields[match[1]!] = unquote(match[2]!);
|
||||
work = work.slice(0, match.index);
|
||||
}
|
||||
return { message: work.trim(), fields };
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string, id: number): ParsedLogLine {
|
||||
const match = raw.match(HEADER_RE);
|
||||
if (!match) {
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const [, timestamp, level, rest] = match;
|
||||
const normalized = LEVEL_NORMALIZE[level!];
|
||||
if (!normalized) {
|
||||
// Unknown level token — keep raw shape so we don't mis-categorize.
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const { message, fields } = extractTrailingFields(rest!);
|
||||
return {
|
||||
id,
|
||||
timestamp: timestamp!,
|
||||
level: normalized,
|
||||
message,
|
||||
fields,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -29,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,
|
||||
@@ -67,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();
|
||||
};
|
||||
@@ -122,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 (
|
||||
@@ -143,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) => {
|
||||
@@ -183,7 +195,7 @@ export function TabBar() {
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{group && <NewTabButton />}
|
||||
<NewTabButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,96 +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" }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
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" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
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-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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're on the latest version.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
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 === "invitations" && <InvitationsPage />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +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 { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
@@ -21,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 }>();
|
||||
@@ -35,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]);
|
||||
@@ -45,45 +40,39 @@ 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}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agent = agents.find((a) => a.id === id) ?? null;
|
||||
|
||||
useDocumentTitle(agent?.name ?? "Agent");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedAgentDetailPage agentId={id} />;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
@@ -14,9 +13,5 @@ export function IssueDetailPage() {
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
function requireRuntimeAppUrl(): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const webUrl = requireRuntimeAppUrl();
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${webUrl}/login?platform=desktop`,
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
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={() => {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function RuntimeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const runtime = runtimes?.find((r) => r.id === id);
|
||||
|
||||
useDocumentTitle(runtime?.name ?? "Runtime");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedRuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SkillDetailPage as SharedSkillDetailPage } from "@multica/views/skills";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillDetailOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: skill } = useQuery(skillDetailOptions(wsId, id ?? ""));
|
||||
|
||||
useDocumentTitle(skill?.name ?? "Skill");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedSkillDetailPage skillId={id} />;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { runtimeKeys } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
|
||||
* type — we redeclare the fields we consume here to avoid coupling the bridge
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
|
||||
* field is overridden; other fields (name, provider, last_seen_at, etc)
|
||||
* remain server-authoritative. We deliberately ignore intermediate states
|
||||
* (starting / stopping / installing_cli / cli_not_found) so the cache
|
||||
* doesn't flap during boot — if the daemon is in such a state, the runtime
|
||||
* is effectively offline anyway, and the server-side sweeper will mark it
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return {
|
||||
...rt,
|
||||
status: "online",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local daemon status changes via Electron IPC and writes them
|
||||
* into the runtimes Query cache for the active workspace.
|
||||
*
|
||||
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
|
||||
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
|
||||
* we know about local daemon state instantly via IPC, so we use it to
|
||||
* pre-populate the cache and give users a sub-second feedback loop. Web and
|
||||
* "looking at someone else's daemon" still go through the server path.
|
||||
*
|
||||
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
|
||||
* in the same workspace (one per provider). We map across all matches so
|
||||
* every related runtime row sees the same status flip.
|
||||
*/
|
||||
export function useDaemonIPCBridge(wsId: string | undefined): void {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
|
||||
if (!daemonAPI?.onStatusChange) return;
|
||||
|
||||
const unsubscribe = daemonAPI.onStatusChange((status) => {
|
||||
if (!status.daemonId) return;
|
||||
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((rt) =>
|
||||
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [wsId, qc]);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { LocaleAdapter, SupportedLocale } from "@multica/core/i18n";
|
||||
|
||||
const STORAGE_KEY = "multica-locale";
|
||||
|
||||
// Desktop adapter:
|
||||
// - User choice: localStorage (set by Settings switcher).
|
||||
// - System preference: locale main injected via additionalArguments
|
||||
// (read from preload, exposed on window.desktopAPI.systemLocale).
|
||||
// - Persist: localStorage. The Settings switcher additionally PATCHes
|
||||
// /api/me when logged in so user.language follows the user across devices.
|
||||
export function createDesktopLocaleAdapter(systemLocale: string): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice() {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSystemPreferences() {
|
||||
return systemLocale ? [systemLocale] : [];
|
||||
},
|
||||
persist(locale: SupportedLocale) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,112 +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";
|
||||
|
||||
function requireRuntimeAppUrl(scope: string): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
`Invariant violated: ${scope} rendered before App accepted runtime config`,
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
// 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 === "/invitations") {
|
||||
overlay.open({ type: "invitations" });
|
||||
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.
|
||||
@@ -120,89 +24,59 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() — ChatWindow in particular —
|
||||
// can read URL search params. Must stay in sync with TabNavigationProvider
|
||||
// below; a partial shape here (just pathname) silently broke focus-mode
|
||||
// anchor resolution on `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
search: router?.state.location.search ?? "",
|
||||
}),
|
||||
);
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
if (!router) {
|
||||
setLocation({ pathname: "/", search: "" });
|
||||
return;
|
||||
}
|
||||
setLocation({
|
||||
pathname: router.state.location.pathname,
|
||||
search: router.state.location.search,
|
||||
if (!activeTab) return;
|
||||
setPathname(activeTab.router.state.location.pathname);
|
||||
return activeTab.router.subscribe((state) => {
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
return router.subscribe((state) => {
|
||||
setLocation({
|
||||
pathname: state.location.pathname,
|
||||
search: state.location.search,
|
||||
});
|
||||
});
|
||||
}, [activeTabId, router]);
|
||||
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (path === "/login") {
|
||||
// DashboardGuard token expired — force back to login screen
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
active?.router.navigate(path);
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
active?.router.navigate(path, { replace: true });
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
currentActiveTab()?.router.navigate(-1);
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(-1);
|
||||
},
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
store.switchWorkspace(slug, path);
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, 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.
|
||||
@@ -216,7 +90,6 @@ export function TabNavigationProvider({
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -228,33 +101,20 @@ 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) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, router, location],
|
||||
[router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -6,27 +6,27 @@ 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";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { DashboardPage } from "@multica/views/dashboard";
|
||||
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 { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -59,42 +59,83 @@ 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 />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{
|
||||
path: "issues",
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
handle: { title: "Issues" },
|
||||
},
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
@@ -127,38 +168,12 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <DesktopRuntimesPage />,
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{
|
||||
path: "runtimes/:id",
|
||||
element: <RuntimeDetailPage />,
|
||||
handle: { title: "Runtime" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
element: <SkillDetailPage />,
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
|
||||
{
|
||||
path: "squads/:id",
|
||||
element: <SquadDetailPageView />,
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
handle: { title: "Usage" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
@@ -170,12 +185,6 @@ export const appRoutes: RouteObject[] = [
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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,236 +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("defers disposing the closed tab router until after the store update", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
const closingTab = useTabStore
|
||||
.getState()
|
||||
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
|
||||
const dispose = vi.mocked(closingTab!.router.dispose);
|
||||
|
||||
store.closeTab(closedTabId);
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
|
||||
).toBe(false);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores router-sync updates from a tab after it has been closed", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
|
||||
store.closeTab(closedTabId);
|
||||
const before = useTabStore.getState().byWorkspace.acme;
|
||||
|
||||
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
|
||||
store.updateTabHistory(closedTabId, 1, 2);
|
||||
|
||||
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
|
||||
expect(
|
||||
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replace the tab group for no-op router-sync updates", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
|
||||
const before = useTabStore.getState().byWorkspace.acme;
|
||||
|
||||
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
|
||||
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
|
||||
|
||||
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -112,612 +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];
|
||||
const disposeClosingRouter = () => {
|
||||
// Let React unmount the tab's RouterProvider before disposing it.
|
||||
window.setTimeout(() => closing.router.dispose(), 0);
|
||||
};
|
||||
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 },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
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 },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
},
|
||||
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 };
|
||||
if (
|
||||
next.path === current.path &&
|
||||
next.title === current.title &&
|
||||
next.icon === current.icon
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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];
|
||||
if (
|
||||
current.historyIndex === historyIndex &&
|
||||
current.historyLength === historyLength
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,31 +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: "invitations" }
|
||||
| { 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 }),
|
||||
}));
|
||||
@@ -51,35 +51,3 @@ export function formatUptime(uptime?: string): string {
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
* "what does this mean for me?" — i.e. whether tasks can run on this device.
|
||||
*
|
||||
* `runtimeCount` is the number of runtimes the local daemon has registered
|
||||
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
|
||||
* when state === "running".
|
||||
*/
|
||||
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
if (runtimeCount === 0) {
|
||||
return "Running, but no runtimes have registered yet.";
|
||||
}
|
||||
if (runtimeCount === 1) {
|
||||
return "Running here · 1 runtime available for tasks.";
|
||||
}
|
||||
return `Running here · ${runtimeCount} runtimes available for tasks.`;
|
||||
case "stopped":
|
||||
return "Not running · this device can't take new tasks.";
|
||||
case "starting":
|
||||
return "Starting up the local daemon…";
|
||||
case "stopping":
|
||||
return "Shutting down the local daemon…";
|
||||
case "installing_cli":
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
deriveWsUrl,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
} from "./runtime-config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
it("uses cloud defaults without a desktop.json file", () => {
|
||||
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives https/wss compatible URLs from apiUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
|
||||
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the leading api. label when deriving appUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.multica.ai" }),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ws for http api URLs", () => {
|
||||
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
|
||||
});
|
||||
|
||||
it("accepts explicit appUrl and wsUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com/",
|
||||
wsUrl: "wss://ws.example.com/socket/",
|
||||
appUrl: "https://app.example.com/",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://ws.example.com/socket",
|
||||
appUrl: "https://app.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
|
||||
).toThrow(/schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects non-http api schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
|
||||
).toThrow(/apiUrl must use http or https/);
|
||||
});
|
||||
|
||||
it("rejects non-ws websocket schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "https://api.example.com/ws",
|
||||
}),
|
||||
),
|
||||
).toThrow(/wsUrl must use ws or wss/);
|
||||
});
|
||||
|
||||
it("preserves electron-vite dev env precedence", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "http://dev-api.example.test:8080/",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws/",
|
||||
appUrl: "http://dev-app.example.test:3000/",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://dev-api.example.test:8080",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws",
|
||||
appUrl: "http://dev-app.example.test:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to local web URL when dev apiUrl is localhost", () => {
|
||||
expect(runtimeConfigFromDevEnv({ apiUrl: "http://localhost:8080" })).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives dev appUrl by stripping the leading api. label", () => {
|
||||
// When the dev renderer is pointed at a remote backend (e.g. a test
|
||||
// environment), copy-link / share URLs must reflect that environment's
|
||||
// public web host, not the api host. Multica's convention exposes the
|
||||
// api at `api.<web-host>`, so stripping the leading label gives the
|
||||
// right web origin without a separate VITE_APP_URL.
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({ apiUrl: "https://api.test.multica.ai" }),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
wsUrl: "wss://api.test.multica.ai/ws",
|
||||
appUrl: "https://test.multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("dev VITE_APP_URL still wins over apiUrl-derived value", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
appUrl: "https://staging.multica.ai",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
wsUrl: "wss://api.test.multica.ai/ws",
|
||||
appUrl: "https://staging.multica.ai",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
export interface RuntimeConfig {
|
||||
schemaVersion: 1;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RuntimeConfigResult =
|
||||
| { ok: true; config: RuntimeConfig }
|
||||
| { ok: false; error: RuntimeConfigError };
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
|
||||
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface RuntimeConfigEnv {
|
||||
apiUrl?: string;
|
||||
wsUrl?: string;
|
||||
appUrl?: string;
|
||||
}
|
||||
|
||||
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
const apiUrl = normalizeHttpUrl(
|
||||
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
|
||||
"VITE_API_URL",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl,
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: env.appUrl
|
||||
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
|
||||
: deriveDevAppUrl(apiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConfig(raw: string): RuntimeConfig {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid desktop runtime config: expected a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
|
||||
}
|
||||
|
||||
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
|
||||
const appUrl = optionalString(obj.appUrl, "appUrl");
|
||||
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
|
||||
|
||||
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl: normalizedApiUrl,
|
||||
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
|
||||
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveWsUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error("apiUrl must use http or https");
|
||||
url.pathname = joinPath(url.pathname, "/ws");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
// Convention: api hosts are exposed at `api.<web-host>` (api.multica.ai →
|
||||
// multica.ai, api.test.multica.ai → test.multica.ai). Strip the leading
|
||||
// `api.` label so a single `apiUrl` configuration produces the right
|
||||
// shareable web URL. Hosts that don't match the convention (no leading
|
||||
// `api.` label, or short two-label hosts like `api.local`) fall through
|
||||
// untouched — those deployments must set `appUrl` explicitly.
|
||||
export function deriveAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
if (url.hostname.startsWith("api.") && url.hostname.split(".").length >= 3) {
|
||||
url.hostname = url.hostname.slice("api.".length);
|
||||
}
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
// Dev variant: when the api host is the local backend (`localhost:8080` /
|
||||
// `127.0.0.1:8080`), the renderer is served from a different port (3000),
|
||||
// so deriving by host alone is wrong. Fall back to the local dev web URL
|
||||
// in that case; for any non-local host (e.g. a remote test environment),
|
||||
// trust the production-style derivation so `apiUrl=https://api.test.x`
|
||||
// yields `appUrl=https://test.x` without a separate VITE_APP_URL.
|
||||
export function deriveDevAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
||||
return LOCAL_DEV_RUNTIME_CONFIG.appUrl;
|
||||
}
|
||||
return deriveAppUrl(apiUrl);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function joinPath(base: string, suffix: string): string {
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}${suffix}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -1,38 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src/renderer/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
|
||||
7
apps/docs/app/(home)/layout.tsx
Normal 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>;
|
||||
}
|
||||
29
apps/docs/app/(home)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +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";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const lang = asLang(params.lang);
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
|
||||
</DocsLocaleProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.generateParams().filter((p) => p.slug.length > 0);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates(params.slug),
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import "../global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { uiTranslations, localeLabels } from "@/lib/translations";
|
||||
import { DocsSettings } from "@/components/docs-settings";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
fallback: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans CJK SC",
|
||||
"sans-serif",
|
||||
],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
|
||||
// Editorial serif used for headings and showpiece elements. Italic style is
|
||||
// deliberately NOT loaded — italic in CJK is a synthetic slant that breaks
|
||||
// glyph design. Emphasis in docs is carried by brand color + weight, never
|
||||
// font-style. Mirrors apps/web/app/layout.tsx for the upright family.
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
style: ["normal"],
|
||||
variable: "--font-serif",
|
||||
fallback: [
|
||||
"ui-serif",
|
||||
"Iowan Old Style",
|
||||
"Apple Garamond",
|
||||
"Baskerville",
|
||||
"Times New Roman",
|
||||
"serif",
|
||||
],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Multica Docs",
|
||||
default: "Multica Docs",
|
||||
},
|
||||
description:
|
||||
"Documentation for Multica — the open-source managed agents platform.",
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = (i18n.languages as readonly string[]).includes(rawLang)
|
||||
? (rawLang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
const locales = i18n.languages.map((l) => ({
|
||||
locale: l,
|
||||
name: localeLabels[l],
|
||||
}));
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang}
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"antialiased",
|
||||
inter.variable,
|
||||
geistMono.variable,
|
||||
sourceSerif.variable,
|
||||
)}
|
||||
>
|
||||
<body className="font-sans">
|
||||
<RootProvider
|
||||
i18n={{
|
||||
locale: lang,
|
||||
locales,
|
||||
translations: uiTranslations[lang],
|
||||
}}
|
||||
search={{ options: { api: "/docs/api/search" } }}
|
||||
>
|
||||
<DocsLayout
|
||||
tree={source.getPageTree(lang)}
|
||||
// Suppress Fumadocs's default sidebar-footer icons (theme +
|
||||
// language + search). Our custom <DocsSettings> is mounted as
|
||||
// the sidebar footer instead — two labelled buttons, not three
|
||||
// icons.
|
||||
themeSwitch={{ enabled: false }}
|
||||
searchToggle={{ enabled: false }}
|
||||
sidebar={{ footer: <DocsSettings locale={lang} /> }}
|
||||
{...baseOptions}
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { DocsPage, DocsBody } from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { DocsHero } from "@/components/hero";
|
||||
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { homeCopy } from "@/lib/translations";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
// A layout's `generateStaticParams` does NOT cascade — every page that
|
||||
// wants SSG must declare its own. Without this, both `/docs/` and
|
||||
// `/docs/zh` (the busiest URLs on the site) render dynamically on every
|
||||
// request.
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = asLang(rawLang);
|
||||
const page = source.getPage([], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const copy = homeCopy[lang];
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsHero
|
||||
eyebrow={copy.eyebrow}
|
||||
title={
|
||||
<>
|
||||
{copy.titleLead}
|
||||
<em className="font-medium not-italic text-[var(--primary)]">
|
||||
{copy.titleAccent}
|
||||
</em>
|
||||
</>
|
||||
}
|
||||
subtitle={page.data.description}
|
||||
/>
|
||||
<Byline items={[...copy.byline]} />
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
a: LocaleLink,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
</DocsLocaleProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = asLang(rawLang);
|
||||
const page = source.getPage([], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates([]),
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,4 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
|
||||
// Orama doesn't ship a Chinese tokenizer and its built-in English regex
|
||||
// strips Han characters entirely, so `locale=zh` would either return empty
|
||||
// results or throw. Tokenize CJK input character-by-character and keep
|
||||
// Latin/digit runs whole — gives serviceable recall for Chinese docs while
|
||||
// letting Romanized terms (product names, CLI commands) still match.
|
||||
function tokenizeCJK(raw: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const regex = /[一-鿿㐀-䶿]|[A-Za-z0-9]+/g;
|
||||
const lower = raw.toLowerCase();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(lower)) !== null) {
|
||||
tokens.push(match[0]);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export const { GET } = createFromSource(source, {
|
||||
localeMap: {
|
||||
zh: {
|
||||
components: {
|
||||
tokenizer: {
|
||||
language: "english",
|
||||
normalizationCache: new Map(),
|
||||
tokenize: tokenizeCJK,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { GET } = createFromSource(source);
|
||||
|
||||
47
apps/docs/app/docs/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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 async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
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 async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
12
apps/docs/app/docs/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||