mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 06:25:56 +02:00
Compare commits
92 Commits
v0.2.8
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9f30f15c4 | ||
|
|
35aca57939 | ||
|
|
e0e91fc792 | ||
|
|
977b0c0558 | ||
|
|
17136742b9 | ||
|
|
5e51f5b356 | ||
|
|
13daede63e | ||
|
|
6107211a6e | ||
|
|
044d1443b5 | ||
|
|
8f10741a4d | ||
|
|
cbe0cbef56 | ||
|
|
502add4bd1 | ||
|
|
5ef957ca1b | ||
|
|
6d9ca9de93 | ||
|
|
e994d77982 | ||
|
|
ad803b86ec | ||
|
|
b51d1c4dc3 | ||
|
|
efc08a1e37 | ||
|
|
6fd1255873 | ||
|
|
6c72c71e3e | ||
|
|
83a3683d07 | ||
|
|
fae3afee79 | ||
|
|
91424752ac | ||
|
|
d97aec83d7 | ||
|
|
95bcffef8c | ||
|
|
d6e7824ff1 | ||
|
|
f2ba087f74 | ||
|
|
059356cce7 | ||
|
|
7375bda9b5 | ||
|
|
9dcc082920 | ||
|
|
98edc6b9ff | ||
|
|
88b892f1ca | ||
|
|
2cced51d64 | ||
|
|
6717db1fad | ||
|
|
2a248b8548 | ||
|
|
f84d216794 | ||
|
|
101da19b02 | ||
|
|
dc8096fb6e | ||
|
|
2dae42f58a | ||
|
|
f6dd47c944 | ||
|
|
f98a67dd90 | ||
|
|
90ccd97469 | ||
|
|
180a534511 | ||
|
|
2d0916ee38 | ||
|
|
5335edd50d | ||
|
|
153e2b6245 | ||
|
|
205e8c1e9c | ||
|
|
cd6bb48283 | ||
|
|
fbf41bde73 | ||
|
|
936df59fa1 | ||
|
|
fa7e4cbdca | ||
|
|
747d9492cf | ||
|
|
c787546ede | ||
|
|
14a9b5293e | ||
|
|
b8b38381bb | ||
|
|
3036c6418e | ||
|
|
26a2db2540 | ||
|
|
aa9932e4e1 | ||
|
|
4a7de91ddf | ||
|
|
3b426d21ee | ||
|
|
b624cd98ad | ||
|
|
f247a4f544 | ||
|
|
0b1333fb00 | ||
|
|
387f76d328 | ||
|
|
3fd2fb2ae3 | ||
|
|
1a565a221a | ||
|
|
536f4286f1 | ||
|
|
c6d54e8ce5 | ||
|
|
20c9d985f5 | ||
|
|
6366e2f4ba | ||
|
|
642844c736 | ||
|
|
6ecf15e62c | ||
|
|
52c9bd72cb | ||
|
|
7ada72faa6 | ||
|
|
df86f559e0 | ||
|
|
d5071abb75 | ||
|
|
ba003eee83 | ||
|
|
a3a6158d96 | ||
|
|
9481350ef0 | ||
|
|
637bdc8eb3 | ||
|
|
6f63fae41a | ||
|
|
c5a00d8b8c | ||
|
|
4ac43e9e49 | ||
|
|
03e21aee80 | ||
|
|
632fdde700 | ||
|
|
cc1ccedaf3 | ||
|
|
8eb81aa396 | ||
|
|
965bf731ab | ||
|
|
0db7d2fb64 | ||
|
|
4368e1be18 | ||
|
|
bb31afbbce | ||
|
|
4a25b91590 |
27
.env.example
27
.env.example
@@ -36,6 +36,14 @@ MULTICA_CODEX_MODEL=
|
||||
MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Self-host image channel
|
||||
# Default stable release channel. Pin to an exact release like v0.2.4 if you
|
||||
# want to stay on a specific version. If the selected tag has not been
|
||||
# published to GHCR yet, use make selfhost-build / the build override instead.
|
||||
MULTICA_IMAGE_TAG=latest
|
||||
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
@@ -44,10 +52,12 @@ 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=
|
||||
@@ -90,10 +100,9 @@ NEXT_PUBLIC_WS_URL=
|
||||
# ==================== Self-hosting: Control Signups (fixes #930) ====================
|
||||
# Set to "false" to completely disable new user signups (recommended for private instances)
|
||||
ALLOW_SIGNUP=true
|
||||
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
|
||||
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
|
||||
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
|
||||
NEXT_PUBLIC_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=
|
||||
@@ -101,3 +110,11 @@ ALLOWED_EMAIL_DOMAINS=
|
||||
# Optional: Only allow these exact email addresses (comma-separated)
|
||||
ALLOWED_EMAILS=
|
||||
|
||||
# ==================== Analytics (PostHog) ====================
|
||||
# Product analytics events feed the acquisition → activation → expansion funnel.
|
||||
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
|
||||
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -48,8 +48,22 @@ jobs:
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
|
||||
# (which would flip the server binary itself onto the Redis-backed
|
||||
# realtime relay + request stores); the tests talk to this Redis
|
||||
# directly so they run alongside the Postgres-backed suite.
|
||||
REDIS_TEST_URL: redis://localhost:6379/1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
59
.github/workflows/desktop-smoke.yml
vendored
Normal file
59
.github/workflows/desktop-smoke.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Desktop Smoke Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
desktop:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish never
|
||||
|
||||
- name: Upload Desktop artifacts (${{ matrix.target }})
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.target }}
|
||||
path: apps/desktop/dist
|
||||
if-no-files-found: error
|
||||
334
.github/workflows/release.yml
vendored
334
.github/workflows/release.yml
vendored
@@ -3,15 +3,21 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
# GitHub Actions uses glob patterns here, not regex. Match versioned
|
||||
# tags broadly at the trigger layer, then enforce strict semver below.
|
||||
- "v*.*.*"
|
||||
- "!v*-dirty*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.release_meta.outputs.tag_name }}
|
||||
is_stable: ${{ steps.release_meta.outputs.is_stable }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -19,13 +25,25 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag name
|
||||
id: release_meta
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
echo "Triggered by tag: $tag"
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$tag" == *-dirty* ]]; then
|
||||
echo "::error::Refusing to release from dirty tag '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$tag" == *-* ]]; then
|
||||
echo "is_stable=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_stable=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -36,6 +54,21 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -44,3 +77,298 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
# Multi-arch images are built natively per platform on dedicated runners
|
||||
# (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm) and merged into a
|
||||
# manifest list. This avoids QEMU emulation, which was making the Next.js
|
||||
# arm64 build run for 30+ minutes per release.
|
||||
docker-backend-build:
|
||||
needs: verify
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute backend image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-backend
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Backend
|
||||
org.opencontainers.image.description=Multica self-hosted backend
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-backend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=release-backend-${{ env.PLATFORM_PAIR }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-backend,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-backend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker-backend-merge:
|
||||
needs: [verify, docker-backend-build]
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-docker-backend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-backend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute backend image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-backend
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
|
||||
type=raw,value=${{ needs.verify.outputs.tag_name }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-backend@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
ghcr.io/${{ github.repository_owner }}/multica-backend:${{ steps.meta.outputs.version }}
|
||||
|
||||
docker-web-build:
|
||||
needs: verify
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute web image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-web
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Web
|
||||
org.opencontainers.image.description=Multica self-hosted web frontend
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-web-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=release-web-${{ env.PLATFORM_PAIR }}
|
||||
build-args: |
|
||||
REMOTE_API_URL=http://backend:8080
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-web,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-web-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker-web-merge:
|
||||
needs: [verify, docker-web-build]
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-docker-web-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-web-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute web image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-web
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
|
||||
type=raw,value=${{ needs.verify.outputs.tag_name }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-web@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Build the Desktop installers for Linux and Windows and upload them to
|
||||
# the GitHub Release that the `release` job above just published. macOS
|
||||
# Desktop continues to ship via the manual `release-desktop` skill so it
|
||||
# can be signed + notarized with Apple Developer credentials that are
|
||||
# not (yet) wired into CI.
|
||||
desktop:
|
||||
needs: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# electron-builder's GitHub publisher reads this:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable code signing on Linux/Windows for now — the public
|
||||
# release is unsigned for these platforms, the CLI carries the
|
||||
# trust boundary. Set CSC_LINK in repo secrets to enable
|
||||
# Windows signing later.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,3 +57,4 @@ _features/
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
@@ -21,12 +21,12 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
# Legacy archive name kept so already-released CLIs (whose `multica update`
|
||||
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
|
||||
# once those versions are no longer in use.
|
||||
- id: legacy
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
@@ -34,6 +34,16 @@ archives:
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
# Versioned archive name used by current CLI / install scripts /
|
||||
# desktop bootstrap going forward.
|
||||
- id: versioned
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
@@ -48,6 +58,8 @@ changelog:
|
||||
|
||||
brews:
|
||||
- name: multica
|
||||
ids:
|
||||
- versioned
|
||||
repository:
|
||||
owner: multica-ai
|
||||
name: homebrew-tap
|
||||
|
||||
85
.vercelignore
Normal file
85
.vercelignore
Normal file
@@ -0,0 +1,85 @@
|
||||
# Deploy the frontend apps from the monorepo root.
|
||||
# Keep apps/web, apps/docs, shared packages, and root workspace metadata.
|
||||
# Exclude unrelated workspaces and local artifacts that can make
|
||||
# `vercel deploy` upload far more than the app needs.
|
||||
|
||||
.agent_context
|
||||
.claude
|
||||
.context
|
||||
.env*
|
||||
.envrc
|
||||
.tool-versions
|
||||
_features
|
||||
.kilo
|
||||
.idea
|
||||
.DS_Store
|
||||
.husky
|
||||
.vscode
|
||||
|
||||
/.dockerignore
|
||||
/.goreleaser.yml
|
||||
/AGENTS.md
|
||||
/CLAUDE.md
|
||||
/CLI_AND_DAEMON.md
|
||||
/CLI_INSTALL.md
|
||||
/CONTRIBUTING.md
|
||||
/Dockerfile
|
||||
/Dockerfile.web
|
||||
/HANDOFF_ARCHITECTURE_AUDIT.md
|
||||
/Makefile
|
||||
/README.md
|
||||
/README.zh-CN.md
|
||||
/SELF_HOSTING.md
|
||||
/SELF_HOSTING_ADVANCED.md
|
||||
/SELF_HOSTING_AI.md
|
||||
/docker-compose*.yml
|
||||
/playwright.config.ts
|
||||
/skills-lock.json
|
||||
|
||||
/.github/
|
||||
/docker/
|
||||
/docs/
|
||||
/e2e/
|
||||
/server/
|
||||
/apps/desktop/
|
||||
/scripts/
|
||||
|
||||
*.log
|
||||
*.pid
|
||||
*.tsbuildinfo
|
||||
|
||||
.cache
|
||||
.next
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.vercel
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
data
|
||||
|
||||
node_modules
|
||||
bin
|
||||
dist
|
||||
out
|
||||
build
|
||||
dist-electron
|
||||
|
||||
# Deployment-only trims: tests and lint configs are not used by `next build`.
|
||||
**/__tests__/**
|
||||
**/test/**
|
||||
**/*.test.*
|
||||
**/*.spec.*
|
||||
/packages/eslint-config/
|
||||
/apps/web/components.json
|
||||
/apps/web/eslint.config.mjs
|
||||
/apps/web/vitest.config.ts
|
||||
|
||||
# Root repo metadata not needed in the deployment source.
|
||||
/.env.example
|
||||
/.gitattributes
|
||||
/.gitignore
|
||||
/LICENSE
|
||||
|
||||
*.app
|
||||
*.dmg
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -106,6 +106,7 @@ pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
@@ -190,55 +191,28 @@ Every path in the desktop app falls into exactly one category. Choosing the wron
|
||||
|
||||
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
|
||||
|
||||
### Workspace identity singleton
|
||||
### Workspace context
|
||||
|
||||
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
|
||||
|
||||
1. API client's `X-Workspace-Slug` header.
|
||||
2. Zustand per-workspace storage namespace.
|
||||
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
|
||||
|
||||
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
|
||||
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order:
|
||||
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
|
||||
|
||||
1. Read destination from cached workspace list (no extra fetch).
|
||||
1. Read destination from cached workspace list.
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
|
||||
3. `navigation.push(destination)`.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
Reversing step 4 with steps 1–3 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS window-move)
|
||||
### Drag region (macOS)
|
||||
|
||||
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via `useImmersiveMode` in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
|
||||
|
||||
**Pattern**: flex child at top, not absolute overlay.
|
||||
|
||||
```tsx
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
|
||||
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
|
||||
{/* page content — interactive elements need their own "no-drag" */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Height matches `MainTopBar` (48px / `h-12`) for consistency.
|
||||
|
||||
Canonical examples: `components/window-overlay.tsx`, `pages/login.tsx`.
|
||||
|
||||
### UX vs platform chrome
|
||||
|
||||
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (drag strip, `useImmersiveMode`, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
|
||||
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ fi
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
VERSION="${LATEST#v}"
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
|
||||
@@ -592,6 +592,19 @@ If you want to stop PostgreSQL and keep your local databases:
|
||||
make db-down
|
||||
```
|
||||
|
||||
If you want a fresh database for the current checkout only (drops the
|
||||
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
|
||||
|
||||
```bash
|
||||
make stop # stop backend/frontend first
|
||||
make db-reset
|
||||
make start
|
||||
```
|
||||
|
||||
- only affects the current env's database; other worktree databases are untouched
|
||||
- refuses to run if `DATABASE_URL` points at a remote host
|
||||
- pass `ENV_FILE=.env.worktree` to target a specific worktree
|
||||
|
||||
If you want to wipe all local PostgreSQL data for this repo:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
|
||||
163
Makefile
163
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
|
||||
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,10 +36,23 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
# Default target changed from selfhost to help: bare `make` now prints this help
|
||||
# instead of launching a full Docker Compose build, which is safer for onboarding.
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
##@ Help
|
||||
|
||||
help: ## Show available make targets and common local workflows
|
||||
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
|
||||
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
|
||||
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
makehelp: help ## Alias for `make help`
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
##@ Self-hosting
|
||||
|
||||
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
@@ -51,8 +64,58 @@ selfhost:
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Pulling official Multica images..."
|
||||
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
|
||||
echo ""; \
|
||||
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
|
||||
echo "If this is before the first GHCR release, build from the current checkout:"; \
|
||||
echo " make selfhost-build"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
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 "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
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-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 \
|
||||
@@ -69,6 +132,9 @@ selfhost:
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo ""; \
|
||||
echo "Built images locally via docker-compose.selfhost.build.yml."; \
|
||||
echo "Local tags: multica-backend:dev and multica-web:dev."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
echo " multica setup self-host"; \
|
||||
@@ -78,16 +144,15 @@ selfhost:
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
selfhost-stop: ## Stop the self-hosted Docker Compose stack
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
##@ One-click
|
||||
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
setup:
|
||||
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
|
||||
$(REQUIRE_ENV)
|
||||
@echo "==> Using env file: $(ENV_FILE)"
|
||||
@echo "==> Installing dependencies..."
|
||||
@@ -98,8 +163,7 @@ setup:
|
||||
@echo ""
|
||||
@echo "✓ Setup complete! Run 'make start' to launch the app."
|
||||
|
||||
# Start all services (backend + frontend)
|
||||
start:
|
||||
start: ## Start backend and frontend for the current checkout and run migrations first
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Using env file: $(ENV_FILE)"
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@@ -113,8 +177,7 @@ start:
|
||||
pnpm dev:web & \
|
||||
wait
|
||||
|
||||
# Stop all services
|
||||
stop:
|
||||
stop: ## Stop backend and frontend processes for the current checkout
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@@ -126,33 +189,52 @@ stop:
|
||||
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
|
||||
esac
|
||||
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
|
||||
$(REQUIRE_ENV)
|
||||
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
|
||||
|
||||
db-up:
|
||||
db-up: ## Start the shared PostgreSQL container used by main and worktrees
|
||||
@$(COMPOSE) up -d postgres
|
||||
|
||||
db-down:
|
||||
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
|
||||
@$(COMPOSE) down
|
||||
|
||||
worktree-env:
|
||||
# Drop + recreate the current env's database, then run all migrations.
|
||||
# Use for a clean slate in local dev. Only affects the DB named in
|
||||
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
|
||||
# worktree DBs are untouched. Refuses to run against a remote host.
|
||||
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
|
||||
$(REQUIRE_ENV)
|
||||
@case "$(DATABASE_URL)" in \
|
||||
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
|
||||
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
|
||||
esac
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
|
||||
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
|
||||
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
|
||||
@echo "==> Running migrations..."
|
||||
cd server && go run ./cmd/migrate up
|
||||
@echo ""
|
||||
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
|
||||
|
||||
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
|
||||
@bash scripts/init-worktree-env.sh .env.worktree
|
||||
|
||||
setup-main:
|
||||
setup-main: ## Prepare the main checkout using .env
|
||||
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
start-main:
|
||||
start-main: ## Start the main checkout using .env
|
||||
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
stop-main:
|
||||
stop-main: ## Stop the main checkout processes defined by .env
|
||||
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
check-main:
|
||||
check-main: ## Run the full verification pipeline for the main checkout
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree:
|
||||
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
@@ -161,65 +243,68 @@ setup-worktree:
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree:
|
||||
start-worktree: ## Start this worktree using .env.worktree
|
||||
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
stop-worktree:
|
||||
stop-worktree: ## Stop this worktree's backend and frontend processes
|
||||
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
check-worktree:
|
||||
check-worktree: ## Run the full verification pipeline for this worktree
|
||||
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
# ---------- Individual commands ----------
|
||||
##@ Individual commands
|
||||
|
||||
# One-command dev: auto-setup env/deps/db/migrations, then start all services
|
||||
dev:
|
||||
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
|
||||
@bash scripts/dev.sh
|
||||
|
||||
# Go server only
|
||||
server:
|
||||
server: ## Run only the Go server for the current checkout
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon:
|
||||
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
|
||||
|
||||
cli:
|
||||
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
|
||||
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
|
||||
|
||||
multica:
|
||||
multica: ## Run the multica CLI entrypoint directly from the Go source tree
|
||||
cd server && go run ./cmd/multica $(MULTICA_ARGS)
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build:
|
||||
build: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test:
|
||||
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
migrate-up:
|
||||
##@ Database
|
||||
|
||||
migrate-up: ## Create the target DB if needed, then apply database migrations
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
|
||||
migrate-down:
|
||||
migrate-down: ## Create the target DB if needed, then roll back database migrations
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate down
|
||||
|
||||
sqlc:
|
||||
sqlc: ## Regenerate sqlc code
|
||||
cd server && sqlc generate
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
##@ Cleanup
|
||||
|
||||
clean: ## Remove generated server binaries and temp files
|
||||
rm -rf server/bin server/tmp
|
||||
|
||||
13
README.md
13
README.md
@@ -85,7 +85,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,13 +185,3 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -172,13 +172,3 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
@@ -54,6 +54,10 @@ make selfhost
|
||||
|
||||
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
@@ -69,6 +73,8 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
@@ -156,14 +162,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
|
||||
|
||||
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
|
||||
## Rebuilding After Updates
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
---
|
||||
|
||||
@@ -186,6 +193,7 @@ JWT_SECRET=$(openssl rand -hex 32)
|
||||
Then start everything:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
|
||||
@@ -42,6 +42,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### Signup Controls (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
@@ -234,7 +246,7 @@ When using separate domains for frontend and backend, set these environment vari
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend (set before building the frontend image)
|
||||
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
@@ -250,15 +262,31 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then rebuild:
|
||||
Then restart the stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
|
||||
### WebSocket for LAN / Non-localhost Access
|
||||
|
||||
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
|
||||
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
|
||||
|
||||
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
|
||||
|
||||
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
|
||||
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
|
||||
|
||||
# Rebuild the web image so the build-time value is baked in
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
```
|
||||
|
||||
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
|
||||
|
||||
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
## Health Check
|
||||
|
||||
@@ -274,8 +302,9 @@ Use this for load balancer health checks or monitoring.
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
@@ -21,23 +21,26 @@ mac:
|
||||
- zip
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
|
||||
# so the filename alone surfaces kind, version, platform, and CPU arch.
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
|
||||
@@ -2,17 +2,31 @@
|
||||
"name": "@multica/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
"homepage": "https://multica.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/multica-ai/multica.git",
|
||||
"directory": "apps/desktop"
|
||||
},
|
||||
"author": {
|
||||
"name": "Multica",
|
||||
"email": "support@multica.ai"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "node scripts/package.mjs",
|
||||
"package:all": "node scripts/package.mjs --all-platforms --publish never",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
@@ -25,6 +39,7 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -23,8 +23,54 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const PLATFORM_TO_GOOS = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
|
||||
|
||||
function runtimePlatformFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-platform");
|
||||
if (flagIndex === -1) return process.platform;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function runtimeArchFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-arch");
|
||||
if (flagIndex === -1) return process.arch;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function normalizeRuntimePlatform(platform) {
|
||||
if (platform in PLATFORM_TO_GOOS) return platform;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target platform: ${platform}. ` +
|
||||
"Use darwin, linux, or win32.",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeArch(arch) {
|
||||
if (SUPPORTED_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target architecture: ${arch}. ` +
|
||||
"Use x64 or arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
function binaryNameForPlatform(platform) {
|
||||
return platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
const targetPlatform = normalizeRuntimePlatform(
|
||||
runtimePlatformFromArgs(process.argv.slice(2)),
|
||||
);
|
||||
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
|
||||
const goos = PLATFORM_TO_GOOS[targetPlatform];
|
||||
const goarch = targetArch === "x64" ? "amd64" : targetArch;
|
||||
const binName = binaryNameForPlatform(targetPlatform);
|
||||
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
@@ -61,8 +107,9 @@ if (hasGo()) {
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
|
||||
);
|
||||
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
@@ -70,10 +117,19 @@ if (hasGo()) {
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
join("bin", binName),
|
||||
srcBinary,
|
||||
"./cmd/multica",
|
||||
],
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
{
|
||||
cwd: serverDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -88,9 +144,11 @@ if (!(await exists(srcBinary))) {
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
|
||||
// into resources/bin/), then `electron-vite build` to produce the
|
||||
// main/preload/renderer bundles under out/, then invokes electron-builder
|
||||
// with `-c.extraMetadata.version=<derived>` so the override applies at
|
||||
// build time without mutating the tracked package.json.
|
||||
// Builds the Electron bundles once, then for each requested target
|
||||
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
|
||||
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
|
||||
// the override applies at build time without mutating the tracked
|
||||
// package.json.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
@@ -25,11 +25,50 @@
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { delimiter, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
const bundleCliScript = resolve(here, "bundle-cli.mjs");
|
||||
|
||||
const PLATFORM_CONFIG = {
|
||||
mac: {
|
||||
aliases: new Set(["--mac", "--macos", "-m"]),
|
||||
builderFlag: "--mac",
|
||||
runtimePlatform: "darwin",
|
||||
label: "macOS",
|
||||
},
|
||||
win: {
|
||||
aliases: new Set(["--win", "--windows", "-w"]),
|
||||
builderFlag: "--win",
|
||||
runtimePlatform: "win32",
|
||||
label: "Windows",
|
||||
},
|
||||
linux: {
|
||||
aliases: new Set(["--linux", "-l"]),
|
||||
builderFlag: "--linux",
|
||||
runtimePlatform: "linux",
|
||||
label: "Linux",
|
||||
},
|
||||
};
|
||||
|
||||
const ARCH_FLAGS = new Map([
|
||||
["--x64", "x64"],
|
||||
["--arm64", "arm64"],
|
||||
["--ia32", "ia32"],
|
||||
["--armv7l", "armv7l"],
|
||||
["--universal", "universal"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
|
||||
const MAC_ALL_PLATFORM_TARGETS = [
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
];
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
@@ -77,20 +116,231 @@ function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
export function envWithLocalBins(env = process.env, root = desktopRoot) {
|
||||
const pathKey =
|
||||
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
|
||||
const existingPath = env[pathKey] ?? "";
|
||||
const localBins = uniqueOrdered([
|
||||
resolve(root, "node_modules", ".bin"),
|
||||
resolve(root, "..", "..", "node_modules", ".bin"),
|
||||
]);
|
||||
const mergedPath = uniqueOrdered([
|
||||
...localBins,
|
||||
...String(existingPath)
|
||||
.split(delimiter)
|
||||
.filter(Boolean),
|
||||
]).join(delimiter);
|
||||
return { ...env, [pathKey]: mergedPath };
|
||||
}
|
||||
|
||||
function hostPlatformKey(platform = process.platform) {
|
||||
if (platform === "darwin") return "mac";
|
||||
if (platform === "win32") return "win";
|
||||
if (platform === "linux") return "linux";
|
||||
throw new Error(`[package] unsupported host platform: ${platform}`);
|
||||
}
|
||||
|
||||
function hostArchKey(arch = process.arch) {
|
||||
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
function expandPlatformShorthand(token) {
|
||||
if (!/^-[mwl]{2,}$/.test(token)) return null;
|
||||
const expanded = [];
|
||||
for (const char of token.slice(1)) {
|
||||
if (char === "m") expanded.push("mac");
|
||||
if (char === "w") expanded.push("win");
|
||||
if (char === "l") expanded.push("linux");
|
||||
}
|
||||
return uniqueOrdered(expanded);
|
||||
}
|
||||
|
||||
function platformKeyForToken(token) {
|
||||
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
|
||||
if (config.aliases.has(token)) return platform;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function platformTargetsTemplate() {
|
||||
return { mac: [], win: [], linux: [] };
|
||||
}
|
||||
|
||||
export function parsePackageArgs(argv) {
|
||||
const sharedArgs = [];
|
||||
const platformTargets = platformTargetsTemplate();
|
||||
const requestedPlatforms = [];
|
||||
const requestedArchs = [];
|
||||
let allPlatforms = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--all-platforms") {
|
||||
allPlatforms = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expandedPlatforms = expandPlatformShorthand(token);
|
||||
if (expandedPlatforms) {
|
||||
requestedPlatforms.push(...expandedPlatforms);
|
||||
continue;
|
||||
}
|
||||
|
||||
const platform = platformKeyForToken(token);
|
||||
if (platform) {
|
||||
requestedPlatforms.push(platform);
|
||||
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
||||
platformTargets[platform].push(argv[i + 1]);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const arch = ARCH_FLAGS.get(token);
|
||||
if (arch) {
|
||||
requestedArchs.push(arch);
|
||||
continue;
|
||||
}
|
||||
|
||||
sharedArgs.push(token);
|
||||
}
|
||||
|
||||
return {
|
||||
allPlatforms,
|
||||
sharedArgs,
|
||||
platformTargets,
|
||||
requestedPlatforms: uniqueOrdered(requestedPlatforms),
|
||||
requestedArchs: uniqueOrdered(requestedArchs),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
|
||||
if (parsed.allPlatforms) {
|
||||
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
|
||||
throw new Error(
|
||||
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
|
||||
);
|
||||
}
|
||||
if (platform !== "darwin") {
|
||||
throw new Error(
|
||||
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
|
||||
);
|
||||
}
|
||||
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
|
||||
}
|
||||
|
||||
const platforms =
|
||||
parsed.requestedPlatforms.length > 0
|
||||
? parsed.requestedPlatforms
|
||||
: [hostPlatformKey(platform)];
|
||||
const archs =
|
||||
parsed.requestedArchs.length > 0
|
||||
? parsed.requestedArchs
|
||||
: [hostArchKey(arch)];
|
||||
|
||||
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
|
||||
"Use --x64 or --arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
return platforms.flatMap((targetPlatform) =>
|
||||
archs.map((targetArch) => ({
|
||||
platform: targetPlatform,
|
||||
arch: targetArch,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTarget(target) {
|
||||
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
|
||||
}
|
||||
|
||||
export function builderArgsForTarget(
|
||||
target,
|
||||
parsed,
|
||||
version,
|
||||
{
|
||||
disableMacNotarize = false,
|
||||
hostPlatform = process.platform,
|
||||
useScopedOutputDir = false,
|
||||
} = {},
|
||||
) {
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
|
||||
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
|
||||
const requestedTargets = parsed.platformTargets[target.platform];
|
||||
if (
|
||||
target.platform === "linux" &&
|
||||
hostPlatform !== "linux" &&
|
||||
requestedTargets.length === 0
|
||||
) {
|
||||
// electron-builder only guarantees AppImage/Snap when cross-building
|
||||
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
|
||||
// to AppImage unless the caller explicitly requests Linux targets.
|
||||
builderArgs.push("AppImage");
|
||||
} else {
|
||||
builderArgs.push(...requestedTargets);
|
||||
}
|
||||
builderArgs.push(`--${target.arch}`);
|
||||
builderArgs.push(...parsed.sharedArgs);
|
||||
if (useScopedOutputDir) {
|
||||
builderArgs.push(
|
||||
`-c.directories.output=dist/${target.platform}-${target.arch}`,
|
||||
);
|
||||
}
|
||||
// electron-builder's update metadata file is `latest.yml` for Windows
|
||||
// regardless of arch (only Linux gets an arch suffix automatically — see
|
||||
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
|
||||
// channel override, building Windows x64 and arm64 in two invocations
|
||||
// makes both publish `latest.yml` to the same GitHub Release, so the
|
||||
// second upload overwrites the first and one of the two architectures
|
||||
// ends up with no auto-update metadata. Route Windows arm64 to its own
|
||||
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
|
||||
// the renderer-side updater pins the matching channel per arch.
|
||||
if (target.platform === "win" && target.arch === "arm64") {
|
||||
builderArgs.push("-c.publish.channel=latest-arm64");
|
||||
}
|
||||
return builderArgs;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const parsed = parsePackageArgs(passthrough);
|
||||
const buildMatrix = resolveBuildMatrix(parsed);
|
||||
console.log(
|
||||
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
|
||||
);
|
||||
|
||||
// Step 1: build the Electron main/preload/renderer bundles. Without
|
||||
// this step electron-builder silently packages whatever is already in
|
||||
// out/, which on a fresh checkout (or after a partial build) ships an
|
||||
// app that white-screens because the renderer bundle is missing.
|
||||
//
|
||||
// CI invokes this script via `node scripts/package.mjs`, so we cannot
|
||||
// rely on pnpm/npm to inject package-local binaries into PATH.
|
||||
//
|
||||
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
|
||||
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
|
||||
// PATHEXT when spawning a bare command without a shell — it would fail
|
||||
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
|
||||
// through the shell is harmless. See
|
||||
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
const viteResult = spawnSync("electron-vite", ["build"], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
env: envWithLocalBins(),
|
||||
shell: true,
|
||||
});
|
||||
if (viteResult.error) {
|
||||
console.error(
|
||||
@@ -103,7 +353,7 @@ function main() {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -113,43 +363,62 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
|
||||
if (disableMacNotarize) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
const useScopedOutputDir = buildMatrix.length > 1;
|
||||
|
||||
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
// Step 3: for each requested target, build the matching CLI into
|
||||
// resources/bin/ and package that target in isolation.
|
||||
for (const target of buildMatrix) {
|
||||
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
bundleCliScript,
|
||||
"--target-platform",
|
||||
PLATFORM_CONFIG[target.platform].runtimePlatform,
|
||||
"--target-arch",
|
||||
target.arch,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
const builderArgs = builderArgsForTarget(target, parsed, version, {
|
||||
disableMacNotarize,
|
||||
hostPlatform: process.platform,
|
||||
useScopedOutputDir,
|
||||
});
|
||||
|
||||
// Step 4: invoke electron-builder for the current target only.
|
||||
// `shell: true` for the same Windows `.cmd` shim reason as the
|
||||
// electron-vite invocation above.
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
env: envWithLocalBins(),
|
||||
shell: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { delimiter, resolve } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
import {
|
||||
builderArgsForTarget,
|
||||
envWithLocalBins,
|
||||
normalizeGitVersion,
|
||||
parsePackageArgs,
|
||||
resolveBuildMatrix,
|
||||
stripLeadingSeparator,
|
||||
} from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
@@ -59,3 +67,207 @@ describe("stripLeadingSeparator", () => {
|
||||
expect(stripLeadingSeparator([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePackageArgs", () => {
|
||||
it("collects per-platform targets and shared args", () => {
|
||||
expect(
|
||||
parsePackageArgs([
|
||||
"--win", "nsis",
|
||||
"--mac", "dmg", "zip",
|
||||
"--arm64",
|
||||
"--publish", "never",
|
||||
]),
|
||||
).toEqual({
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: {
|
||||
mac: ["dmg", "zip"],
|
||||
win: ["nsis"],
|
||||
linux: [],
|
||||
},
|
||||
requestedPlatforms: ["win", "mac"],
|
||||
requestedArchs: ["arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("expands combined short flags", () => {
|
||||
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
|
||||
"mac",
|
||||
"win",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks the all-platforms shortcut", () => {
|
||||
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBuildMatrix", () => {
|
||||
it("defaults to the current host platform and arch", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([{ platform: "mac", arch: "arm64" }]);
|
||||
});
|
||||
|
||||
it("expands all-platforms on macOS", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: true,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported architectures", () => {
|
||||
expect(() =>
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["universal"],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/unsupported Desktop CLI architecture/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("builderArgsForTarget", () => {
|
||||
it("adds scoped output directories for multi-target builds", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["arm64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{
|
||||
disableMacNotarize: true,
|
||||
hostPlatform: "darwin",
|
||||
useScopedOutputDir: true,
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"-c.mac.notarize=false",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--arm64",
|
||||
"--publish",
|
||||
"never",
|
||||
"-c.directories.output=dist/win-arm64",
|
||||
"-c.publish.channel=latest-arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "always"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "win32", useScopedOutputDir: true },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"always",
|
||||
"-c.directories.output=dist/win-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["linux"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "darwin" },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--linux",
|
||||
"AppImage",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"never",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envWithLocalBins", () => {
|
||||
it("prepends desktop-local binary directories to PATH", () => {
|
||||
const desktopRoot = "/repo/apps/desktop";
|
||||
const result = envWithLocalBins(
|
||||
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
|
||||
desktopRoot,
|
||||
);
|
||||
expect(result.PATH.split(delimiter)).toEqual([
|
||||
resolve(desktopRoot, "node_modules", ".bin"),
|
||||
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves an existing Path key and avoids duplicate entries", () => {
|
||||
const desktopRoot = "/repo/apps/desktop";
|
||||
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
|
||||
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
|
||||
const result = envWithLocalBins(
|
||||
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
|
||||
desktopRoot,
|
||||
);
|
||||
expect(result).not.toHaveProperty("PATH");
|
||||
expect(result.Path.split(delimiter)).toEqual([
|
||||
desktopBin,
|
||||
workspaceBin,
|
||||
"runner-bin",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,35 +8,15 @@ import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
// Desktop prefers the bundled `multica` CLI shipped inside the app for
|
||||
// same-repo builds, but it can also repair or bootstrap a managed copy in
|
||||
// userData on first launch when the bundled binary is missing or unusable.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
@@ -92,14 +72,8 @@ async function sha256OfFile(path: string): Promise<string> {
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
@@ -118,7 +92,14 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const assetName = platformAssetName();
|
||||
const checksums = await fetchChecksums();
|
||||
const assetName = selectPlatformReleaseAssetName(checksums.keys());
|
||||
const expectedChecksum = checksums.get(assetName);
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
@@ -130,7 +111,7 @@ async function installFresh(): Promise<string> {
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
await verifyChecksum(archivePath, assetName, expectedChecksum);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
@@ -143,6 +124,7 @@ async function installFresh(): Promise<string> {
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rm(target, { force: true }).catch(() => {});
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
@@ -166,8 +148,10 @@ async function installFresh(): Promise<string> {
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
export async function ensureManagedCli(
|
||||
options: { forceInstall?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target)) return target;
|
||||
if (existsSync(target) && !options.forceInstall) return target;
|
||||
return installFresh();
|
||||
}
|
||||
|
||||
59
apps/desktop/src/main/cli-release-asset.test.ts
Normal file
59
apps/desktop/src/main/cli-release-asset.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
describe("selectPlatformReleaseAssetName", () => {
|
||||
it("prefers the versioned archive name when both exist", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the legacy archive name when only legacy is present", () => {
|
||||
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed darwin archive from release assets", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-arm64.tar.gz",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed windows zip archive", () => {
|
||||
const assetNames = [
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the current platform asset is missing", () => {
|
||||
expect(() =>
|
||||
selectPlatformReleaseAssetName(
|
||||
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/no release asset found/);
|
||||
});
|
||||
});
|
||||
62
apps/desktop/src/main/cli-release-asset.ts
Normal file
62
apps/desktop/src/main/cli-release-asset.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
|
||||
|
||||
function platformArchiveDescriptor(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): { os: string; arch: string; ext: string } {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[platform];
|
||||
const mappedArch = archMap[arch];
|
||||
if (!os || !mappedArch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
|
||||
);
|
||||
}
|
||||
const ext = platform === "win32" ? "zip" : "tar.gz";
|
||||
return { os, arch: mappedArch, ext };
|
||||
}
|
||||
|
||||
export function selectPlatformReleaseAssetName(
|
||||
assetNames: Iterable<string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string {
|
||||
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
|
||||
platform,
|
||||
arch,
|
||||
);
|
||||
const names = [...assetNames];
|
||||
|
||||
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
|
||||
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
|
||||
// only ship the legacy archive keep working.
|
||||
const suffix = `-${os}-${mappedArch}.${ext}`;
|
||||
const matches = names.filter(
|
||||
(name) =>
|
||||
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
|
||||
);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
|
||||
if (names.includes(legacyName)) {
|
||||
return legacyName;
|
||||
}
|
||||
|
||||
throw new Error(`no release asset found for current platform: ${suffix}`);
|
||||
}
|
||||
33
apps/desktop/src/main/context-menu.ts
Normal file
33
apps/desktop/src/main/context-menu.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
|
||||
|
||||
// Electron ships with no default right-click menu, so a user selecting text
|
||||
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
|
||||
// menu using `roles`, which keeps i18n + accelerator handling native.
|
||||
export function installContextMenu(webContents: WebContents): void {
|
||||
webContents.on("context-menu", (_event, params) => {
|
||||
const { editFlags, selectionText, isEditable } = params;
|
||||
const hasSelection = selectionText.trim().length > 0;
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
if (isEditable && editFlags.canCut) {
|
||||
menu.append(new MenuItem({ role: "cut" }));
|
||||
}
|
||||
if (hasSelection && editFlags.canCopy) {
|
||||
menu.append(new MenuItem({ role: "copy" }));
|
||||
}
|
||||
if (isEditable && editFlags.canPaste) {
|
||||
menu.append(new MenuItem({ role: "paste" }));
|
||||
}
|
||||
if (isEditable && editFlags.canSelectAll) {
|
||||
if (menu.items.length > 0) {
|
||||
menu.append(new MenuItem({ type: "separator" }));
|
||||
}
|
||||
menu.append(new MenuItem({ role: "selectAll" }));
|
||||
}
|
||||
|
||||
if (menu.items.length === 0) return;
|
||||
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
|
||||
menu.popup({ window });
|
||||
});
|
||||
}
|
||||
@@ -316,6 +316,36 @@ function bundledCliPath(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function probeCliBinary(
|
||||
bin: string,
|
||||
source: "bundled" | "managed" | "path",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
||||
return parsed.version;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
|
||||
);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
@@ -339,27 +369,55 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
const version = await probeCliBinary(bundled, "bundled");
|
||||
if (version) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
cachedCliBinaryVersion = version;
|
||||
return bundled;
|
||||
}
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
const version = await probeCliBinary(managed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = managed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return managed;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
const installed = await ensureManagedCli({
|
||||
forceInstall: existsSync(managed),
|
||||
});
|
||||
const version = await probeCliBinary(installed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = installed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return installed;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] managed CLI at ${installed} failed validation after install`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
|
||||
const onPath = findCliOnPath();
|
||||
if (onPath) {
|
||||
const version = await probeCliBinary(onPath, "path");
|
||||
if (version) {
|
||||
cachedCliBinary = onPath;
|
||||
cachedCliBinaryVersion = version;
|
||||
return onPath;
|
||||
}
|
||||
}
|
||||
|
||||
cachedCliBinary = null;
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -370,11 +428,10 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Reads the version of the currently resolved CLI binary. Cached for the
|
||||
* process lifetime — the bundled binary doesn't change after bundle time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* etc.) so callers can fail open.
|
||||
* wrong-arch bundled binary, etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
@@ -383,24 +440,7 @@ async function getCliBinaryVersion(): Promise<string | null> {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -109,6 +110,8 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
@@ -193,6 +196,16 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
|
||||
// preload can attach the values to `desktopAPI.appInfo` before any renderer
|
||||
// code reads them, ensuring the very first HTTP request from the renderer
|
||||
// already carries X-Client-Version and X-Client-OS.
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -4,6 +4,16 @@ import { app, BrowserWindow, ipcMain } from "electron";
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
|
||||
// arches would otherwise collide on the same file in the GitHub Release.
|
||||
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
|
||||
// of this pact. Pin the channel here so arm64 clients fetch
|
||||
// `latest-arm64.yml` instead of the x64 metadata.
|
||||
if (process.platform === "win32" && process.arch === "arm64") {
|
||||
autoUpdater.channel = "latest-arm64";
|
||||
}
|
||||
|
||||
const STARTUP_CHECK_DELAY_MS = 5_000;
|
||||
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
|
||||
5
apps/desktop/src/preload/index.d.ts
vendored
5
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,6 +1,11 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
appInfo: {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
// (async ipc.invoke) would race the ApiClient construction in initCore and
|
||||
// the first few HTTP requests would go out without X-Client-Version/OS.
|
||||
function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" | "unknown" } {
|
||||
try {
|
||||
const info = ipcRenderer.sendSync("app:get-info") as
|
||||
| { version: string; os: "macos" | "windows" | "linux" | "unknown" }
|
||||
| undefined;
|
||||
if (info && typeof info.version === "string" && typeof info.os === "string") return info;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Fallback: derive OS from process.platform; version unknown.
|
||||
const p = process.platform;
|
||||
const os: "macos" | "windows" | "linux" | "unknown" =
|
||||
p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
@@ -90,11 +92,28 @@ function AppContent() {
|
||||
// account switches (user A logout → user B login) should not trigger a
|
||||
// daemon restart here — daemon-manager already restarts on user change
|
||||
// via syncToken.
|
||||
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
|
||||
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const wsCount = workspaces?.length ?? 0;
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
@@ -104,32 +123,22 @@ function AppContent() {
|
||||
// warning because `switchWorkspace` is a Zustand setState that the
|
||||
// TabBar is subscribed to. useLayoutEffect flushes both renders before
|
||||
// the user sees anything, so there's no visible flicker.
|
||||
//
|
||||
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
|
||||
// the first fetch, so without this guard we'd run validation against an
|
||||
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
|
||||
// back to `workspaces[0]` once the real list arrives — losing the user's
|
||||
// last-opened workspace on every app start.
|
||||
useLayoutEffect(() => {
|
||||
if (!workspaces) return;
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
const tabStore = useTabStore.getState();
|
||||
tabStore.validateWorkspaceSlugs(validSlugs);
|
||||
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
|
||||
tabStore.switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [workspaces]);
|
||||
|
||||
// Bidirectional new-workspace overlay: visible when there are no
|
||||
// workspaces to enter, hidden as soon as one exists. Gated on
|
||||
// `workspaceListFetched` so the initial render doesn't flash the
|
||||
// overlay before the list arrives. The overlay's own `invite` type is
|
||||
// not touched here — that's an in-flight task owned by the user.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!workspaceListFetched) return;
|
||||
const { overlay, open, close } = useWindowOverlayStore.getState();
|
||||
const isEmpty = wsCount === 0;
|
||||
if (isEmpty) {
|
||||
if (!overlay) open({ type: "new-workspace" });
|
||||
} else if (overlay?.type === "new-workspace") {
|
||||
close();
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
|
||||
if (!activeWorkspaceSlug && workspaces.length > 0) {
|
||||
switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount]);
|
||||
}, [workspaces, workspaceListFetched]);
|
||||
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
// false = session started with >=1 workspace, OR we've already restarted; skip
|
||||
@@ -158,8 +167,15 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
// Pageview tracker sits at the app root so it covers every visible
|
||||
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
|
||||
// would miss the logged-out and overlay states.
|
||||
return (
|
||||
<>
|
||||
<PageviewTracker />
|
||||
{user ? <DesktopShell /> : <DesktopLoginPage />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
@@ -187,12 +203,20 @@ async function handleDaemonLogout() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "desktop", version, os }),
|
||||
[version, os],
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
@@ -123,17 +123,16 @@ export function DesktopShell() {
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
{/* Content area with inset styling */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
|
||||
* `daemonAPI` (main-process daemon state) into the page so its empty
|
||||
* state can distinguish "no runtime registered" from "runtime is on its
|
||||
* way" — without the bundled daemon's status, the page shows a
|
||||
* misleading "Run multica daemon start" hint during the few seconds
|
||||
* between page load and the daemon's first registration.
|
||||
*
|
||||
* `bootstrapping` is true while the daemon is installing, starting, or
|
||||
* already running but hasn't surfaced as a server-side runtime yet.
|
||||
* RuntimeList only shows the spinner when the runtime list is also
|
||||
* empty, so once the daemon registers (and the list fills) the flag
|
||||
* has no visible effect.
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
status.state === "installing_cli" ||
|
||||
status.state === "starting" ||
|
||||
status.state === "running";
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
* 1. Logged-out state → `/login`. No workspace context, no tabs.
|
||||
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
|
||||
* that match the equivalent web routes. Overlays are NOT tab routes on
|
||||
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
|
||||
* tab path alone would either miss them or mislabel them as "/".
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
* implementation in `apps/web/components/pageview-tracker.tsx`.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
capturePageview(path);
|
||||
}, [path]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
return "/workspaces/new";
|
||||
case "onboarding":
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
@@ -39,6 +40,7 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -110,12 +110,25 @@ export function UpdateNotification() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useImmersiveMode } from "@multica/views/platform";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
@@ -9,18 +9,21 @@ import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: renders above the tab system when the
|
||||
* user is in a pre-workspace flow (create workspace, accept invite).
|
||||
* user is in a pre-workspace flow (onboarding, create workspace, accept
|
||||
* invite).
|
||||
*
|
||||
* This component is a thin **platform shell**:
|
||||
* - Hands the window-drag strip and macOS traffic-light hiding
|
||||
* (`useImmersiveMode`) — both are platform-specific, web has neither
|
||||
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
|
||||
* doesn't leak through
|
||||
* This component is intentionally thin — just a fixed positioning shell
|
||||
* that covers the tab system. It does NOT hide traffic lights or provide
|
||||
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
|
||||
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
|
||||
* native macOS traffic lights stay visible and the page content can fill
|
||||
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
|
||||
* pre-dashboard flows and keeps platform chrome consistent across every
|
||||
* "not-in-dashboard" surface.
|
||||
*
|
||||
* All UX affordances (Back button, Log out button, welcome copy, invite
|
||||
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
|
||||
* components under `packages/views/`, so web and desktop render identical
|
||||
* content. The platform split is: UX in shared code, chrome here.
|
||||
* card) live inside the shared view components under `packages/views/`,
|
||||
* so web and desktop render identical content.
|
||||
*/
|
||||
export function WindowOverlay() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
@@ -34,8 +37,6 @@ function WindowOverlayInner() {
|
||||
const { push } = useNavigation();
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
useImmersiveMode();
|
||||
|
||||
if (!overlay) return null;
|
||||
|
||||
// Back is only meaningful when there's somewhere to go — i.e. the user
|
||||
@@ -44,42 +45,35 @@ function WindowOverlayInner() {
|
||||
const onBack = wsList.length > 0 ? close : undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
{/* Window-drag strip. Rendered as a flex *child* (not absolute
|
||||
overlay) so it owns its own 48px of real layout space — the
|
||||
prior absolute-positioned approach relied on z-index stacking
|
||||
to beat the content wrapper's no-drag, which in practice didn't
|
||||
hit-test reliably for `-webkit-app-region` on the welcome
|
||||
screen. A real flex row with nothing else in it has no such
|
||||
ambiguity: any pixel at top-48 is drag, full stop.
|
||||
|
||||
Height matches `MainTopBar` (48px) so the drag-to-grab area
|
||||
feels consistent with the rest of the app. The strip is
|
||||
invisible; macOS traffic lights would normally sit here but
|
||||
`useImmersiveMode` has hidden them for the overlay's lifetime. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-12 shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="fixed inset-0 z-50 flex flex-col overflow-auto bg-background">
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
|
||||
"Apple Garamond", Baskerville, "Times New Roman", serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import App from "./App";
|
||||
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
|
||||
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
|
||||
import "@fontsource-variable/inter";
|
||||
// Editorial serif — matches web's next/font Source_Serif_4. Loaded app-wide so
|
||||
// onboarding headings and any future editorial surface can use `font-serif`
|
||||
// (see tokens.css @theme inline). Variable font = one file covers all weights.
|
||||
import "@fontsource-variable/source-serif-4";
|
||||
import "@fontsource-variable/source-serif-4/wght-italic.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
@@ -14,11 +15,7 @@ export function DesktopLoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
<div
|
||||
className="h-[38px] shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<DragStrip />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -53,6 +54,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/onboarding") {
|
||||
overlay.open({ type: "onboarding" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
@@ -106,18 +114,32 @@ export function DesktopNavigationProvider({
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
const [pathname, setPathname] = useState(
|
||||
router?.state.location.pathname ?? "/",
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() can read URL search params.
|
||||
// Must stay in sync with TabNavigationProvider below; a partial shape
|
||||
// here (just pathname) silently broke focus-mode anchor resolution on
|
||||
// `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
search: router?.state.location.search ?? "",
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router) {
|
||||
setPathname("/");
|
||||
setLocation({ pathname: "/", search: "" });
|
||||
return;
|
||||
}
|
||||
setPathname(router.state.location.pathname);
|
||||
setLocation({
|
||||
pathname: router.state.location.pathname,
|
||||
search: router.state.location.search,
|
||||
});
|
||||
return router.subscribe((state) => {
|
||||
setPathname(state.location.pathname);
|
||||
setLocation({
|
||||
pathname: state.location.pathname,
|
||||
search: state.location.search,
|
||||
});
|
||||
});
|
||||
}, [activeTabId, router]);
|
||||
|
||||
@@ -142,8 +164,8 @@ export function DesktopNavigationProvider({
|
||||
back: () => {
|
||||
currentActiveTab()?.router.navigate(-1);
|
||||
},
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
@@ -159,7 +181,7 @@ export function DesktopNavigationProvider({
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[pathname],
|
||||
[location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -13,11 +13,11 @@ import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { ChatPage } from "@multica/views/chat";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
@@ -114,12 +114,13 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
@@ -101,6 +101,7 @@ interface TabStore {
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
chat: "MessageSquare",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
|
||||
@@ -14,7 +14,8 @@ import { create } from "zustand";
|
||||
*/
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string };
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
overlay: WindowOverlay | null;
|
||||
|
||||
@@ -169,6 +169,16 @@ Stop PostgreSQL and keep local databases:
|
||||
make db-down
|
||||
```
|
||||
|
||||
Reset only the current checkout's database (drops `POSTGRES_DB`, recreates it, re-runs all migrations). Other worktree databases are untouched.
|
||||
|
||||
```bash
|
||||
make stop
|
||||
make db-reset
|
||||
make start
|
||||
```
|
||||
|
||||
> `make db-reset` refuses to run if `DATABASE_URL` points at a remote host.
|
||||
|
||||
Wipe all local PostgreSQL data:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -53,13 +53,17 @@ make selfhost
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
@@ -70,6 +74,8 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
<Callout>
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
</Callout>
|
||||
@@ -151,14 +157,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
|
||||
Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
</Callout>
|
||||
|
||||
## Rebuilding After Updates
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
---
|
||||
|
||||
@@ -191,6 +198,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### Signup Controls (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
@@ -4,8 +4,13 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import {
|
||||
paths,
|
||||
resolvePostAuthDestination,
|
||||
useHasOnboarded,
|
||||
} from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import {
|
||||
@@ -17,14 +22,15 @@ import {
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import Link from "next/link";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const googleClientId = useConfigStore((state) => state.googleClientId);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -42,9 +48,10 @@ function LoginPageContent() {
|
||||
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
const [desktopError, setDesktopError] = useState("");
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
// (or /onboarding if the user has none). Skip this entire path when
|
||||
// the user arrived to authorize the CLI.
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
@@ -65,29 +72,33 @@ function LoginPageContent() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, qc]);
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
// Read the latest user snapshot directly — the closure's `hasOnboarded`
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
// The LoginPage view populates the workspace list cache before calling
|
||||
// onSuccess, so it's safe to read here.
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.push(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
router.push(resolvePostAuthDestination(list, onboarded));
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
@@ -163,6 +174,22 @@ function LoginPageContent() {
|
||||
: undefined
|
||||
}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
extra={
|
||||
// Web-only nudge toward the desktop app. Copy is hardcoded EN
|
||||
// for now because the login route sits outside the landing
|
||||
// group's LocaleProvider — if this page ever becomes
|
||||
// locale-aware, the strings live in positioning doc §3.3.
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Prefer the desktop app?{" "}
|
||||
<Link
|
||||
href="/download"
|
||||
onClick={() => captureDownloadIntent("login")}
|
||||
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
|
||||
>
|
||||
Download
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
72
apps/web/app/(auth)/onboarding/page.tsx
Normal file
72
apps/web/app/(auth)/onboarding/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
paths,
|
||||
resolvePostAuthDestination,
|
||||
useHasOnboarded,
|
||||
} from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboarding";
|
||||
|
||||
/**
|
||||
* Web shell for the onboarding flow. The route is the platform chrome on
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
* shows are hardcoded — nothing environmental to thread through.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
// owns the per-step width constraint internally — Welcome renders a
|
||||
// wide two-column hero, all other steps wrap themselves at max-w-xl.
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
}
|
||||
}}
|
||||
runtimeInstructions={<CliInstallInstructions />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
apps/web/app/(landing)/download/download-client.tsx
Normal file
140
apps/web/app/(landing)/download/download-client.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { LandingHeader } from "@/features/landing/components/landing-header";
|
||||
import { LandingFooter } from "@/features/landing/components/landing-footer";
|
||||
import { DownloadHero } from "@/features/landing/components/download/hero";
|
||||
import { AllPlatforms } from "@/features/landing/components/download/all-platforms";
|
||||
import { CliSection } from "@/features/landing/components/download/cli-section";
|
||||
import { CloudSection } from "@/features/landing/components/download/cloud-section";
|
||||
import { useLocale } from "@/features/landing/i18n";
|
||||
import {
|
||||
detectOS,
|
||||
type DetectResult,
|
||||
} from "@/features/landing/utils/os-detect";
|
||||
import type { LatestRelease } from "@/features/landing/utils/github-release";
|
||||
import { captureDownloadPageViewed } from "@multica/core/analytics";
|
||||
|
||||
const ALL_RELEASES_URL =
|
||||
"https://github.com/multica-ai/multica/releases";
|
||||
|
||||
export function DownloadClient({ release }: { release: LatestRelease }) {
|
||||
const [detected, setDetected] = useState<DetectResult | null>(null);
|
||||
const versionUnavailable = release.version === null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
detectOS().then((result) => {
|
||||
if (cancelled) return;
|
||||
setDetected(result);
|
||||
// Fires once per page mount after detect resolves. Carries the
|
||||
// detect outcome + version-unavailable flag so PostHog can split
|
||||
// Safari-mac-arm64 fallback rate, Intel-Mac dead-end rate, and
|
||||
// rate-limit degraded sessions. `first_detected_os/arch` is
|
||||
// $set_once'd on the person so every downstream event gains a
|
||||
// platform dimension (useful for "Android visitors who later
|
||||
// downloaded Windows" style cross-device queries once we land
|
||||
// the desktop install closure).
|
||||
captureDownloadPageViewed({
|
||||
detected_os: result.os,
|
||||
detected_arch: result.arch,
|
||||
detect_confident: result.archConfident,
|
||||
version_available: !versionUnavailable,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [versionUnavailable]);
|
||||
|
||||
const releaseHtmlUrl = release.htmlUrl ?? ALL_RELEASES_URL;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Positioning context for the dark-variant LandingHeader —
|
||||
mirrors multica-landing.tsx. The header is `absolute top-0
|
||||
inset-x-0`, so it anchors to this `relative` wrapper and
|
||||
scrolls off together with the dark hero below. Without the
|
||||
wrapper, `absolute` would escape to the initial containing
|
||||
block and read as fixed. */}
|
||||
<div className="relative">
|
||||
<LandingHeader variant="dark" />
|
||||
<DownloadHero
|
||||
detected={detected}
|
||||
assets={release.assets}
|
||||
versionUnavailable={versionUnavailable}
|
||||
version={release.version}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AllPlatforms
|
||||
assets={release.assets}
|
||||
fallbackHref={ALL_RELEASES_URL}
|
||||
version={release.version}
|
||||
detected={detected}
|
||||
/>
|
||||
<CliSection />
|
||||
<CloudSection />
|
||||
<VersionInfoFooter
|
||||
version={release.version}
|
||||
releaseHtmlUrl={releaseHtmlUrl}
|
||||
/>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionInfoFooter({
|
||||
version,
|
||||
releaseHtmlUrl,
|
||||
}: {
|
||||
version: string | null;
|
||||
releaseHtmlUrl: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.footer;
|
||||
|
||||
return (
|
||||
<section className="bg-white pb-16 text-[#0a0d12] sm:pb-20">
|
||||
<div className="mx-auto flex max-w-[920px] flex-wrap items-center gap-x-6 gap-y-2 border-t border-[#0a0d12]/8 px-4 pt-8 text-[13px] text-[#0a0d12]/60 sm:px-6 lg:px-8">
|
||||
{version ? (
|
||||
<>
|
||||
<span>
|
||||
{d.currentVersion.replace("{version}", version)}
|
||||
</span>
|
||||
<span aria-hidden className="text-[#0a0d12]/25">
|
||||
·
|
||||
</span>
|
||||
<Link
|
||||
href={releaseHtmlUrl}
|
||||
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.releaseNotes.replace("{version}", version)}
|
||||
</Link>
|
||||
<span aria-hidden className="text-[#0a0d12]/25">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{d.versionUnavailable}</span>
|
||||
<span aria-hidden className="text-[#0a0d12]/25">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={ALL_RELEASES_URL}
|
||||
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.allReleases}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
apps/web/app/(landing)/download/page.tsx
Normal file
29
apps/web/app/(landing)/download/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { fetchLatestRelease } from "@/features/landing/utils/github-release";
|
||||
import { DownloadClient } from "./download-client";
|
||||
|
||||
// Vercel ISR: the server fetch inside fetchLatestRelease carries
|
||||
// `next: { revalidate: 300 }`, which makes GitHub API cost at most
|
||||
// one request per region per 5 minutes. Page-level revalidate mirrors
|
||||
// that window so the first paint also refreshes every 5 minutes.
|
||||
export const revalidate = 300;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Download Multica",
|
||||
description:
|
||||
"Download Multica for macOS, Windows, or Linux — or install the CLI for servers and remote dev boxes.",
|
||||
openGraph: {
|
||||
title: "Download Multica",
|
||||
description:
|
||||
"Get the Multica desktop app with a bundled daemon, or install the CLI for servers and remote dev boxes.",
|
||||
url: "/download",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/download",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function DownloadPage() {
|
||||
const release = await fetchLatestRelease();
|
||||
return <DownloadClient release={release} />;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export default async function LandingLayout({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
|
||||
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} landing-light h-full overflow-x-hidden overflow-y-auto bg-white`}>
|
||||
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
|
||||
</div>
|
||||
</>
|
||||
|
||||
1
apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx
Normal file
1
apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ChatPage as default } from "@multica/views/chat";
|
||||
@@ -3,14 +3,19 @@
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
extra={
|
||||
<>
|
||||
<SearchCommand />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -10,6 +10,18 @@ const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
||||
id: "user-1",
|
||||
name: "Test",
|
||||
email: "test@multica.ai",
|
||||
avatar_url: null,
|
||||
onboarded_at: null,
|
||||
onboarding_questionnaire: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
@@ -51,30 +63,44 @@ describe("CallbackPage", () => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(undefined);
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
mockSearchParams.set("state", "next:https://evil.example");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
||||
});
|
||||
|
||||
it("honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
@@ -62,18 +62,17 @@ function CallbackContent() {
|
||||
} else {
|
||||
// Normal web flow
|
||||
loginWithGoogle(code, redirectUri)
|
||||
.then(async () => {
|
||||
.then(async (loggedInUser) => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
// URL is now the source of truth for the current workspace — the
|
||||
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
|
||||
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
|
||||
// in the first workspace's issues, or /workspaces/new for zero-workspace users.
|
||||
const [first] = wsList;
|
||||
const defaultDest = first
|
||||
? paths.workspace(first.slug).issues()
|
||||
: paths.newWorkspace();
|
||||
router.push(nextUrl || defaultDest);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
@@ -3,3 +3,44 @@
|
||||
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
|
||||
* @multica/ui/styles/base.css
|
||||
* ============================================================================= */
|
||||
|
||||
/* The landing route tree is intentionally always-light (hero/cli/cloud
|
||||
* sections use hardcoded dark/light palettes). Shared components rendered
|
||||
* inside (e.g. CloudWaitlistExpand on /download) use semantic tokens that
|
||||
* otherwise flip to dark values under the `.dark` class set by next-themes,
|
||||
* producing a palette mismatch against the hardcoded section. Re-declare
|
||||
* tokens to their light values so nested token-driven components stay in
|
||||
* lockstep with the surrounding design. */
|
||||
.landing-light,
|
||||
.landing-light * {
|
||||
color-scheme: light;
|
||||
}
|
||||
.landing-light {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--brand: oklch(0.55 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.75 0.16 85);
|
||||
--info: oklch(0.55 0.18 250);
|
||||
--priority: oklch(0.65 0.18 50);
|
||||
--scrollbar-thumb: oklch(0 0 0 / 10%);
|
||||
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter, Geist_Mono } from "next/font/google";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -39,6 +39,23 @@ const geistMono = Geist_Mono({
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
// Editorial serif used for onboarding headlines. Italic support for h1 em
|
||||
// accents (e.g. "...on one shared board."). Only loaded on routes that
|
||||
// render the font; layout-shift-prevention handled by next/font's synthetic
|
||||
// fallback metrics, same as Inter.
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
variable: "--font-serif",
|
||||
fallback: [
|
||||
"ui-serif",
|
||||
"Iowan Old Style",
|
||||
"Apple Garamond",
|
||||
"Baskerville",
|
||||
"Times New Roman",
|
||||
"serif",
|
||||
],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -89,7 +106,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
|
||||
29
apps/web/components/pageview-tracker.tsx
Normal file
29
apps/web/components/pageview-tracker.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the Next.js App Router path or query
|
||||
* string changes. Mounted once at the root so every route transition is
|
||||
* covered, including transitions into workspace-scoped subtrees.
|
||||
*
|
||||
* PostHog's own `capture_pageview: true` auto-capture is deliberately
|
||||
* disabled in `initAnalytics` so we own the event shape — this component
|
||||
* is what actually fires the event. Before this existed the acquisition
|
||||
* funnel's `/ → signup` step was empty.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const qs = searchParams?.toString();
|
||||
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||
capturePageview(url);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import packageJson from "../package.json";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "@/features/auth/auth-cookie";
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
// Legacy token in localStorage → keep this session in token mode so users who
|
||||
// logged in before the cookie-auth migration stay authed. They migrate to
|
||||
@@ -32,8 +35,20 @@ function deriveWsUrl(): string | undefined {
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
// Build-time version preferred (CI sets NEXT_PUBLIC_APP_VERSION to a git tag
|
||||
// or sha so different deploys are distinguishable in server logs); fall back
|
||||
// to the package.json version so local dev still reports something useful.
|
||||
const WEB_VERSION =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version || "dev";
|
||||
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
const cookieAuth = !hasLegacyToken();
|
||||
// Stable identity reference so downstream effects keyed on it don't see a
|
||||
// new object on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "web", version: WEB_VERSION }),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
@@ -41,7 +56,13 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
cookieAuth={cookieAuth}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
identity={identity}
|
||||
>
|
||||
{/* Suspense boundary is required by Next.js for useSearchParams in
|
||||
a client component mounted this high in the tree. */}
|
||||
<Suspense fallback={null}>
|
||||
<PageviewTracker />
|
||||
</Suspense>
|
||||
<WebNavigationProvider>{children}</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type MouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { useLocale } from "../i18n";
|
||||
import type { Locale } from "../i18n/types";
|
||||
|
||||
const MONTHS_EN = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
type ParsedDate = { year: number; month: number; day: number };
|
||||
|
||||
function parseDate(dateStr: string): ParsedDate {
|
||||
const parts = dateStr.split("-");
|
||||
return {
|
||||
year: Number(parts[0]),
|
||||
month: Number(parts[1]),
|
||||
day: Number(parts[2]),
|
||||
};
|
||||
}
|
||||
|
||||
function monthYearLabel(year: number, month: number, locale: Locale) {
|
||||
if (!year || !month) return "";
|
||||
if (locale === "zh") return `${year}\u5e74${month}\u6708`;
|
||||
return `${MONTHS_EN[month - 1]} ${year}`;
|
||||
}
|
||||
|
||||
function fullDateLabel(dateStr: string, locale: Locale) {
|
||||
const { year, month, day } = parseDate(dateStr);
|
||||
if (!year || !month || !day) return dateStr;
|
||||
if (locale === "zh") return `${year}\u5e74${month}\u6708${day}\u65e5`;
|
||||
return `${MONTHS_EN[month - 1]} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
type Release = {
|
||||
version: string;
|
||||
date: string;
|
||||
title: string;
|
||||
changes: string[];
|
||||
features?: string[];
|
||||
improvements?: string[];
|
||||
fixes?: string[];
|
||||
};
|
||||
|
||||
type MonthGroup = {
|
||||
key: string;
|
||||
year: number;
|
||||
month: number;
|
||||
entries: Release[];
|
||||
};
|
||||
|
||||
function groupByMonth(entries: readonly Release[]): MonthGroup[] {
|
||||
const groups: MonthGroup[] = [];
|
||||
for (const entry of entries) {
|
||||
const { year, month } = parseDate(entry.date);
|
||||
const key = `${year}-${month}`;
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.entries.push(entry);
|
||||
} else {
|
||||
groups.push({ key, year, month, entries: [entry] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function anchorId(version: string) {
|
||||
return `release-${version.replace(/\./g, "-")}`;
|
||||
}
|
||||
|
||||
function ChangeList({ items }: { items: string[] }) {
|
||||
return (
|
||||
@@ -21,74 +104,222 @@ function ChangeList({ items }: { items: string[] }) {
|
||||
}
|
||||
|
||||
export function ChangelogPageClient() {
|
||||
const { t } = useLocale();
|
||||
const { t, locale } = useLocale();
|
||||
const categoryLabels = t.changelog.categories;
|
||||
const entries = t.changelog.entries;
|
||||
const groups = useMemo(() => groupByMonth(entries), [entries]);
|
||||
|
||||
const [activeVersion, setActiveVersion] = useState<string>(
|
||||
entries[0]?.version ?? ""
|
||||
);
|
||||
const navLockRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length === 0) return;
|
||||
const visible = new Set<string>();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(observed) => {
|
||||
observed.forEach((e) => {
|
||||
const v = (e.target as HTMLElement).dataset.version;
|
||||
if (!v) return;
|
||||
if (e.isIntersecting) visible.add(v);
|
||||
else visible.delete(v);
|
||||
});
|
||||
// Ignore observer updates while we're programmatically scrolling
|
||||
// to a clicked target — otherwise the active indicator flickers
|
||||
// through each passing entry.
|
||||
if (navLockRef.current !== null) return;
|
||||
|
||||
const firstVisible = entries.find((r) => visible.has(r.version));
|
||||
if (firstVisible) {
|
||||
setActiveVersion(firstVisible.version);
|
||||
return;
|
||||
}
|
||||
const scrollY = window.scrollY;
|
||||
let best = entries[0]?.version ?? "";
|
||||
for (const r of entries) {
|
||||
const el = document.getElementById(anchorId(r.version));
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top + scrollY <= scrollY + 160) {
|
||||
best = r.version;
|
||||
}
|
||||
}
|
||||
setActiveVersion(best);
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
|
||||
entries.forEach((r) => {
|
||||
const el = document.getElementById(anchorId(r.version));
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [entries]);
|
||||
|
||||
const jumpTo =
|
||||
(version: string) => (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
const el = document.getElementById(anchorId(version));
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.replaceState(null, "", `#${anchorId(version)}`);
|
||||
setActiveVersion(version);
|
||||
if (navLockRef.current !== null) {
|
||||
window.clearTimeout(navLockRef.current);
|
||||
}
|
||||
navLockRef.current = window.setTimeout(() => {
|
||||
navLockRef.current = null;
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
<div className="mx-auto max-w-[1080px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<div className="lg:grid lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16">
|
||||
<aside className="hidden lg:block">
|
||||
<nav
|
||||
aria-label={t.changelog.toc}
|
||||
className="sticky top-28 max-h-[calc(100vh-8rem)] overflow-y-auto pb-8 pr-2"
|
||||
>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#0a0d12]/50">
|
||||
{t.changelog.toc}
|
||||
</h3>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{t.changelog.entries.map((release) => {
|
||||
const hasCategorized =
|
||||
release.features || release.improvements || release.fixes;
|
||||
<div className="relative mt-5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-[4px] top-7 bottom-2 w-px bg-[#0a0d12]/10"
|
||||
/>
|
||||
|
||||
return (
|
||||
<div key={release.version} className="relative">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[13px] font-semibold tabular-nums">
|
||||
v{release.version}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#0a0d12]/40">
|
||||
{release.date}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
|
||||
{release.title}
|
||||
</h2>
|
||||
<ol className="space-y-5">
|
||||
{groups.map((group) => (
|
||||
<li key={group.key}>
|
||||
<p className="ml-6 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#0a0d12]/45">
|
||||
{monthYearLabel(group.year, group.month, locale)}
|
||||
</p>
|
||||
|
||||
{hasCategorized ? (
|
||||
<div className="mt-4 space-y-5">
|
||||
{release.features && release.features.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.features}
|
||||
</h3>
|
||||
<ChangeList items={release.features} />
|
||||
</div>
|
||||
)}
|
||||
{release.improvements &&
|
||||
release.improvements.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.improvements}
|
||||
</h3>
|
||||
<ChangeList items={release.improvements} />
|
||||
</div>
|
||||
)}
|
||||
{release.fixes && release.fixes.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.fixes}
|
||||
</h3>
|
||||
<ChangeList items={release.fixes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChangeList items={release.changes} />
|
||||
)}
|
||||
<ol className="mt-1.5">
|
||||
{group.entries.map((release) => {
|
||||
const isActive =
|
||||
release.version === activeVersion;
|
||||
const { day } = parseDate(release.date);
|
||||
return (
|
||||
<li key={release.version}>
|
||||
<a
|
||||
href={`#${anchorId(release.version)}`}
|
||||
onClick={jumpTo(release.version)}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
className={[
|
||||
"group relative flex items-center gap-3 rounded-md py-1 pr-2 text-[13px] transition-colors",
|
||||
isActive
|
||||
? "text-[#0a0d12]"
|
||||
: "text-[#0a0d12]/55 hover:text-[#0a0d12]/80",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
"relative z-10 block size-[9px] shrink-0 rounded-full border transition-all duration-200",
|
||||
isActive
|
||||
? "border-[#0a0d12] bg-[#0a0d12] ring-4 ring-[#0a0d12]/8"
|
||||
: "border-[#0a0d12]/25 bg-white group-hover:border-[#0a0d12]/60",
|
||||
].join(" ")}
|
||||
/>
|
||||
<span
|
||||
className={[
|
||||
"w-[1.25rem] shrink-0 text-right tabular-nums",
|
||||
isActive
|
||||
? "font-semibold"
|
||||
: "font-medium",
|
||||
].join(" ")}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
<span className="tabular-nums text-[11px] text-[#0a0d12]/35">
|
||||
v{release.version}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="mx-auto min-w-0 max-w-[720px] lg:mx-0">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{entries.map((release) => {
|
||||
const hasCategorized =
|
||||
release.features || release.improvements || release.fixes;
|
||||
return (
|
||||
<section
|
||||
key={release.version}
|
||||
id={anchorId(release.version)}
|
||||
data-version={release.version}
|
||||
className="relative scroll-mt-28"
|
||||
>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[13px] font-semibold tabular-nums">
|
||||
v{release.version}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#0a0d12]/40">
|
||||
{fullDateLabel(release.date, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
|
||||
{release.title}
|
||||
</h2>
|
||||
|
||||
{hasCategorized ? (
|
||||
<div className="mt-4 space-y-5">
|
||||
{release.features && release.features.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.features}
|
||||
</h3>
|
||||
<ChangeList items={release.features} />
|
||||
</div>
|
||||
)}
|
||||
{release.improvements &&
|
||||
release.improvements.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.improvements}
|
||||
</h3>
|
||||
<ChangeList items={release.improvements} />
|
||||
</div>
|
||||
)}
|
||||
{release.fixes && release.fixes.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.fixes}
|
||||
</h3>
|
||||
<ChangeList items={release.fixes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChangeList items={release.changes} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
239
apps/web/features/landing/components/download/all-platforms.tsx
Normal file
239
apps/web/features/landing/components/download/all-platforms.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
captureDownloadInitiated,
|
||||
type DownloadInitiatedPayload,
|
||||
} from "@multica/core/analytics";
|
||||
import { useLocale } from "../../i18n";
|
||||
import type { DetectResult } from "../../utils/os-detect";
|
||||
import type { DownloadAssets } from "../../utils/parse-release-assets";
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from "./os-icons";
|
||||
|
||||
type Platform = DownloadInitiatedPayload["platform"];
|
||||
type Arch = DownloadInitiatedPayload["arch"];
|
||||
type Format = DownloadInitiatedPayload["format"];
|
||||
|
||||
interface Props {
|
||||
assets: DownloadAssets;
|
||||
/** Link to GitHub releases page, used when individual asset URLs
|
||||
* couldn't be resolved (API down / parse failure). */
|
||||
fallbackHref: string;
|
||||
/** Release tag (e.g. "v0.2.13"); null on fetch failure. */
|
||||
version: string | null;
|
||||
/** Current OS/arch guess. Used only to compute `matched_detect` on
|
||||
* the download_initiated event — the row UI itself is static. */
|
||||
detected: DetectResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full matrix of platform + arch + format links. Always visible
|
||||
* regardless of which platform the Hero resolved to — lets power
|
||||
* users grab any build directly.
|
||||
*/
|
||||
export function AllPlatforms({
|
||||
assets,
|
||||
fallbackHref,
|
||||
version,
|
||||
detected,
|
||||
}: Props) {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.allPlatforms;
|
||||
|
||||
const trackClick = (platform: Platform, arch: Arch, format: Format) => {
|
||||
if (!version) return;
|
||||
captureDownloadInitiated({
|
||||
platform,
|
||||
arch,
|
||||
format,
|
||||
version,
|
||||
// Manual pick from the matrix — Hero is the primary CTA.
|
||||
primary_cta: false,
|
||||
// True only when the row matches what we guessed client-side.
|
||||
// Lets us measure detect accuracy from the miss rate on this
|
||||
// event alone (no need to cross-join to download_page_viewed).
|
||||
matched_detect:
|
||||
!!detected &&
|
||||
detected.os === platform &&
|
||||
detected.arch === arch,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="all-platforms"
|
||||
className="bg-white py-20 text-[#0a0d12] sm:py-24"
|
||||
>
|
||||
<div className="mx-auto max-w-[920px] px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
|
||||
{d.title}
|
||||
</h2>
|
||||
|
||||
<div className="mt-10 overflow-hidden rounded-2xl border border-[#0a0d12]/10">
|
||||
<Row
|
||||
icon={<AppleIcon className="text-[#0a0d12]" />}
|
||||
label={d.macLabel}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatDmg,
|
||||
href: assets.macArm64Dmg,
|
||||
onClick: () => trackClick("mac", "arm64", "dmg"),
|
||||
},
|
||||
{
|
||||
label: d.formatZip,
|
||||
href: assets.macArm64Zip,
|
||||
onClick: () => trackClick("mac", "arm64", "zip"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<WindowsIcon className="text-[#0a0d12]" />}
|
||||
label={d.winX64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatExe,
|
||||
href: assets.winX64Exe,
|
||||
onClick: () => trackClick("windows", "x64", "exe"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<WindowsIcon className="text-[#0a0d12]" />}
|
||||
label={d.winArm64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatExe,
|
||||
href: assets.winArm64Exe,
|
||||
onClick: () => trackClick("windows", "arm64", "exe"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<LinuxIcon className="text-[#0a0d12]" />}
|
||||
label={d.linuxX64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatAppImage,
|
||||
href: assets.linuxAmd64AppImage,
|
||||
onClick: () => trackClick("linux", "x64", "appimage"),
|
||||
},
|
||||
{
|
||||
label: d.formatDeb,
|
||||
href: assets.linuxAmd64Deb,
|
||||
onClick: () => trackClick("linux", "x64", "deb"),
|
||||
},
|
||||
{
|
||||
label: d.formatRpm,
|
||||
href: assets.linuxAmd64Rpm,
|
||||
onClick: () => trackClick("linux", "x64", "rpm"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<LinuxIcon className="text-[#0a0d12]" />}
|
||||
label={d.linuxArm64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatAppImage,
|
||||
href: assets.linuxArm64AppImage,
|
||||
onClick: () => trackClick("linux", "arm64", "appimage"),
|
||||
},
|
||||
{
|
||||
label: d.formatDeb,
|
||||
href: assets.linuxArm64Deb,
|
||||
onClick: () => trackClick("linux", "arm64", "deb"),
|
||||
},
|
||||
{
|
||||
label: d.formatRpm,
|
||||
href: assets.linuxArm64Rpm,
|
||||
onClick: () => trackClick("linux", "arm64", "rpm"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.intelNote}</p>
|
||||
|
||||
{isFallbackNeeded(assets) ? (
|
||||
<p className="mt-2 text-[13px] text-[#0a0d12]/60">
|
||||
<Link
|
||||
href={fallbackHref}
|
||||
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t.download.footer.allReleases}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Row
|
||||
// ------------------------------------------------------------
|
||||
|
||||
interface RowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
formats: {
|
||||
label: string;
|
||||
href: string | undefined;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
unavailable: string;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
function Row({ icon, label, formats, unavailable, isLast }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-6 gap-y-3 px-6 py-5 ${isLast ? "" : "border-b border-[#0a0d12]/8"}`}
|
||||
>
|
||||
<div className="flex min-w-[220px] items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#0a0d12]/5">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-[14.5px] font-medium">{label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{formats.map((f) =>
|
||||
f.href ? (
|
||||
<a
|
||||
key={f.label}
|
||||
href={f.href}
|
||||
onClick={f.onClick}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#0a0d12]/12 bg-white px-3 py-1.5 text-[13px] font-medium transition-colors hover:border-[#0a0d12]/30 hover:bg-[#0a0d12]/5"
|
||||
>
|
||||
{f.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
key={f.label}
|
||||
aria-disabled="true"
|
||||
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-lg border border-[#0a0d12]/8 bg-[#0a0d12]/5 px-3 py-1.5 text-[13px] text-[#0a0d12]/40"
|
||||
title={unavailable}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ten desktop artifacts are expected per release (two Mac,
|
||||
// two Windows, six Linux). If any are missing, surface the GitHub
|
||||
// fallback link so users on an orphaned row have a way out.
|
||||
const EXPECTED_ASSET_COUNT = 10;
|
||||
|
||||
function isFallbackNeeded(assets: DownloadAssets): boolean {
|
||||
return Object.values(assets).filter(Boolean).length < EXPECTED_ASSET_COUNT;
|
||||
}
|
||||
108
apps/web/features/landing/components/download/cli-section.tsx
Normal file
108
apps/web/features/landing/components/download/cli-section.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Terminal } from "lucide-react";
|
||||
import { useLocale } from "../../i18n";
|
||||
|
||||
const INSTALL_CMD =
|
||||
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
|
||||
const SETUP_CMD = "multica setup";
|
||||
|
||||
/**
|
||||
* Scenario-first CLI section. Copy leans into servers / remote dev
|
||||
* boxes / headless setups rather than positioning CLI as a
|
||||
* lightweight Desktop. Two copy-and-paste command blocks.
|
||||
*/
|
||||
export function CliSection() {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.cli;
|
||||
|
||||
return (
|
||||
<section id="cli" className="bg-[#f7f7f5] py-20 text-[#0a0d12] sm:py-24">
|
||||
<div className="mx-auto max-w-[820px] px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
|
||||
{d.title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[620px] text-[15px] leading-7 text-[#0a0d12]/72">
|
||||
{d.sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-5">
|
||||
<CommandBlock
|
||||
label={d.installLabel}
|
||||
cmd={INSTALL_CMD}
|
||||
copyLabel={d.copyLabel}
|
||||
copiedLabel={d.copiedLabel}
|
||||
/>
|
||||
<CommandBlock
|
||||
label={d.startLabel}
|
||||
cmd={SETUP_CMD}
|
||||
copyLabel={d.copyLabel}
|
||||
copiedLabel={d.copiedLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.sshNote}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandBlock({
|
||||
label,
|
||||
cmd,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
}: {
|
||||
label: string;
|
||||
cmd: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard may be unavailable (insecure context) — silent no-op
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-[12px] font-medium uppercase tracking-[0.08em] text-[#0a0d12]/55">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-[#0a0d12]/10 bg-white px-4 py-3 font-mono text-[13.5px]">
|
||||
<Terminal
|
||||
className="mt-0.5 size-4 shrink-0 text-[#0a0d12]/55"
|
||||
aria-hidden
|
||||
/>
|
||||
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
|
||||
{cmd}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium text-[#0a0d12]/70 transition-colors hover:bg-[#0a0d12]/5 hover:text-[#0a0d12]"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="size-3.5" />
|
||||
{copiedLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="size-3.5" />
|
||||
{copyLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CloudWaitlistExpand } from "@multica/views/onboarding";
|
||||
import { useLocale } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Cloud runtime waitlist — thin wrapper around the shared
|
||||
* CloudWaitlistExpand form with a download-page-appropriate title
|
||||
* and subtitle. Submission persists via `joinCloudWaitlist` inside
|
||||
* the child; the submitted flag here only prevents double-submits
|
||||
* for the lifetime of the page.
|
||||
*/
|
||||
export function CloudSection() {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.cloud;
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="bg-white py-20 text-[#0a0d12] sm:py-24">
|
||||
<div className="mx-auto max-w-[720px] px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
|
||||
{d.title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[560px] text-[15px] leading-7 text-[#0a0d12]/72">
|
||||
{d.sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<CloudWaitlistExpand
|
||||
submitted={submitted}
|
||||
onSubmitted={() => setSubmitted(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
285
apps/web/features/landing/components/download/hero.tsx
Normal file
285
apps/web/features/landing/components/download/hero.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Download } from "lucide-react";
|
||||
import {
|
||||
captureDownloadInitiated,
|
||||
type DownloadInitiatedPayload,
|
||||
} from "@multica/core/analytics";
|
||||
import { useLocale } from "../../i18n";
|
||||
import type { DetectResult } from "../../utils/os-detect";
|
||||
import type { DownloadAssets } from "../../utils/parse-release-assets";
|
||||
import { heroButtonClassName } from "../shared";
|
||||
|
||||
interface Props {
|
||||
detected: DetectResult | null;
|
||||
assets: DownloadAssets;
|
||||
/** True when the GitHub API fetch failed; disables all CTAs and
|
||||
* surfaces a "version unavailable" line. */
|
||||
versionUnavailable: boolean;
|
||||
/** Release tag (e.g. "v0.2.13"). Null when version lookup failed —
|
||||
* in that case CTAs are already disabled, no tracking fires. */
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top CTA section. Server-renders a generic "Choose your platform"
|
||||
* placeholder (SEO + flash-before-hydration), then swaps to a
|
||||
* platform-specific CTA once the client detection resolves.
|
||||
*/
|
||||
export function DownloadHero({
|
||||
detected,
|
||||
assets,
|
||||
versionUnavailable,
|
||||
version,
|
||||
}: Props) {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.hero;
|
||||
|
||||
const content = resolveContent(detected, assets, versionUnavailable, d);
|
||||
|
||||
// Fires download_initiated on primary CTA click. `primary_cta: true`
|
||||
// identifies the hero-recommended path; `matched_detect: true` is
|
||||
// always true here by construction (the primary is computed from
|
||||
// the detect result). All Platforms rows below emit with
|
||||
// matched_detect=false when the user overrides.
|
||||
const onPrimaryClick = (tracking: HeroTracking | undefined) => {
|
||||
if (!tracking || !version) return;
|
||||
captureDownloadInitiated({
|
||||
...tracking,
|
||||
version,
|
||||
primary_cta: true,
|
||||
matched_detect: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[#05070b] text-white">
|
||||
<BackdropGradient />
|
||||
<div className="relative z-10 mx-auto max-w-[1120px] px-4 pb-24 pt-32 text-center sm:px-6 sm:pt-40 lg:px-8 lg:pb-28">
|
||||
<h1 className="mx-auto max-w-[880px] font-[family-name:var(--font-serif)] text-[3rem] leading-[1.02] tracking-[-0.035em] drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4rem] lg:text-[5rem]">
|
||||
{content.title}
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-[620px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
|
||||
{content.sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
{content.primary ? (
|
||||
<PrimaryCta
|
||||
href={content.primary.href}
|
||||
disabled={content.primary.disabled}
|
||||
onClick={() => onPrimaryClick(content.primary?.tracking)}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{content.primary.label}
|
||||
{!content.primary.disabled && (
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
)}
|
||||
</PrimaryCta>
|
||||
) : null}
|
||||
{content.alt ? (
|
||||
<Link
|
||||
href={content.alt.href}
|
||||
className={heroButtonClassName("ghost")}
|
||||
onClick={() => onPrimaryClick(content.alt?.tracking)}
|
||||
>
|
||||
{content.alt.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{content.hint ? (
|
||||
<p className="mx-auto mt-5 max-w-[520px] text-[13px] text-white/64">
|
||||
{content.hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{versionUnavailable ? (
|
||||
<p className="mx-auto mt-6 max-w-[520px] text-[12px] uppercase tracking-[0.14em] text-white/50">
|
||||
{t.download.footer.versionUnavailable}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Content resolver — maps (detect, assets) → CTA props
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type HeroTracking = Pick<
|
||||
DownloadInitiatedPayload,
|
||||
"platform" | "arch" | "format"
|
||||
>;
|
||||
|
||||
interface HeroContent {
|
||||
title: string;
|
||||
sub: string;
|
||||
primary?: {
|
||||
href: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
tracking?: HeroTracking;
|
||||
};
|
||||
alt?: { href: string; label: string; tracking?: HeroTracking };
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
type HeroDict = ReturnType<typeof useLocale>["t"]["download"]["hero"];
|
||||
|
||||
function resolveContent(
|
||||
detected: DetectResult | null,
|
||||
assets: DownloadAssets,
|
||||
versionUnavailable: boolean,
|
||||
d: HeroDict,
|
||||
): HeroContent {
|
||||
// Before hydration resolves, render a neutral prompt. Same copy
|
||||
// also catches `os === "unknown"`.
|
||||
if (!detected || detected.os === "unknown") {
|
||||
return { title: d.unknown.title, sub: d.unknown.sub };
|
||||
}
|
||||
|
||||
if (detected.os === "mac") {
|
||||
// Only Chromium high-entropy returns arch confidently. Safari
|
||||
// always reports Intel even on Apple Silicon, so we treat
|
||||
// "non-confident" as arm64 + add a small Intel disclaimer.
|
||||
if (detected.arch === "x64" && detected.archConfident) {
|
||||
return {
|
||||
title: d.macIntel.title,
|
||||
sub: d.macIntel.sub,
|
||||
primary: {
|
||||
href: "#cli",
|
||||
label: d.macIntel.disabledCta,
|
||||
disabled: true,
|
||||
},
|
||||
hint: d.macIntel.intelHint,
|
||||
};
|
||||
}
|
||||
const dmg = assets.macArm64Dmg;
|
||||
const zip = assets.macArm64Zip;
|
||||
return {
|
||||
title: d.macArm64.title,
|
||||
sub: d.macArm64.sub,
|
||||
primary: dmg
|
||||
? {
|
||||
href: dmg,
|
||||
label: d.macArm64.primary,
|
||||
disabled: false,
|
||||
tracking: { platform: "mac", arch: "arm64", format: "dmg" },
|
||||
}
|
||||
: versionUnavailable
|
||||
? { href: "#", label: d.macArm64.primary, disabled: true }
|
||||
: undefined,
|
||||
alt: zip
|
||||
? {
|
||||
href: zip,
|
||||
label: d.macArm64.altZip,
|
||||
tracking: { platform: "mac", arch: "arm64", format: "zip" },
|
||||
}
|
||||
: undefined,
|
||||
hint: detected.archConfident ? undefined : d.safariMacHint,
|
||||
};
|
||||
}
|
||||
|
||||
if (detected.os === "windows") {
|
||||
// Trust arch whenever the UA hints at it (even non-confident);
|
||||
// Windows-on-ARM can still run x64 via emulation so this is low
|
||||
// risk either way. Surface the arch-fallback hint when we're
|
||||
// guessing so users on uncommon setups know to scroll down.
|
||||
const isArm = detected.arch === "arm64";
|
||||
const copy = isArm ? d.winArm64 : d.winX64;
|
||||
const url = isArm ? assets.winArm64Exe : assets.winX64Exe;
|
||||
return {
|
||||
title: copy.title,
|
||||
sub: copy.sub,
|
||||
primary: url
|
||||
? {
|
||||
href: url,
|
||||
label: copy.primary,
|
||||
disabled: false,
|
||||
tracking: {
|
||||
platform: "windows",
|
||||
arch: isArm ? "arm64" : "x64",
|
||||
format: "exe",
|
||||
},
|
||||
}
|
||||
: versionUnavailable
|
||||
? { href: "#", label: copy.primary, disabled: true }
|
||||
: undefined,
|
||||
hint: detected.archConfident ? undefined : d.archFallbackHint,
|
||||
};
|
||||
}
|
||||
|
||||
// Linux — same principle: trust the arm64 signal, surface a hint
|
||||
// when we're not confident. Linux ARM has no binary emulation so
|
||||
// the hint matters more here than on Windows.
|
||||
const isArmLinux = detected.arch === "arm64";
|
||||
const primaryUrl = isArmLinux
|
||||
? assets.linuxArm64AppImage
|
||||
: assets.linuxAmd64AppImage;
|
||||
return {
|
||||
title: d.linux.title,
|
||||
sub: d.linux.sub,
|
||||
primary: primaryUrl
|
||||
? {
|
||||
href: primaryUrl,
|
||||
label: d.linux.primary,
|
||||
disabled: false,
|
||||
tracking: {
|
||||
platform: "linux",
|
||||
arch: isArmLinux ? "arm64" : "x64",
|
||||
format: "appimage",
|
||||
},
|
||||
}
|
||||
: versionUnavailable
|
||||
? { href: "#", label: d.linux.primary, disabled: true }
|
||||
: undefined,
|
||||
alt: { href: "#all-platforms", label: d.linux.altFormats },
|
||||
hint: detected.archConfident ? undefined : d.archFallbackHint,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Pieces
|
||||
// ------------------------------------------------------------
|
||||
|
||||
function PrimaryCta({
|
||||
href,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
disabled: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<span
|
||||
aria-disabled="true"
|
||||
className="inline-flex cursor-not-allowed items-center justify-center gap-2 rounded-[12px] border border-white/15 bg-white/8 px-5 py-3 text-[14px] font-semibold text-white/60"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} onClick={onClick} className={heroButtonClassName("solid")}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function BackdropGradient() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 70% 50% at 50% 0%, rgba(80,120,255,0.18), transparent 60%), radial-gradient(ellipse 50% 40% at 50% 80%, rgba(255,90,90,0.08), transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
apps/web/features/landing/components/download/os-icons.tsx
Normal file
54
apps/web/features/landing/components/download/os-icons.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Inline SVG marks for macOS / Windows / Linux.
|
||||
* Lucide lacks real Apple / Tux marks, and the download page needs
|
||||
* the recognizable brand glyphs next to platform rows. Kept as
|
||||
* minimal monochrome outlines so they inherit currentColor.
|
||||
*/
|
||||
|
||||
type IconProps = React.SVGProps<SVGSVGElement> & { size?: number };
|
||||
|
||||
export function AppleIcon({ size = 18, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M16.37 12.8c.02-1.9 1.56-2.83 1.63-2.87-.89-1.3-2.28-1.48-2.77-1.5-1.18-.12-2.3.69-2.9.69-.6 0-1.52-.68-2.5-.66-1.28.02-2.47.74-3.13 1.88-1.33 2.3-.34 5.7.96 7.57.63.92 1.38 1.94 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.14 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.1 1.04-2.15-.02-.01-2-.77-2.02-3.05-.02-1.9 1.55-2.81 1.63-2.87zm-2.05-5.24c.52-.63.88-1.52.78-2.4-.75.03-1.66.5-2.2 1.12-.48.55-.9 1.44-.79 2.32.84.06 1.69-.42 2.21-1.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsIcon({ size = 18, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M3 5.5 10.5 4.5v6.75H3V5.5Zm0 7.25h7.5v6.75L3 18.5v-5.75Zm8.75-8.4L21 3v9H11.75V4.35ZM11.75 12h9.25v9L11.75 19.65V12Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinuxIcon({ size = 18, ...props }: IconProps) {
|
||||
// Simplified Tux silhouette — round head + body.
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.3 2.3.8 3.2-.7.7-1.3 1.8-1.6 3-.4 1.4-.7 3.3-1.8 4.4-.6.6-1 .9-1 1.6 0 .9.8 1.3 2 1.6 1.5.3 2.6.1 3.6-.3.6-.2 1.3-.4 2-.4s1.4.2 2 .4c1 .4 2.1.6 3.6.3 1.2-.3 2-.7 2-1.6 0-.7-.4-1-1-1.6-1.1-1.1-1.4-3-1.8-4.4-.3-1.2-.9-2.3-1.6-3 .5-.9.8-2 .8-3.2 0-2.7-1.6-4.6-4-4.6Zm-1.5 5.2c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm3 0c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm-3 2.6c.7.5 1.5.8 1.5.8s.8-.3 1.5-.8c0 .6-.7 1-1.5 1s-1.5-.4-1.5-1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -701,14 +701,9 @@ const mockUsageData = USAGE_SEEDS.map((s, i) => ({
|
||||
|
||||
/* Heatmap color helper — same as real ActivityHeatmap */
|
||||
function getHeatmapColor(level: number): string {
|
||||
const colors = [
|
||||
"var(--color-muted, hsl(var(--muted)))",
|
||||
"hsl(var(--chart-3) / 0.3)",
|
||||
"hsl(var(--chart-3) / 0.5)",
|
||||
"hsl(var(--chart-3) / 0.75)",
|
||||
"hsl(var(--chart-3) / 1)",
|
||||
];
|
||||
return colors[level] ?? colors[0]!;
|
||||
if (level === 0) return "var(--color-muted)";
|
||||
const opacities = ["25%", "45%", "68%", "90%"];
|
||||
return `color-mix(in oklch, var(--color-foreground) ${opacities[level - 1]}, transparent)`;
|
||||
}
|
||||
|
||||
/* Generate heatmap cells — simplified version of real ActivityHeatmap */
|
||||
@@ -766,7 +761,7 @@ function DailyCostBars({ data }: { data: typeof mockUsageData }) {
|
||||
width={8}
|
||||
height={Math.max(h, 2)}
|
||||
rx={1}
|
||||
fill="hsl(var(--chart-1))"
|
||||
fill="var(--color-chart-1)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
@@ -71,6 +72,11 @@ export function LandingFooter() {
|
||||
{...(link.href.startsWith("http")
|
||||
? { target: "_blank", rel: "noreferrer" }
|
||||
: {})}
|
||||
onClick={
|
||||
link.href === "/download"
|
||||
? () => captureDownloadIntent("landing_footer")
|
||||
: undefined
|
||||
}
|
||||
className="text-[14px] text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Download } from "lucide-react";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
@@ -42,25 +44,11 @@ export function LandingHero() {
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/multica-ai/multica/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="/download"
|
||||
className={heroButtonClassName("ghost")}
|
||||
onClick={() => captureDownloadIntent("landing_hero")}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t.hero.downloadDesktop}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceListOptions } from "@multica/core/workspace";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths";
|
||||
|
||||
/**
|
||||
* Client-side fallback redirect for authenticated visitors on the landing page.
|
||||
@@ -16,7 +16,7 @@ import { paths } from "@multica/core/paths";
|
||||
* login* — before the user has ever visited a workspace — the cookie is
|
||||
* absent, so the proxy falls through to the landing page. This component
|
||||
* covers that gap: once auth is resolved and the workspace list has loaded,
|
||||
* push the user into their workspace (or /workspaces/new if they have none).
|
||||
* push the user into their workspace (or /onboarding if they have none).
|
||||
*
|
||||
* Renders nothing. Uses `router.replace` so the landing page never enters
|
||||
* browser history for authenticated users.
|
||||
@@ -25,21 +25,17 @@ export function RedirectIfAuthenticated() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
const { data: list } = useQuery({
|
||||
const { data: list = [], isFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || !list) return;
|
||||
const [first] = list;
|
||||
if (!first) {
|
||||
router.replace(paths.newWorkspace());
|
||||
return;
|
||||
}
|
||||
router.replace(paths.workspace(first.slug).issues());
|
||||
}, [isLoading, user, list, router]);
|
||||
if (isLoading || !user || !isFetched) return;
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
}, [isLoading, user, isFetched, list, hasOnboarded, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import { en } from "./en";
|
||||
import { zh } from "./zh";
|
||||
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { createEnDict } from "./en";
|
||||
import { createZhDict } from "./zh";
|
||||
import type { LandingDict, Locale } from "./types";
|
||||
|
||||
const dictionaries: Record<Locale, LandingDict> = { en, zh };
|
||||
const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict> = {
|
||||
en: createEnDict,
|
||||
zh: createZhDict,
|
||||
};
|
||||
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
@@ -26,6 +30,11 @@ export function LocaleProvider({
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
const allowSignup = useConfigStore((state) => state.allowSignup);
|
||||
const t = useMemo(
|
||||
() => dictionaryFactories[locale](allowSignup),
|
||||
[allowSignup, locale],
|
||||
);
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
@@ -34,7 +43,7 @@ export function LocaleProvider({
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{ locale, t: dictionaries[locale], setLocale }}
|
||||
value={{ locale, t, setLocale }}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const en: LandingDict = {
|
||||
export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
return {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
@@ -122,8 +121,8 @@ export const en: LandingDict = {
|
||||
headlineFaded: "in the next hour.",
|
||||
steps: [
|
||||
{
|
||||
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
|
||||
description: ALLOW_SIGNUP
|
||||
title: allowSignup ? "Sign up & create your workspace" : "Login to your workspace",
|
||||
description: allowSignup
|
||||
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
|
||||
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
|
||||
},
|
||||
@@ -227,7 +226,7 @@ export const en: LandingDict = {
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "How it Works", href: "#how-it-works" },
|
||||
{ label: "Changelog", href: "/changelog" },
|
||||
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
|
||||
{ label: "Download", href: "/download" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
@@ -276,12 +275,72 @@ export const en: LandingDict = {
|
||||
changelog: {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
toc: "All releases",
|
||||
categories: {
|
||||
features: "New Features",
|
||||
improvements: "Improvements",
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
title: "Local Skills, LaTeX, Focus Mode & Orphan-Task Recovery",
|
||||
changes: [],
|
||||
features: [
|
||||
"Import runtime local Skills into the workspace as first-class artifacts",
|
||||
"Orphan-task recovery — abandoned agent runs auto-retry, with manual rerun as fallback",
|
||||
"LaTeX rendering in issues, comments and chat",
|
||||
"Chat Focus mode — share the page you're on as conversation context",
|
||||
],
|
||||
improvements: [
|
||||
"Sub-issue `status_changed` events no longer spam parent-issue subscribers",
|
||||
"Multi-arch Docker release images built natively per-arch (no QEMU)",
|
||||
"Pin sidebar derives fields client-side for snappier reorders",
|
||||
"Expanded reserved-slug list so new slugs can't collide with product routes",
|
||||
],
|
||||
fixes: [
|
||||
"Gemini runtime model list now includes Gemini 3 and CLI aliases",
|
||||
"Chat focus button disabled on pages without an anchor",
|
||||
"Onboarding pin sync, welcome layout and runtime bootstrap state",
|
||||
"`install.ps1` OS architecture detection hardened for more Windows setups",
|
||||
"`/download` falls back to the previous release within a 1h freshness window",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
title: "Desktop Cross-Platform Packaging, CLI Self-Update & Board Pagination",
|
||||
changes: [],
|
||||
features: [
|
||||
"Desktop app cross-platform packaging — macOS, Windows, and Linux artifacts from a single release pipeline",
|
||||
"`multica update` self-update command — upgrade the CLI and local daemon without reinstalling",
|
||||
"Issue board paginates every status column, not only Done — large backlogs stay responsive",
|
||||
],
|
||||
fixes: [
|
||||
"Workspace isolation enforced end-to-end for agent execution on the local daemon (security)",
|
||||
"Windows daemon stays alive after the terminal closes, so background agents keep running",
|
||||
"Board cards render their description preview again — list queries no longer strip the description field",
|
||||
"OpenClaw agent runtime now reads the real model from agent metadata instead of falling back to a default",
|
||||
"Comment Markdown preserved end-to-end — the HTML sanitizer that was stripping formatting has been removed",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Per-Agent Models, Kimi Runtime & Self-Host Auth",
|
||||
changes: [],
|
||||
features: [
|
||||
"Per-agent `model` field with a provider-aware dropdown — pick the LLM model for each agent from the UI or via `multica agent create/update --model`, with live discovery from each runtime's CLI",
|
||||
"Kimi CLI as a new agent runtime (Moonshot AI's `kimi-cli` over ACP), with model selection, auto-approved tool permissions, and streaming tool-call rendering",
|
||||
"Expand toggle on inline comment and reply editors for composing long text",
|
||||
],
|
||||
fixes: [
|
||||
"Posting the result comment is now an explicit, numbered step in agent workflows so final replies reach the issue instead of terminal output",
|
||||
"Agent live status card no longer leaks across issues when switching via Cmd+K",
|
||||
"Self-hosted session cookies honor the `FRONTEND_ORIGIN` scheme — plain-HTTP deployments stop silently dropping cookies, and `COOKIE_DOMAIN=<ip>` now falls back to host-only with a warning instead of breaking login",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
@@ -692,4 +751,80 @@ export const en: LandingDict = {
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon · bundled daemon, zero setup",
|
||||
primary: "Download (.dmg)",
|
||||
altZip: "or download .zip",
|
||||
},
|
||||
macIntel: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon required — Intel Macs not yet supported.",
|
||||
disabledCta: "Apple Silicon required",
|
||||
intelHint:
|
||||
"On an Intel Mac? Use the CLI below — it runs the same daemon.",
|
||||
},
|
||||
winX64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "Bundled daemon, zero setup",
|
||||
primary: "Download (.exe)",
|
||||
},
|
||||
winArm64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "ARM · bundled daemon, zero setup",
|
||||
primary: "Download (.exe)",
|
||||
},
|
||||
linux: {
|
||||
title: "Multica for Linux",
|
||||
sub: "Bundled daemon, zero setup",
|
||||
primary: "Download AppImage",
|
||||
altFormats: "or .deb / .rpm",
|
||||
},
|
||||
unknown: {
|
||||
title: "Choose your platform",
|
||||
sub: "All installers are listed below.",
|
||||
},
|
||||
safariMacHint: "On an Intel Mac? Use the CLI below.",
|
||||
archFallbackHint: "Wrong architecture? See all formats below.",
|
||||
},
|
||||
allPlatforms: {
|
||||
title: "All platforms",
|
||||
macLabel: "macOS · Apple Silicon",
|
||||
winX64Label: "Windows · x64",
|
||||
winArm64Label: "Windows · ARM64",
|
||||
linuxX64Label: "Linux · x64",
|
||||
linuxArm64Label: "Linux · ARM64",
|
||||
formatDmg: ".dmg",
|
||||
formatZip: ".zip",
|
||||
formatExe: ".exe",
|
||||
formatAppImage: ".AppImage",
|
||||
formatDeb: ".deb",
|
||||
formatRpm: ".rpm",
|
||||
intelNote:
|
||||
"Apple Silicon only — Intel Macs not supported in this release.",
|
||||
unavailable: "Not available",
|
||||
},
|
||||
cli: {
|
||||
title: "Prefer the CLI?",
|
||||
sub: "For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal.",
|
||||
installLabel: "Install",
|
||||
startLabel: "Start daemon",
|
||||
sshNote: "Already on a server? Same commands work over SSH.",
|
||||
copyLabel: "Copy",
|
||||
copiedLabel: "Copied",
|
||||
},
|
||||
cloud: {
|
||||
title: "Cloud runtime (waitlist)",
|
||||
sub: "We’ll host the runtime for you. Not live yet — leave your email to be notified.",
|
||||
},
|
||||
footer: {
|
||||
releaseNotes: "What’s new in {version}",
|
||||
allReleases: "View all releases",
|
||||
currentVersion: "Current version: {version}",
|
||||
versionUnavailable: "Version unavailable — check GitHub",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export type LandingDict = {
|
||||
changelog: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
toc: string;
|
||||
categories: {
|
||||
features: string;
|
||||
improvements: string;
|
||||
@@ -101,4 +102,63 @@ export type LandingDict = {
|
||||
fixes?: string[];
|
||||
}[];
|
||||
};
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: string;
|
||||
sub: string;
|
||||
primary: string;
|
||||
altZip: string;
|
||||
};
|
||||
macIntel: {
|
||||
title: string;
|
||||
sub: string;
|
||||
disabledCta: string;
|
||||
intelHint: string;
|
||||
};
|
||||
winX64: { title: string; sub: string; primary: string };
|
||||
winArm64: { title: string; sub: string; primary: string };
|
||||
linux: {
|
||||
title: string;
|
||||
sub: string;
|
||||
primary: string;
|
||||
altFormats: string;
|
||||
};
|
||||
unknown: { title: string; sub: string };
|
||||
safariMacHint: string;
|
||||
archFallbackHint: string;
|
||||
};
|
||||
allPlatforms: {
|
||||
title: string;
|
||||
macLabel: string;
|
||||
winX64Label: string;
|
||||
winArm64Label: string;
|
||||
linuxX64Label: string;
|
||||
linuxArm64Label: string;
|
||||
formatDmg: string;
|
||||
formatZip: string;
|
||||
formatExe: string;
|
||||
formatAppImage: string;
|
||||
formatDeb: string;
|
||||
formatRpm: string;
|
||||
intelNote: string;
|
||||
unavailable: string;
|
||||
};
|
||||
cli: {
|
||||
title: string;
|
||||
sub: string;
|
||||
installLabel: string;
|
||||
startLabel: string;
|
||||
sshNote: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
};
|
||||
cloud: { title: string; sub: string };
|
||||
footer: {
|
||||
releaseNotes: string;
|
||||
allReleases: string;
|
||||
currentVersion: string;
|
||||
versionUnavailable: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const zh: LandingDict = {
|
||||
export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
return {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
login: "\u767b\u5f55",
|
||||
@@ -122,8 +121,8 @@ export const zh: LandingDict = {
|
||||
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
|
||||
steps: [
|
||||
{
|
||||
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
|
||||
description: ALLOW_SIGNUP
|
||||
title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间",
|
||||
description: allowSignup
|
||||
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
|
||||
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
|
||||
},
|
||||
@@ -227,7 +226,7 @@ export const zh: LandingDict = {
|
||||
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
|
||||
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
|
||||
{ label: "更新日志", href: "/changelog" },
|
||||
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
|
||||
{ label: "下载", href: "/download" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
@@ -276,12 +275,72 @@ export const zh: LandingDict = {
|
||||
changelog: {
|
||||
title: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
toc: "\u5386\u53f2\u7248\u672c",
|
||||
categories: {
|
||||
features: "新功能",
|
||||
improvements: "改进",
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
title: "本地 Skills、LaTeX、Focus 模式与孤儿任务自恢复",
|
||||
changes: [],
|
||||
features: [
|
||||
"支持将 Runtime 本地 Skills 导入工作区,成为一等工作区资产",
|
||||
"孤儿任务自动恢复——意外中断的 Agent 执行会自动重试,必要时可手动重跑",
|
||||
"Issue、评论与 Chat 支持 LaTeX 渲染",
|
||||
"Chat Focus 模式——将当前页面作为上下文分享给对话",
|
||||
],
|
||||
improvements: [
|
||||
"子 Issue 的 `status_changed` 事件不再向父 Issue 订阅者刷屏",
|
||||
"Docker 发布镜像改为按架构原生构建,免 QEMU",
|
||||
"侧边栏 Pin 字段在客户端派生,排序更跟手",
|
||||
"扩充保留 slug 列表,新工作区 slug 不会再和产品路由冲突",
|
||||
],
|
||||
fixes: [
|
||||
"Gemini Runtime 模型列表补上 Gemini 3 及若干 CLI 别名",
|
||||
"没有锚点的页面上 Chat focus 按钮改为禁用",
|
||||
"修复 Onboarding 中 Pin 同步、欢迎页布局与 Runtime bootstrap 状态",
|
||||
"`install.ps1` 的系统架构探测更稳健,覆盖更多 Windows 环境",
|
||||
"`/download` 在 1 小时新鲜度窗口内可回退到上一版本,避免撞上半发布状态",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
title: "桌面应用跨平台打包、CLI 自更新与看板分页",
|
||||
changes: [],
|
||||
features: [
|
||||
"桌面应用跨平台打包——同一条发布流水线产出 macOS、Windows 和 Linux 安装包",
|
||||
"新增 `multica update` 自更新命令——无需重装即可升级 CLI 和本地 Daemon",
|
||||
"Issue 看板所有状态列都支持分页(不再只是 Done 列),大积压下依然流畅",
|
||||
],
|
||||
fixes: [
|
||||
"本地 Daemon 对 Agent 执行强制端到端工作区隔离(安全)",
|
||||
"Windows 下 Daemon 终端关闭后继续常驻,后台 Agent 不再被意外终止",
|
||||
"看板卡片重新显示描述预览——列表查询不再丢掉 description 字段",
|
||||
"OpenClaw Agent 改为从 Agent 元数据读取真实模型,不再回退到默认值",
|
||||
"评论 Markdown 全链路保留——移除会误伤格式的 HTML sanitizer",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Agent 模型选择、Kimi Runtime 与自部署登录",
|
||||
changes: [],
|
||||
features: [
|
||||
"Agent 新增 `model` 字段及按 Provider 聚合的模型下拉框——可在界面或通过 `multica agent create/update --model` 为每个 Agent 选择 LLM 模型,并从各 Runtime CLI 实时发现可用模型",
|
||||
"新增 Kimi CLI Agent Runtime(Moonshot AI 的 `kimi-cli`,基于 ACP),支持模型选择、自动授权工具权限以及流式工具调用渲染",
|
||||
"评论和回复编辑器新增放大按钮,便于撰写长文本",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 工作流将“发布结果评论”提升为独立的显式步骤,确保最终回复送达 Issue 而不是只留在终端输出",
|
||||
"通过 Cmd+K 切换 Issue 时不再出现其他 Issue 的 Agent 实时状态残留",
|
||||
"自部署会话 Cookie 的 Secure 标志改由 `FRONTEND_ORIGIN` 协议决定——HTTP 部署不再因浏览器丢弃 Cookie 导致登录失败;`COOKIE_DOMAIN=<ip>` 会自动回退到 host-only 并输出警告",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
@@ -692,4 +751,78 @@ export const zh: LandingDict = {
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon · 内置 daemon,无需配置",
|
||||
primary: "下载 (.dmg)",
|
||||
altZip: "或下载 .zip",
|
||||
},
|
||||
macIntel: {
|
||||
title: "Multica for macOS",
|
||||
sub: "需要 Apple Silicon——暂不支持 Intel Mac。",
|
||||
disabledCta: "需要 Apple Silicon",
|
||||
intelHint: "在 Intel Mac 上?请使用下方 CLI——底层跑的是同一个 daemon。",
|
||||
},
|
||||
winX64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "内置 daemon,无需配置",
|
||||
primary: "下载 (.exe)",
|
||||
},
|
||||
winArm64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "ARM · 内置 daemon,无需配置",
|
||||
primary: "下载 (.exe)",
|
||||
},
|
||||
linux: {
|
||||
title: "Multica for Linux",
|
||||
sub: "内置 daemon,无需配置",
|
||||
primary: "下载 AppImage",
|
||||
altFormats: "或 .deb / .rpm",
|
||||
},
|
||||
unknown: {
|
||||
title: "选择你的平台",
|
||||
sub: "下方是所有支持的安装包。",
|
||||
},
|
||||
safariMacHint: "在 Intel Mac 上?请使用下方 CLI。",
|
||||
archFallbackHint: "架构不对?下方是所有可选格式。",
|
||||
},
|
||||
allPlatforms: {
|
||||
title: "所有平台",
|
||||
macLabel: "macOS · Apple Silicon",
|
||||
winX64Label: "Windows · x64",
|
||||
winArm64Label: "Windows · ARM64",
|
||||
linuxX64Label: "Linux · x64",
|
||||
linuxArm64Label: "Linux · ARM64",
|
||||
formatDmg: ".dmg",
|
||||
formatZip: ".zip",
|
||||
formatExe: ".exe",
|
||||
formatAppImage: ".AppImage",
|
||||
formatDeb: ".deb",
|
||||
formatRpm: ".rpm",
|
||||
intelNote: "仅支持 Apple Silicon——Intel Mac 目前暂不支持。",
|
||||
unavailable: "暂不可用",
|
||||
},
|
||||
cli: {
|
||||
title: "想用 CLI?",
|
||||
sub: "适合服务器、远程开发机、无图形界面环境。底层 daemon 与 Desktop 相同,通过终端安装。",
|
||||
installLabel: "安装",
|
||||
startLabel: "启动 daemon",
|
||||
sshNote: "已经在服务器上?通过 SSH 执行同样的命令即可。",
|
||||
copyLabel: "复制",
|
||||
copiedLabel: "已复制",
|
||||
},
|
||||
cloud: {
|
||||
title: "Cloud runtime(等待名单)",
|
||||
sub: "我们将为你托管 runtime,目前尚未上线——留下邮箱,上线后通知你。",
|
||||
},
|
||||
footer: {
|
||||
releaseNotes: "v{version} 更新内容",
|
||||
allReleases: "查看所有版本",
|
||||
currentVersion: "当前版本:{version}",
|
||||
versionUnavailable: "版本获取失败——请前往 GitHub 查看",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
149
apps/web/features/landing/utils/github-release.test.ts
Normal file
149
apps/web/features/landing/utils/github-release.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchLatestRelease } from "./github-release";
|
||||
|
||||
const SAMPLE_LATEST_ASSET = {
|
||||
name: "multica-desktop-0.2.14-mac-arm64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/multica-ai/multica/releases/download/v0.2.14/multica-desktop-0.2.14-mac-arm64.dmg",
|
||||
};
|
||||
|
||||
const SAMPLE_PREV_ASSET = {
|
||||
name: "multica-desktop-0.2.13-mac-arm64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/multica-ai/multica/releases/download/v0.2.13/multica-desktop-0.2.13-mac-arm64.dmg",
|
||||
};
|
||||
|
||||
function releasePayload(overrides: {
|
||||
tag: string;
|
||||
publishedMinutesAgo?: number;
|
||||
asset?: { name: string; browser_download_url: string };
|
||||
prerelease?: boolean;
|
||||
draft?: boolean;
|
||||
}) {
|
||||
const published = new Date(
|
||||
Date.now() - (overrides.publishedMinutesAgo ?? 0) * 60_000,
|
||||
).toISOString();
|
||||
return {
|
||||
tag_name: overrides.tag,
|
||||
published_at: published,
|
||||
html_url: `https://github.com/multica-ai/multica/releases/tag/${overrides.tag}`,
|
||||
prerelease: overrides.prerelease ?? false,
|
||||
draft: overrides.draft ?? false,
|
||||
assets: overrides.asset ? [overrides.asset] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetchWithReleases(releases: unknown[]) {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(releases), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("fetchLatestRelease", () => {
|
||||
it("uses previous release when latest was published within the fresh window", async () => {
|
||||
mockFetchWithReleases([
|
||||
releasePayload({
|
||||
tag: "v0.2.14",
|
||||
publishedMinutesAgo: 10,
|
||||
asset: SAMPLE_LATEST_ASSET,
|
||||
}),
|
||||
releasePayload({
|
||||
tag: "v0.2.13",
|
||||
publishedMinutesAgo: 60 * 24,
|
||||
asset: SAMPLE_PREV_ASSET,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await fetchLatestRelease();
|
||||
expect(result.version).toBe("v0.2.13");
|
||||
expect(result.assets.macArm64Dmg).toBe(SAMPLE_PREV_ASSET.browser_download_url);
|
||||
});
|
||||
|
||||
it("uses latest release once it is older than the fresh window", async () => {
|
||||
mockFetchWithReleases([
|
||||
releasePayload({
|
||||
tag: "v0.2.14",
|
||||
publishedMinutesAgo: 120,
|
||||
asset: SAMPLE_LATEST_ASSET,
|
||||
}),
|
||||
releasePayload({
|
||||
tag: "v0.2.13",
|
||||
publishedMinutesAgo: 60 * 24,
|
||||
asset: SAMPLE_PREV_ASSET,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await fetchLatestRelease();
|
||||
expect(result.version).toBe("v0.2.14");
|
||||
expect(result.assets.macArm64Dmg).toBe(SAMPLE_LATEST_ASSET.browser_download_url);
|
||||
});
|
||||
|
||||
it("falls back to latest when there is no previous release", async () => {
|
||||
mockFetchWithReleases([
|
||||
releasePayload({
|
||||
tag: "v0.0.1",
|
||||
publishedMinutesAgo: 5,
|
||||
asset: SAMPLE_LATEST_ASSET,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await fetchLatestRelease();
|
||||
expect(result.version).toBe("v0.0.1");
|
||||
});
|
||||
|
||||
it("skips prereleases and drafts in the candidate list", async () => {
|
||||
mockFetchWithReleases([
|
||||
releasePayload({
|
||||
tag: "v0.2.15-rc.1",
|
||||
publishedMinutesAgo: 30,
|
||||
prerelease: true,
|
||||
}),
|
||||
releasePayload({
|
||||
tag: "v0.2.14",
|
||||
publishedMinutesAgo: 120,
|
||||
asset: SAMPLE_LATEST_ASSET,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await fetchLatestRelease();
|
||||
expect(result.version).toBe("v0.2.14");
|
||||
});
|
||||
|
||||
it("returns an empty release shape when the API errors", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response("rate limited", { status: 403 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await fetchLatestRelease();
|
||||
expect(result).toEqual({
|
||||
version: null,
|
||||
publishedAt: null,
|
||||
htmlUrl: null,
|
||||
assets: {},
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns an empty release shape when all candidates are filtered out", async () => {
|
||||
mockFetchWithReleases([
|
||||
releasePayload({ tag: "v0.2.15-rc.1", prerelease: true }),
|
||||
releasePayload({ tag: "v0.2.14-draft", draft: true }),
|
||||
]);
|
||||
|
||||
const result = await fetchLatestRelease();
|
||||
expect(result.version).toBeNull();
|
||||
expect(result.assets).toEqual({});
|
||||
});
|
||||
});
|
||||
114
apps/web/features/landing/utils/github-release.ts
Normal file
114
apps/web/features/landing/utils/github-release.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
parseReleaseAssets,
|
||||
type DownloadAssets,
|
||||
} from "./parse-release-assets";
|
||||
|
||||
/**
|
||||
* Server-side fetcher for the latest Multica release, designed to
|
||||
* run inside a Next.js server component. Response is cached by the
|
||||
* Next.js fetch cache for 5 minutes (Vercel ISR) so hitting /download
|
||||
* costs at most one GitHub API call per region per 5 minutes.
|
||||
*
|
||||
* Desktop assets don't all land at the same time: CI uploads Linux
|
||||
* and Windows within a minute of each other, but macOS is packaged
|
||||
* manually (notarization credentials aren't wired into CI yet) and
|
||||
* lands tens of minutes later. To avoid showing the half-filled
|
||||
* mid-flight state on /download, the fetcher pulls the two most
|
||||
* recent releases and falls back to the previous one for the first
|
||||
* hour after publish. Empirically full desktop uploads complete in
|
||||
* ~20 min; 1 h gives 3x buffer for commonly-variable manual steps.
|
||||
*
|
||||
* On any failure (network, rate limit, malformed payload) returns a
|
||||
* `null`-shaped result and logs — the page degrades to a "version
|
||||
* unavailable" view rather than 500ing.
|
||||
*/
|
||||
|
||||
export interface LatestRelease {
|
||||
version: string | null;
|
||||
publishedAt: string | null;
|
||||
htmlUrl: string | null;
|
||||
assets: DownloadAssets;
|
||||
}
|
||||
|
||||
const GITHUB_RELEASES_URL =
|
||||
"https://api.github.com/repos/multica-ai/multica/releases?per_page=2";
|
||||
|
||||
const REVALIDATE_SECONDS = 300;
|
||||
|
||||
const FRESH_RELEASE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
interface GitHubReleasePayload {
|
||||
tag_name?: string;
|
||||
published_at?: string;
|
||||
html_url?: string;
|
||||
prerelease?: boolean;
|
||||
draft?: boolean;
|
||||
assets?: Array<{ name: string; browser_download_url: string }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(): Promise<LatestRelease> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
// Optional PAT for local development and self-hosted deploys where
|
||||
// the shared outbound IP keeps hitting the 60-requests/hour
|
||||
// unauthenticated limit. Vercel's fetch cache is shared across all
|
||||
// regions so production rarely needs this — but the env var lets
|
||||
// anyone running the site locally avoid the rate-limit dance. Never
|
||||
// prefix this with `NEXT_PUBLIC_`; the token must stay server-side.
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GITHUB_RELEASES_URL, {
|
||||
next: { revalidate: REVALIDATE_SECONDS },
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API responded ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as GitHubReleasePayload[];
|
||||
|
||||
// Defensive filter — Multica doesn't publish prereleases or drafts
|
||||
// today, but the endpoint returns them if that ever changes. A
|
||||
// prerelease shadowing a stable version on /download would be a
|
||||
// regression.
|
||||
const stable = data.filter((r) => !r.prerelease && !r.draft);
|
||||
const latest = stable[0];
|
||||
if (!latest) {
|
||||
return emptyRelease();
|
||||
}
|
||||
const previous = stable[1];
|
||||
const chosen =
|
||||
previous && isWithinFreshWindow(latest) ? previous : latest;
|
||||
|
||||
return {
|
||||
version: chosen.tag_name ?? null,
|
||||
publishedAt: chosen.published_at ?? null,
|
||||
htmlUrl: chosen.html_url ?? null,
|
||||
assets: parseReleaseAssets(chosen.assets ?? []),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("[download] fetchLatestRelease failed:", err);
|
||||
return emptyRelease();
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinFreshWindow(release: GitHubReleasePayload): boolean {
|
||||
if (!release.published_at) return false;
|
||||
const publishedAt = Date.parse(release.published_at);
|
||||
if (Number.isNaN(publishedAt)) return false;
|
||||
return Date.now() - publishedAt < FRESH_RELEASE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function emptyRelease(): LatestRelease {
|
||||
return {
|
||||
version: null,
|
||||
publishedAt: null,
|
||||
htmlUrl: null,
|
||||
assets: {},
|
||||
};
|
||||
}
|
||||
97
apps/web/features/landing/utils/os-detect.ts
Normal file
97
apps/web/features/landing/utils/os-detect.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Client-side OS + architecture detection for the /download page.
|
||||
*
|
||||
* Prefers the modern `navigator.userAgentData.getHighEntropyValues`
|
||||
* API (Chromium), falling back to the UA string.
|
||||
*
|
||||
* Known limitation: Safari on macOS always reports `Intel Mac OS X`
|
||||
* in the UA string even on Apple Silicon, and Safari does not
|
||||
* implement userAgentData. This function therefore returns `arm64`
|
||||
* as the best default for any Mac — UI surfaces a small "On Intel
|
||||
* Mac? Use CLI." hint to cover the Intel minority.
|
||||
*/
|
||||
|
||||
export type OSName = "mac" | "windows" | "linux" | "unknown";
|
||||
export type Arch = "arm64" | "x64" | "unknown";
|
||||
|
||||
export interface DetectResult {
|
||||
os: OSName;
|
||||
arch: Arch;
|
||||
/** True when arch came from userAgentData high-entropy values
|
||||
* (i.e. we can trust the Intel vs arm distinction). False when
|
||||
* we defaulted — UI should show the Intel Mac disclaimer. */
|
||||
archConfident: boolean;
|
||||
}
|
||||
|
||||
interface UADataRecord {
|
||||
platform: string;
|
||||
architecture: string;
|
||||
}
|
||||
|
||||
interface UserAgentDataLike {
|
||||
getHighEntropyValues?: (hints: string[]) => Promise<UADataRecord>;
|
||||
}
|
||||
|
||||
function normalizePlatform(raw: string): OSName {
|
||||
const p = raw.toLowerCase();
|
||||
if (p.includes("mac") || p === "darwin") return "mac";
|
||||
if (p.includes("win")) return "windows";
|
||||
if (p.includes("linux")) return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeArch(raw: string): Arch {
|
||||
const a = raw.toLowerCase();
|
||||
if (a === "arm" || a === "arm64" || a === "aarch64") return "arm64";
|
||||
if (a === "x86" || a === "x86_64" || a === "amd64" || a === "x64") return "x64";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function detectOS(): Promise<DetectResult> {
|
||||
if (typeof navigator === "undefined") {
|
||||
return { os: "unknown", arch: "unknown", archConfident: false };
|
||||
}
|
||||
|
||||
// Modern Chromium: userAgentData with high-entropy values gives
|
||||
// both the platform name and CPU architecture unambiguously.
|
||||
const uaData = (navigator as unknown as { userAgentData?: UserAgentDataLike })
|
||||
.userAgentData;
|
||||
if (uaData?.getHighEntropyValues) {
|
||||
try {
|
||||
const data = await uaData.getHighEntropyValues([
|
||||
"platform",
|
||||
"architecture",
|
||||
]);
|
||||
const os = normalizePlatform(data.platform);
|
||||
const arch = normalizeArch(data.architecture);
|
||||
return { os, arch, archConfident: arch !== "unknown" };
|
||||
} catch {
|
||||
// Some browsers expose the API but reject high-entropy requests.
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: UA + navigator.platform. Safari on Mac lands here and
|
||||
// cannot distinguish Apple Silicon from Intel.
|
||||
const ua = navigator.userAgent;
|
||||
const platform = navigator.platform || "";
|
||||
|
||||
const os: OSName = /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X/i.test(ua)
|
||||
? "mac"
|
||||
: /Win/i.test(platform) || /Windows/i.test(ua)
|
||||
? "windows"
|
||||
: /Linux/i.test(platform) || /Linux/i.test(ua)
|
||||
? "linux"
|
||||
: "unknown";
|
||||
|
||||
let arch: Arch = "unknown";
|
||||
if (os === "mac") {
|
||||
// Best default. Real Intel Mac users will see the disclaimer.
|
||||
arch = "arm64";
|
||||
} else if (/arm|aarch/i.test(ua)) {
|
||||
arch = "arm64";
|
||||
} else if (os !== "unknown") {
|
||||
arch = "x64";
|
||||
}
|
||||
|
||||
return { os, arch, archConfident: false };
|
||||
}
|
||||
94
apps/web/features/landing/utils/parse-release-assets.ts
Normal file
94
apps/web/features/landing/utils/parse-release-assets.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Parses the GitHub Releases API asset array into a structured
|
||||
* download asset map. Skips auxiliary files (blockmaps, update
|
||||
* manifests, checksums) and the CLI tarballs — only desktop
|
||||
* installer artifacts are relevant on the /download page.
|
||||
*
|
||||
* Desktop artifact naming (see apps/desktop/electron-builder.yml):
|
||||
* multica-desktop-{version}-mac-{arch}.{dmg|zip}
|
||||
* multica-desktop-{version}-windows-{arch}.exe
|
||||
* multica-desktop-{version}-linux-{arch}.{AppImage|deb|rpm}
|
||||
*
|
||||
* Linux arch appears as amd64 / x86_64 / arm64 / aarch64 depending
|
||||
* on the format; we normalize to amd64 and arm64.
|
||||
*/
|
||||
|
||||
export interface GitHubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
export interface DownloadAssets {
|
||||
macArm64Dmg?: string;
|
||||
macArm64Zip?: string;
|
||||
winX64Exe?: string;
|
||||
winArm64Exe?: string;
|
||||
linuxAmd64AppImage?: string;
|
||||
linuxAmd64Deb?: string;
|
||||
linuxAmd64Rpm?: string;
|
||||
linuxArm64AppImage?: string;
|
||||
linuxArm64Deb?: string;
|
||||
linuxArm64Rpm?: string;
|
||||
}
|
||||
|
||||
const DESKTOP_ARTIFACT_RE =
|
||||
/^multica-desktop-[^-]+-(mac|windows|linux)-([a-z0-9_]+)\.(dmg|zip|exe|AppImage|deb|rpm)$/i;
|
||||
|
||||
function normalizeLinuxArch(arch: string): "amd64" | "arm64" | null {
|
||||
const a = arch.toLowerCase();
|
||||
if (a === "amd64" || a === "x86_64") return "amd64";
|
||||
if (a === "arm64" || a === "aarch64") return "arm64";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseReleaseAssets(raw: GitHubAsset[]): DownloadAssets {
|
||||
const out: DownloadAssets = {};
|
||||
for (const asset of raw) {
|
||||
const name = asset.name;
|
||||
// Skip auxiliary files that share the release (update manifests,
|
||||
// blockmaps, checksums). CLI tarballs and other non-desktop
|
||||
// artifacts are excluded automatically because they don't match
|
||||
// DESKTOP_ARTIFACT_RE below.
|
||||
if (name.endsWith(".blockmap") || name.endsWith(".yml")) continue;
|
||||
if (name.startsWith("checksums")) continue;
|
||||
|
||||
const match = DESKTOP_ARTIFACT_RE.exec(name);
|
||||
if (!match) continue;
|
||||
const platform = match[1];
|
||||
const arch = match[2];
|
||||
const ext = match[3];
|
||||
if (!platform || !arch || !ext) continue;
|
||||
const archLower = arch.toLowerCase();
|
||||
const extLower = ext.toLowerCase();
|
||||
const url = asset.browser_download_url;
|
||||
|
||||
if (platform === "mac") {
|
||||
if (archLower !== "arm64") continue; // we only ship arm64 today
|
||||
if (extLower === "dmg") out.macArm64Dmg = url;
|
||||
else if (extLower === "zip") out.macArm64Zip = url;
|
||||
} else if (platform === "windows") {
|
||||
if (extLower !== "exe") continue;
|
||||
if (archLower === "x64") out.winX64Exe = url;
|
||||
else if (archLower === "arm64") out.winArm64Exe = url;
|
||||
} else if (platform === "linux") {
|
||||
const normalized = normalizeLinuxArch(arch);
|
||||
if (!normalized) continue;
|
||||
const e = extLower;
|
||||
if (normalized === "amd64") {
|
||||
if (e === "appimage") out.linuxAmd64AppImage = url;
|
||||
else if (e === "deb") out.linuxAmd64Deb = url;
|
||||
else if (e === "rpm") out.linuxAmd64Rpm = url;
|
||||
} else {
|
||||
if (e === "appimage") out.linuxArm64AppImage = url;
|
||||
else if (e === "deb") out.linuxArm64Deb = url;
|
||||
else if (e === "rpm") out.linuxArm64Rpm = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether any desktop asset was parsed out. Used for UI degradation. */
|
||||
export function hasAnyAsset(assets: DownloadAssets): boolean {
|
||||
return Object.values(assets).some((v) => typeof v === "string");
|
||||
}
|
||||
@@ -9,6 +9,11 @@ export const mockUser: User = {
|
||||
name: "Test User",
|
||||
email: "test@multica.ai",
|
||||
avatar_url: null,
|
||||
onboarded_at: "2026-01-01T00:00:00Z",
|
||||
onboarding_questionnaire: {},
|
||||
// Matches real server behavior for anyone who onboarded before this
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
19
docker-compose.selfhost.build.yml
Normal file
19
docker-compose.selfhost.build.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
# Development override: build the backend/web images from the current checkout
|
||||
# instead of pulling the official GHCR images.
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: multica-backend:dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
frontend:
|
||||
image: multica-web:dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
NEXT_PUBLIC_APP_VERSION: dev
|
||||
@@ -29,9 +29,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -61,13 +59,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
429
docs/analytics.md
Normal file
429
docs/analytics.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Product Analytics
|
||||
|
||||
This document is the source of truth for the analytics events Multica ships
|
||||
to PostHog. Events feed the acquisition → activation → expansion funnel that
|
||||
drives our weekly Active Workspaces (WAW) north-star metric.
|
||||
|
||||
See [MUL-1122](https://github.com/multica-ai/multica) for the design context.
|
||||
|
||||
## Configuration
|
||||
|
||||
All analytics shipping is toggled by environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
|
||||
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
|
||||
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
|
||||
|
||||
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
|
||||
events leave the process unless the operator explicitly opts in**.
|
||||
|
||||
### Self-hosted instances
|
||||
|
||||
Self-hosters should **never inherit a Multica-issued `POSTHOG_API_KEY`** —
|
||||
that would route their users' behavior to our analytics project. The
|
||||
defaults guarantee this:
|
||||
|
||||
- `.env.example` ships `POSTHOG_API_KEY=` empty. The Docker self-host
|
||||
compose does not set a default either.
|
||||
- With the key unset, `NewFromEnv` returns `NoopClient` and logs
|
||||
`analytics: POSTHOG_API_KEY not set, using noop client` at startup — a
|
||||
visible confirmation that nothing is shipped.
|
||||
- Operators who want their own analytics can set `POSTHOG_API_KEY` and
|
||||
`POSTHOG_HOST` to point at their own PostHog project (Cloud or
|
||||
self-hosted PostHog).
|
||||
- The frontend receives the key via `/api/config` (planned for PR 2), so
|
||||
self-hosts' blank server config also disables frontend event shipping
|
||||
automatically — no separate frontend opt-out plumbing required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
|
||||
│
|
||||
▼
|
||||
bounded queue (1024 events)
|
||||
│
|
||||
▼
|
||||
background worker: batch + POST /batch/
|
||||
│
|
||||
▼
|
||||
PostHog
|
||||
```
|
||||
|
||||
- `analytics.Capture` is **never allowed to block a request handler**. A
|
||||
broken backend must not degrade the product — when the queue is full,
|
||||
events are dropped and counted (visible via `slog` + the `dropped` counter
|
||||
on shutdown).
|
||||
- Batches flush either when `BatchSize` is reached or every `FlushEvery`
|
||||
(default 10 s), whichever comes first.
|
||||
- `Close()` drains remaining events during graceful shutdown. Called from
|
||||
`server/cmd/server/main.go` via `defer`.
|
||||
|
||||
## Identity model
|
||||
|
||||
- **`distinct_id`** — always the user's UUID for logged-in events. The
|
||||
frontend's `posthog.identify(user.id)` merges any prior anonymous events
|
||||
under the same identity, so acquisition attribution (UTM / referrer) stays
|
||||
intact across signup.
|
||||
- **`workspace_id`** — added to every event as a property when present. v1
|
||||
uses event property filtering (free tier) rather than PostHog Groups
|
||||
Analytics (paid) to compute workspace-level metrics.
|
||||
- **PII** — events carry `email_domain` (e.g. `gmail.com`), not the full
|
||||
email. Full email is stored once in person properties via `$set_once` so
|
||||
it's available for individual debugging but not broadcast with every
|
||||
event.
|
||||
- **Person properties (`$set`)** — use for mutable cohort signals
|
||||
(role, use_case, team_size, platform_preference) that a user can
|
||||
legitimately change during onboarding. `Event.Set` on the backend
|
||||
maps to `$set`; the frontend helper is
|
||||
`setPersonProperties()` in `@multica/core/analytics`. Use
|
||||
`$set_once` only for values that must never be overwritten (email,
|
||||
initial attribution, first-completion timestamp).
|
||||
|
||||
## Event contract
|
||||
|
||||
### `signup`
|
||||
|
||||
Fires when a new user is created. Covers both verification-code and Google
|
||||
OAuth entry points (`findOrCreateUser` is the single emission site).
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email_domain` | string | Lower-cased domain portion of the user's email. |
|
||||
| `signup_source` | string | Opaque attribution bundle from the frontend cookie `multica_signup_source` (UTM + referrer). Empty when the cookie is absent. |
|
||||
| `auth_method` | string | Optional. `"google"` for Google OAuth signups. Absent for verification-code signups. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email` | string | Full email. Never broadcast per-event. |
|
||||
| `signup_source` | string | Same as above; kept on the person for later segmentation. |
|
||||
|
||||
### `workspace_created`
|
||||
|
||||
Fires after a `CreateWorkspace` transaction commits successfully.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Added globally; present here for clarity. |
|
||||
|
||||
**Note on "first workspace" segmentation** — we deliberately do *not* stamp
|
||||
an `is_first_workspace` boolean at emit time. Computing it correctly would
|
||||
require an extra column or transaction-scoped logic that still races under
|
||||
concurrent creates. Instead, PostHog answers the same question exactly by
|
||||
looking at whether the user has a prior `workspace_created` event (use a
|
||||
funnel with "first time user does X" or a cohort on
|
||||
`person_properties.$initial_event`). No information is lost.
|
||||
|
||||
### `runtime_registered`
|
||||
|
||||
Fires the first time a `(workspace_id, daemon_id, provider)` tuple is
|
||||
upserted. Heartbeats and repeat registrations never re-emit. First-time
|
||||
detection uses Postgres `xmax = 0` on the upsert RETURNING clause — no
|
||||
extra query, no race.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
|
||||
| `provider` | string | e.g. `"codex"`, `"claude"`. |
|
||||
| `runtime_version` | string | Version of the agent runtime binary. |
|
||||
| `cli_version` | string | Version of the `multica` CLI that registered it. |
|
||||
|
||||
`distinct_id` is the authenticated owner's user id when the daemon was
|
||||
registered via a member's JWT/PAT; daemon-token registrations fall back to
|
||||
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
|
||||
under a single "anonymous" person.
|
||||
|
||||
### `issue_executed`
|
||||
|
||||
Fires **at most once per issue** — when the first task on that issue
|
||||
reaches terminal `done` state. Backed by an atomic
|
||||
`UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *`;
|
||||
retries, re-assignments, and comment-triggered follow-up tasks all hit the
|
||||
WHERE clause and no-op, so the `≥1 / ≥2 / ≥5 / ≥10` funnel buckets count
|
||||
distinct issues, not tasks.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `issue_id` | string (UUID) | |
|
||||
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
|
||||
|
||||
`distinct_id` prefers the issue's human creator so agent-executed events
|
||||
flow into the issue-author's person profile (same place `signup` and
|
||||
`workspace_created` land). Agent-created issues prefix with `agent:` to
|
||||
keep PostHog from merging the agent into a user record.
|
||||
|
||||
**Note on workspace-Nth ordinals** — we deliberately do *not* stamp
|
||||
`nth_issue_for_workspace` at emit time. Computing it correctly would
|
||||
require either a serialised transaction or an advisory lock per workspace;
|
||||
two concurrent first-completions could otherwise both read `count=1` and
|
||||
emit `n=1`. PostHog answers the same question at query time via
|
||||
`row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp)`,
|
||||
and funnel steps of the form "workspace has had ≥2 `issue_executed`
|
||||
events" are expressible without the property. No information is lost.
|
||||
|
||||
### `team_invite_sent`
|
||||
|
||||
Fires from `CreateInvitation` after the DB row is written.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `invited_email_domain` | string | Lower-cased domain; full email lives in the invitation row, not the event. |
|
||||
| `invite_method` | string | Currently always `"email"`. Future non-email invite flows (share link, SCIM) should pass their own value. |
|
||||
|
||||
`distinct_id` is the inviter's user id.
|
||||
|
||||
### `team_invite_accepted`
|
||||
|
||||
Fires from `AcceptInvitation` after both the invitation row is marked
|
||||
accepted and the member row is inserted in the same transaction.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `days_since_invite` | int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
|
||||
|
||||
`distinct_id` is the invitee's user id — this is the event that closes the
|
||||
expansion funnel.
|
||||
|
||||
### `onboarding_questionnaire_submitted`
|
||||
|
||||
Fires on the first PatchOnboarding that transitions the user's
|
||||
questionnaire JSONB from "at least one slot empty" to "all three
|
||||
filled" (team_size, role, use_case). Revisions past that point don't
|
||||
re-emit — the funnel counts users, not edits.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `team_size` | string | `solo` / `team` / `other`. |
|
||||
| `role` | string | `developer` / `product_lead` / `writer` / `founder` / `other`. |
|
||||
| `use_case` | string | `coding` / `planning` / `writing_research` / `explore` / `other`. |
|
||||
| `team_size_has_other` | bool | `true` when the user filled the Q1 free-text escape. |
|
||||
| `role_has_other` | bool | Ditto Q2. |
|
||||
| `use_case_has_other` | bool | Ditto Q3. |
|
||||
|
||||
Person properties set with `$set` (not once — users can go back and
|
||||
change answers before submitting again):
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `team_size` | string | Mirrors the event property for cohort queries. |
|
||||
| `role` | string | Same. |
|
||||
| `use_case` | string | Same. |
|
||||
|
||||
`distinct_id` is the user's id. No workspace_id — the questionnaire is
|
||||
per-user, not per-workspace.
|
||||
|
||||
### `agent_created`
|
||||
|
||||
Fires on every successful `POST /api/workspaces/:id/agents`. Not
|
||||
onboarding-specific — the `is_first_agent_in_workspace` property
|
||||
isolates the Step 4 signal from later agent additions.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `agent_id` | string (UUID) | |
|
||||
| `provider` | string | Runtime provider the agent is bound to (`claude`, `codex`, etc). |
|
||||
| `template` | string | Template slug used to seed the agent (`coding` / `planning` / `writing` / `assistant`). Empty when the caller didn't come from a template picker. |
|
||||
| `is_first_agent_in_workspace` | bool | `true` when the workspace had zero agents before this insert. |
|
||||
|
||||
`distinct_id` is the authenticated owner's user id.
|
||||
|
||||
### `onboarding_completed`
|
||||
|
||||
Fires from CompleteOnboarding on the first call that actually flips
|
||||
`user.onboarded_at` from NULL. Retries are idempotent server-side but
|
||||
deliberately do NOT re-emit, so the funnel counts first-completions
|
||||
only. The client sends `completion_path` in the POST body to label
|
||||
which exit the user took.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
|
||||
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `onboarded_at` | string (RFC3339) | Timestamp the first completion landed. Enables cohort queries like "users onboarded before X" directly from person_properties. |
|
||||
|
||||
`completion_path` values:
|
||||
|
||||
- `full` — Reached Step 5 (first_issue) with a runtime connected.
|
||||
- `runtime_skipped` — Completed without connecting a runtime (user hit Skip in Step 3).
|
||||
- `cloud_waitlist` — Submitted the cloud waitlist form and skipped Step 3.
|
||||
- `skip_existing` — "I've done this before" from Welcome. The user already had a workspace.
|
||||
- `unknown` — Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
|
||||
|
||||
### `cloud_waitlist_joined`
|
||||
|
||||
Fires from JoinCloudWaitlist whenever a user submits the Step 3 cloud
|
||||
waitlist form. Not a completion signal — it's orthogonal to the main
|
||||
funnel and used to size hosted-runtime interest.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `has_reason` | bool | Presence flag for the free-text reason field. The free text stays in the DB; we don't broadcast it. |
|
||||
|
||||
`distinct_id` is the user's id.
|
||||
|
||||
### `feedback_submitted`
|
||||
|
||||
Fires from `CreateFeedback` after the `feedback` row is inserted and the
|
||||
hourly per-user rate-limit check has passed. Retries within the same hour
|
||||
that were rate-limited (429) don't emit. The free-text message is stored
|
||||
in the DB and never broadcast.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `message_length_bucket` | string | `0-100` / `100-500` / `500-2000` / `2000+` — coarse bucket of `len(message)` so we can tell "quick note" from "bug report with repro steps" without leaking content. |
|
||||
| `has_images` | bool | `true` when the markdown contains at least one `` image reference — signals bug reports with visual evidence. |
|
||||
| `platform` | string | Client platform from `X-Client-Platform` header (`web` / `desktop`). Omitted when the header is absent. |
|
||||
| `app_version` | string | Client version from `X-Client-Version` header. Omitted when absent. |
|
||||
|
||||
`distinct_id` is the submitter's user id; `workspace_id` is attached from
|
||||
the modal's current-workspace context and may be empty when feedback is
|
||||
sent from a pre-workspace surface.
|
||||
|
||||
### `starter_content_decided`
|
||||
|
||||
Fires on the atomic NULL → terminal state transition in both
|
||||
ImportStarterContent and DismissStarterContent. The `branch` property
|
||||
mirrors what ImportStarterContent would emit for the same workspace,
|
||||
so import-vs-dismiss rates split cleanly by branch.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `decision` | string | `imported` or `dismissed`. |
|
||||
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
|
||||
|
||||
`distinct_id` is the user's id; `workspace_id` is attached from the
|
||||
request payload.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
every Next.js App Router path or query-string change. The tracker
|
||||
mounts once under `WebProviders` and drives the acquisition funnel's
|
||||
`/ → signup` step. posthog-js's automatic pageview capture is
|
||||
disabled in `initAnalytics` so we own the event shape.
|
||||
- `onboarding_runtime_path_selected` — fired from
|
||||
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
|
||||
user clicks one of the three Step 3 fork cards (before any server
|
||||
call happens, so it's frontend-only). Properties: `path`
|
||||
(`download_desktop` / `cli` / `cloud_waitlist`), `source` (`step3`;
|
||||
literal today but reserved for future surfaces reusing this event),
|
||||
`is_mac`. Also writes `platform_preference` (`web` / `desktop`) to
|
||||
person properties so every subsequent event on the user can be
|
||||
broken down by chosen platform. **Note**: semantic "download
|
||||
intent" is now better served by `download_intent_expressed` below —
|
||||
`path: "download_desktop"` signals Step 3 path choice specifically,
|
||||
not actual download start.
|
||||
|
||||
- `onboarding_runtime_detected` — fired from
|
||||
`packages/views/onboarding/steps/step-runtime-connect.tsx` (desktop
|
||||
Step 3) once per mount, when the scanning phase resolves — either
|
||||
immediately on first runtime registration, or after the 5 s empty
|
||||
timeout. Answers the question "did the user have any AI CLI
|
||||
installed on this machine when they hit Step 3" — currently
|
||||
unanswerable from the existing funnel because the bundled daemon
|
||||
fails to register at all when zero CLIs are on PATH, so
|
||||
`runtime_registered` is silent on that cohort. Splits
|
||||
`completion_path=runtime_skipped` into "had CLIs, skipped anyway"
|
||||
vs "no CLIs available, had no choice". Properties:
|
||||
- `source`: `step3_desktop` (literal; reserved for a future web
|
||||
emission under a different value).
|
||||
- `outcome`: `found` (at least one runtime registered before the
|
||||
5 s grace window expired) or `empty` (none registered by then).
|
||||
- `runtime_count`: number of runtimes visible to this user at
|
||||
resolution time.
|
||||
- `online_count`: subset of `runtime_count` whose `status` is
|
||||
`online`.
|
||||
- `providers`: sorted array of distinct provider names (e.g.
|
||||
`["claude", "codex"]`).
|
||||
- `has_claude` / `has_codex` / `has_cursor`: convenience booleans
|
||||
derived from `providers` for funnel breakdowns without array
|
||||
filtering in HogQL.
|
||||
- `detect_ms`: wall-clock ms from component mount to resolution.
|
||||
Surfaces daemon boot latency — `found` events with a high
|
||||
`detect_ms` approach the timeout threshold and inform whether
|
||||
to lengthen the grace period.
|
||||
|
||||
Person properties set with `$set`:
|
||||
- `has_any_cli`: boolean — cohort signal for "user has at least
|
||||
one local AI CLI detected on this machine".
|
||||
- `detected_cli_count`: number — granular cohort signal.
|
||||
|
||||
Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web
|
||||
users don't run the bundled daemon, so their runtime list reflects
|
||||
daemons from other machines and would corrupt the
|
||||
"CLI installed locally" signal.
|
||||
|
||||
- `download_intent_expressed` — fired whenever a user clicks a CTA
|
||||
that points at the `/download` page. Surfaces five sources across
|
||||
the funnel, letting the top-of-funnel entry be split cleanly.
|
||||
Wrapper lives in `packages/core/analytics/download.ts`
|
||||
(`captureDownloadIntent`). Properties:
|
||||
- `source`: `landing_hero` / `landing_footer` / `login` / `welcome`
|
||||
/ `step3`
|
||||
Also writes `platform_preference: "desktop"` to person properties.
|
||||
|
||||
- `download_page_viewed` — fired once per `/download` mount after OS
|
||||
detect resolves (`apps/web/app/(landing)/download/download-client.tsx`).
|
||||
Properties:
|
||||
- `detected_os`: `mac` / `windows` / `linux` / `unknown`
|
||||
- `detected_arch`: `arm64` / `x64` / `unknown`
|
||||
- `detect_confident`: `true` when detect used
|
||||
`userAgentData.getHighEntropyValues` (Chromium); `false` when it
|
||||
fell back to the UA string (Safari on Mac always lands here —
|
||||
lets us isolate the arm64-default-for-Intel risk cohort).
|
||||
- `version_available`: `false` when the GitHub API fetch failed
|
||||
and the page is in the "Version unavailable" degraded state.
|
||||
Also writes `first_detected_os` / `first_detected_arch` via
|
||||
`$set_once` so every downstream event gains a platform dimension
|
||||
without re-emitting.
|
||||
|
||||
- `download_initiated` — fired when the user clicks a specific
|
||||
installer link on `/download`. Both the hero CTA and the All
|
||||
Platforms matrix rows emit this; split by `primary_cta`.
|
||||
Properties:
|
||||
- `platform`: `mac` / `windows` / `linux`
|
||||
- `arch`: `arm64` / `x64`
|
||||
- `format`: `dmg` / `zip` / `exe` / `appimage` / `deb` / `rpm`
|
||||
- `version`: release tag (e.g. `v0.2.13`) — correlates adoption
|
||||
with release cadence.
|
||||
- `primary_cta`: `true` for the hero-recommended installer, `false`
|
||||
for a manual pick from the All Platforms matrix.
|
||||
- `matched_detect`: `true` when the chosen platform+arch matches
|
||||
what the page detected. `false` lets us quantify detect misses
|
||||
from the single event (no cross-join needed).
|
||||
- `feedback_opened` — fired when the in-app Feedback modal mounts
|
||||
(user clicked "Feedback" in the Help launcher). Paired with the
|
||||
backend's `feedback_submitted` to give a completion rate for the
|
||||
form. Wrapper lives in `packages/core/analytics/feedback.ts`
|
||||
(`captureFeedbackOpened`). Properties:
|
||||
- `source`: `help_menu` (reserved — future entry points like
|
||||
keyboard shortcut or error-toast CTA will pass their own value)
|
||||
- `workspace_id`: string (UUID) when the modal opens inside a
|
||||
workspace. Omitted on pre-workspace surfaces.
|
||||
|
||||
- Attribution is NOT a separate event; UTM + referrer origin are written
|
||||
to the `multica_signup_source` cookie on the first anonymous pageview
|
||||
and read by the backend's `signup` emission. The cookie carries a JSON
|
||||
payload URL-encoded at write time (`encodeURIComponent`) and
|
||||
URL-decoded at read time (`url.QueryUnescape`) — the JSON is never
|
||||
mid-truncated; individual values are capped at 96 chars before
|
||||
`JSON.stringify`, and the entire payload is dropped if it still exceeds
|
||||
512 chars. That way PostHog sees either intact JSON or nothing at all.
|
||||
|
||||
## Governance
|
||||
|
||||
Before adding, renaming, or removing any event:
|
||||
|
||||
1. Update this document first.
|
||||
2. Update `server/internal/analytics/events.go` constants and helpers to
|
||||
match.
|
||||
3. PR description must state which existing funnel / insight is affected.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,511 +0,0 @@
|
||||
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
|
||||
|
||||
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
|
||||
|
||||
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
|
||||
|
||||
---
|
||||
|
||||
## Current State (files to modify)
|
||||
|
||||
| File | Current Role | Change |
|
||||
|------|-------------|--------|
|
||||
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
|
||||
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
|
||||
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
|
||||
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
|
||||
|
||||
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
|
||||
|
||||
This is the core task. The entire DnD orchestration logic changes.
|
||||
|
||||
### Data Model
|
||||
|
||||
```typescript
|
||||
// Local state: maps status → ordered array of issue IDs
|
||||
// This is the ONLY source of truth for card positions during drag
|
||||
type Columns = Record<IssueStatus, string[]>;
|
||||
```
|
||||
|
||||
### Step 1: Replace pendingMove with local columns state
|
||||
|
||||
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
|
||||
|
||||
```typescript
|
||||
// Build columns from TQ issues + view sort settings
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
visibleStatuses: IssueStatus[],
|
||||
sortBy: SortField,
|
||||
sortDirection: SortDirection,
|
||||
): Columns {
|
||||
const cols: Columns = {} as Columns;
|
||||
for (const status of visibleStatuses) {
|
||||
const sorted = sortIssues(
|
||||
issues.filter((i) => i.status === status),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
);
|
||||
cols[status] = sorted.map((i) => i.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
```
|
||||
|
||||
In the component:
|
||||
|
||||
```typescript
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
// Local columns state — follows TQ between drags, local during drag
|
||||
const [columns, setColumns] = useState<Columns>(() =>
|
||||
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
|
||||
);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// Sync from TQ when NOT dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) {
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
}
|
||||
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||
```
|
||||
|
||||
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
|
||||
|
||||
```typescript
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
```
|
||||
|
||||
### Step 2: Implement findColumn helper
|
||||
|
||||
```typescript
|
||||
/** Find which column (status) contains a given ID (issue or column). */
|
||||
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
|
||||
// Is it a column ID itself?
|
||||
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||
// Search columns for the item
|
||||
for (const [status, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return status as IssueStatus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement onDragStart
|
||||
|
||||
```typescript
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
isDragging.current = true;
|
||||
const issue = issueMap.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
}, [issueMap]);
|
||||
```
|
||||
|
||||
### Step 4: Implement onDragOver — the key missing piece
|
||||
|
||||
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
|
||||
|
||||
```typescript
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol || activeCol === overCol) return;
|
||||
|
||||
// Cross-column move: remove from old column, insert into new column
|
||||
setColumns((prev) => {
|
||||
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||
const newIds = [...prev[overCol]!];
|
||||
|
||||
// Insert position: if over a card, insert at that index; if over column, append
|
||||
const overIndex = newIds.indexOf(overId);
|
||||
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||
newIds.splice(insertIndex, 0, activeId);
|
||||
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
}, [columns, visibleStatuses]);
|
||||
```
|
||||
|
||||
### Step 5: Implement onDragEnd — persist to server
|
||||
|
||||
```typescript
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
isDragging.current = false;
|
||||
setActiveIssue(null);
|
||||
|
||||
if (!over) {
|
||||
// Cancelled — reset to TQ state
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol) return;
|
||||
|
||||
// Same column reorder
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute final position from the local column order
|
||||
const finalCol = findColumn(columns, activeId, visibleStatuses);
|
||||
if (!finalCol) return;
|
||||
|
||||
// After potential same-col reorder, re-read columns
|
||||
// (for same-col we just did setColumns above, but it's async;
|
||||
// however we can compute from the intended final order)
|
||||
let finalIds: string[];
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
|
||||
} else {
|
||||
finalIds = columns[finalCol]!;
|
||||
}
|
||||
|
||||
const newPosition = computePosition(finalIds, activeId, issues);
|
||||
const currentIssue = issueMap.get(activeId);
|
||||
|
||||
// Skip if nothing changed
|
||||
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
|
||||
|
||||
onMoveIssue(activeId, finalCol, newPosition);
|
||||
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
|
||||
```
|
||||
|
||||
### Step 6: Update computePosition to work with ID arrays
|
||||
|
||||
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
|
||||
|
||||
```typescript
|
||||
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
|
||||
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
|
||||
|
||||
if (ids.length === 1) return 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Update DragOverlay styling
|
||||
|
||||
```typescript
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
```
|
||||
|
||||
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
|
||||
|
||||
### Step 8: Wire it all together
|
||||
|
||||
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
|
||||
|
||||
```tsx
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
### Step 9: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
|
||||
|
||||
### Step 10: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-view.tsx
|
||||
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
|
||||
|
||||
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
|
||||
|
||||
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
|
||||
|
||||
```typescript
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
|
||||
// Resolve IDs to Issue objects (IDs are already sorted by parent)
|
||||
const resolvedIssues = useMemo(
|
||||
() => issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
|
||||
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issueIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right: add + menu — keep as-is */}
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
|
||||
<EyeOff className="size-3.5" />
|
||||
Hide column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue", { status })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Add issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- No more `useViewStore` for sort — parent handles sorting
|
||||
- No more internal `sortIssues` call
|
||||
- Uses `issueIds` for SortableContext (already in correct order)
|
||||
- Count shows `issueIds.length` instead of `issues.length`
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS (or errors in issues-page.tsx — Task 4)
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-column.tsx
|
||||
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/board-card.tsx`
|
||||
|
||||
### Step 1: Add custom animateLayoutChanges
|
||||
|
||||
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
|
||||
|
||||
```typescript
|
||||
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||
const { isSorting, wasDragging } = args;
|
||||
if (isSorting || wasDragging) return false;
|
||||
return defaultAnimateLayoutChanges(args);
|
||||
};
|
||||
```
|
||||
|
||||
Update useSortable call:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
animateLayoutChanges,
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-card.tsx
|
||||
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/issues-page.tsx`
|
||||
|
||||
### Step 1: Update handleMoveIssue
|
||||
|
||||
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
|
||||
|
||||
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
|
||||
|
||||
```tsx
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
```
|
||||
|
||||
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
|
||||
|
||||
### Step 2: Run full typecheck + test
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/issues-page.tsx
|
||||
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual QA Checklist
|
||||
|
||||
After all code changes, verify these scenarios in the browser:
|
||||
|
||||
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
|
||||
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
|
||||
3. **Drop on empty column**: Drag card to an empty column → card lands there
|
||||
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
|
||||
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
|
||||
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
|
||||
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
|
||||
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
|
||||
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
|
||||
|
||||
---
|
||||
|
||||
## Summary of Architecture Change
|
||||
|
||||
```
|
||||
BEFORE (broken):
|
||||
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
|
||||
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
|
||||
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
|
||||
|
||||
AFTER (correct):
|
||||
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
|
||||
onDragStart → isDragging=true, freeze local state
|
||||
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
|
||||
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
|
||||
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
|
||||
```
|
||||
@@ -1,227 +0,0 @@
|
||||
# Drag & Drop Upload Enhancement — Revised Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Clean drag-and-drop upload with visual feedback. Images render inline, non-images show as file cards. No file type restrictions (match Linear). No separate attachment section (URLs live in markdown).
|
||||
|
||||
**Architecture:** Frontend-only. Images use existing `` markdown. Non-images use `[name](url)` markdown, rendered as a styled card via Tiptap NodeView when URL matches our CDN. Backend unchanged.
|
||||
|
||||
**Tech Stack:** Tiptap ProseMirror, React, Tailwind CSS, shadcn tokens
|
||||
|
||||
---
|
||||
|
||||
## What We Keep (from previous work)
|
||||
|
||||
- **Drag overlay** — `content-editor.tsx` drag handlers + `content-editor.css` overlay styles
|
||||
- **Image upload flow** — blob preview → upload → replace with real URL (existing `file-upload.ts`)
|
||||
- **Non-image upload placeholder** — `⏳ Uploading filename...` → replaced with link (existing `file-upload.ts`)
|
||||
- **`MAX_FILE_SIZE`** — 100MB limit
|
||||
|
||||
## What We Remove (redundant)
|
||||
|
||||
| File | What to remove |
|
||||
|------|----------------|
|
||||
| `attachment-section.tsx` | **Delete entire file** |
|
||||
| `issue-detail.tsx` | attachment query, delete mutation, handleImageRemoved, AttachmentSection JSX, onImageRemoved prop, all `["attachments"]` cache invalidation, onUploadSuccess on CommentInput, `api` import (if unused after) |
|
||||
| `content-editor.tsx` | `onImageRemoved` prop, `onImageRemovedRef` |
|
||||
| `extensions/index.ts` | `onImageRemovedRef` option |
|
||||
| `extensions/file-upload.ts` | `collectImageSrcs`, `imageRemovalTracker` plugin, `isAllowedFileType` check + import, `toast` import |
|
||||
| `shared/constants/upload.ts` | Everything except `MAX_FILE_SIZE` — remove `ALLOWED_MIME_PATTERNS`, `FILE_INPUT_ACCEPT`, `EXTENSION_MIME_MAP`, `isAllowedFileType`, `matchesMimePattern` |
|
||||
| `shared/constants/__tests__/upload.test.ts` | All tests except MAX_FILE_SIZE |
|
||||
| `shared/hooks/use-file-upload.ts` | `isAllowedFileType` import + check |
|
||||
| `components/common/file-upload-button.tsx` | `FILE_INPUT_ACCEPT` import + `accept` attribute |
|
||||
| `comment-input.tsx` | `onUploadSuccess` prop |
|
||||
|
||||
## What We Add (new)
|
||||
|
||||
**File Card Node** — a Tiptap custom node that renders `[name](url)` as a styled card when the URL matches our CDN (`multica-static.copilothub.ai` or S3 bucket domain).
|
||||
|
||||
```
|
||||
Editor view: ┌──────────────────────────┐
|
||||
│ 📄 report.pdf ⬇ │
|
||||
└──────────────────────────┘
|
||||
|
||||
Markdown storage: [report.pdf](https://multica-static.copilothub.ai/xxx.pdf)
|
||||
```
|
||||
|
||||
- Only for non-image CDN URLs (images stay as ``)
|
||||
- Regular external links (github.com, etc.) stay as normal links
|
||||
- Card shows: file type icon + filename + download button
|
||||
- Readonly mode shows the same card
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove Redundant Code
|
||||
|
||||
**Files to modify:**
|
||||
- Delete: `apps/web/features/issues/components/attachment-section.tsx`
|
||||
- Modify: `apps/web/features/issues/components/issue-detail.tsx`
|
||||
- Modify: `apps/web/features/issues/components/comment-input.tsx`
|
||||
- Modify: `apps/web/features/editor/content-editor.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
- Modify: `apps/web/shared/constants/upload.ts`
|
||||
- Modify: `apps/web/shared/constants/__tests__/upload.test.ts`
|
||||
- Modify: `apps/web/shared/hooks/use-file-upload.ts`
|
||||
- Modify: `apps/web/components/common/file-upload-button.tsx`
|
||||
|
||||
**What to do:**
|
||||
1. Delete `attachment-section.tsx`
|
||||
2. `issue-detail.tsx`: Remove AttachmentSection import, attachment useQuery, deleteAttachment useMutation, handleImageRemoved, onImageRemoved prop, all `["attachments"]` invalidation in handleDescriptionUpload (revert to simple `uploadWithToast` call), remove onUploadSuccess from CommentInput
|
||||
3. `comment-input.tsx`: Remove `onUploadSuccess` prop
|
||||
4. `content-editor.tsx`: Remove `onImageRemoved` prop + ref + wiring
|
||||
5. `extensions/index.ts`: Remove `onImageRemovedRef` from interface + call
|
||||
6. `extensions/file-upload.ts`: Remove `collectImageSrcs`, `imageRemovalTracker` plugin, `onImageRemovedRef` param, `isAllowedFileType` import + check, `toast` import (keep `toast` if still used — check)
|
||||
7. `shared/constants/upload.ts`: Keep only `MAX_FILE_SIZE`. Delete everything else.
|
||||
8. `shared/constants/__tests__/upload.test.ts`: Keep only `MAX_FILE_SIZE` test
|
||||
9. `shared/hooks/use-file-upload.ts`: Remove `isAllowedFileType` import + check. Import `MAX_FILE_SIZE` stays.
|
||||
10. `file-upload-button.tsx`: Remove `FILE_INPUT_ACCEPT` import + `accept` attribute
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
**Commit:** `refactor(upload): remove attachment section and file type whitelist`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: File Card Tiptap Node
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/file-card.ts`
|
||||
- Create: `apps/web/features/editor/extensions/file-card-view.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/content-editor.css`
|
||||
|
||||
**Design:**
|
||||
|
||||
The node intercepts markdown links `[name](url)` where URL matches our CDN, and renders them as a card NodeView.
|
||||
|
||||
```typescript
|
||||
// Detection: URL starts with CDN domain or known S3 bucket pattern
|
||||
function isCdnFileUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname.endsWith('.copilothub.ai') || u.hostname.endsWith('.amazonaws.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only match non-image files (images stay as )
|
||||
function isFileCardLink(url: string): boolean {
|
||||
return isCdnFileUrl(url) && !isImageUrl(url);
|
||||
}
|
||||
```
|
||||
|
||||
**Node spec:**
|
||||
- Node name: `fileCard`
|
||||
- Attrs: `href`, `filename`
|
||||
- Markdown serialize: `[filename](href)`
|
||||
- Markdown parse: detect `[text](cdnUrl)` where cdnUrl is non-image CDN link
|
||||
- NodeView: React component with file icon + name + download button
|
||||
|
||||
**Card UI (React NodeView):**
|
||||
```tsx
|
||||
<div className="file-card">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{filename}</span>
|
||||
<a href={href} download={filename} className="...">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.file-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
margin: 0.25rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual:
|
||||
1. Upload a PDF → card appears in editor (not plain link)
|
||||
2. Upload a .go file → card appears
|
||||
3. Upload an image → still renders inline (not as card)
|
||||
4. Paste an external link → still renders as normal link (not card)
|
||||
5. Save and reload → card still displays correctly
|
||||
6. Switch to readonly mode → card still displays
|
||||
|
||||
**Commit:** `feat(editor): render CDN file links as styled cards`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Non-Image Upload to Use File Card
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
|
||||
Currently the non-image upload path inserts a markdown string `[name](url)`. After Task 2 adds the fileCard node, this should insert a `fileCard` node directly instead:
|
||||
|
||||
```typescript
|
||||
// Instead of:
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
replacePlaceholder(editor, placeholder, linkText);
|
||||
|
||||
// Insert fileCard node:
|
||||
replacePlaceholder(editor, placeholder, "");
|
||||
editor.chain().focus().insertContent({
|
||||
type: "fileCard",
|
||||
attrs: { href: result.link, filename: result.filename },
|
||||
}).run();
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual: Upload a PDF → placeholder appears → replaced with file card (not plain text link)
|
||||
|
||||
**Commit:** `feat(upload): insert file card node for non-image uploads`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual test all upload flows:
|
||||
1. Drag image → overlay → drop → inline image with pulse → real image
|
||||
2. Drag PDF → overlay → drop → placeholder → file card
|
||||
3. Drag .mp4 → uploads normally (no type restriction) → file card
|
||||
4. Paste image → inline image
|
||||
5. Click 📎 → file picker shows all types → upload works
|
||||
6. Readonly mode → cards and images display correctly
|
||||
7. Save → reload → everything persists
|
||||
|
||||
**Commit:** fix any issues found
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before (current) | After |
|
||||
|-------------------|-------|
|
||||
| File type whitelist blocks .mp4/.zip/etc | All files accepted (like Linear) |
|
||||
| Attachment Section below description | Gone — files live in markdown |
|
||||
| Non-image files as plain `[name](url)` text | Styled file card with icon + download |
|
||||
| Image removal tracker + attachment cache | Gone — simpler code |
|
||||
| ~300 lines of attachment UI code | Deleted |
|
||||
| ~100 lines of whitelist code | Replaced by 1 line: `MAX_FILE_SIZE` |
|
||||
@@ -1,452 +0,0 @@
|
||||
# Image View Enhancement Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add image hover toolbar (view/download/copy image/copy link/delete), lightbox preview, and smart sizing (centered, max-width capped) — matching Linear's image UX.
|
||||
|
||||
**Architecture:** Convert the Image extension from default `<img>` rendering to a React NodeView (`image-view.tsx`). The NodeView wraps `<img>` in a `<figure>` with a hover toolbar and lightbox portal. CSS handles centering and size cap. No new npm dependencies.
|
||||
|
||||
**Tech Stack:** Tiptap `ReactNodeViewRenderer`, lucide-react, sonner (toast), CSS, `createPortal` for lightbox
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Image NodeView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/image-view.tsx`
|
||||
|
||||
**Step 1: Create the ImageView component**
|
||||
|
||||
```tsx
|
||||
// apps/web/features/editor/extensions/image-view.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import {
|
||||
Maximize2,
|
||||
Download,
|
||||
Copy,
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightbox — full-screen image preview (ESC or click backdrop to close)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image NodeView — renders <img> with hover toolbar + lightbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
|
||||
const src = node.attrs.src as string;
|
||||
const alt = (node.attrs.alt as string) || "";
|
||||
const title = node.attrs.title as string | undefined;
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const handleView = () => setLightbox(true);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = alt || "image";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
window.open(src, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyImage = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob }),
|
||||
]);
|
||||
toast.success("Image copied");
|
||||
} catch {
|
||||
// Fallback: copy link (Safari doesn't support async clipboard image)
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success("Link copied");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success("Link copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-node">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<figure
|
||||
className={cn(
|
||||
"image-figure",
|
||||
selected && isEditable && "image-selected",
|
||||
)}
|
||||
contentEditable={false}
|
||||
onClick={!isEditable && !uploading ? handleView : undefined}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title || undefined}
|
||||
className={cn("image-content", uploading && "image-uploading")}
|
||||
draggable={false}
|
||||
/>
|
||||
{!uploading && (
|
||||
<div
|
||||
className="image-toolbar"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={handleView} title="View image">
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={handleDownload} title="Download">
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyImage}
|
||||
title="Copy image"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
title="Copy link"
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</button>
|
||||
{isEditable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNode()}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</figure>
|
||||
{lightbox && (
|
||||
<ImageLightbox
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClose={() => setLightbox(false)}
|
||||
/>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { ImageView };
|
||||
```
|
||||
|
||||
**Step 2: Verify file created**
|
||||
|
||||
Run: `ls apps/web/features/editor/extensions/image-view.tsx`
|
||||
Expected: file exists
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire Up NodeView in Image Extension
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts:59-75`
|
||||
|
||||
**Step 1: Add import**
|
||||
|
||||
At the top of `index.ts`, after the existing imports, add:
|
||||
|
||||
```typescript
|
||||
import { ImageView } from "./image-view";
|
||||
```
|
||||
|
||||
**Step 2: Update ImageExtension — add NodeView, remove inline style**
|
||||
|
||||
Replace the `ImageExtension` definition (lines 59-75) with:
|
||||
|
||||
```typescript
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploading: {
|
||||
default: false,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.uploading ? { "data-uploading": "" } : {},
|
||||
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageView);
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
});
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Added `addNodeView()` — images now render via React component
|
||||
- Removed `HTMLAttributes: { style: "max-width: 100%; height: auto;" }` — sizing is now in CSS
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/features/editor/extensions/image-view.tsx apps/web/features/editor/extensions/index.ts
|
||||
git commit -m "feat(editor): add Image NodeView with toolbar and lightbox
|
||||
|
||||
- React NodeView renders images with hover toolbar (view/download/copy/link/delete)
|
||||
- Lightbox portal for full-screen preview (ESC or click to close)
|
||||
- Copy image with clipboard API (fallback to copy link on Safari)
|
||||
- Delete button in edit mode only
|
||||
- Readonly: click image opens lightbox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Image CSS — Centering, sizing, toolbar, lightbox
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/content-editor.css:379-395`
|
||||
|
||||
**Step 1: Replace image CSS rules**
|
||||
|
||||
Replace lines 379-395 (from `/* Images — shared styling */` through the `@keyframes` block) with:
|
||||
|
||||
```css
|
||||
/* Images — generic fallback (non-NodeView contexts) */
|
||||
.rich-text-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Image NodeView — centered block with max-width cap */
|
||||
.rich-text-editor .image-node {
|
||||
display: block !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-figure {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: min(100%, 640px);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-figure.image-selected .image-content {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rich-text-editor .image-uploading {
|
||||
opacity: 0.5;
|
||||
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-upload-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Readonly — zoom cursor on clickable images */
|
||||
.rich-text-editor.readonly .image-figure {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Image toolbar — dark pill, top-right corner, appears on hover */
|
||||
.image-toolbar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0.25rem;
|
||||
background: color-mix(in srgb, black 75%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image-figure:hover .image-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: white;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.image-toolbar button:hover {
|
||||
background: color-mix(in srgb, white 15%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/features/editor/content-editor.css
|
||||
git commit -m "style(editor): add image centering, sizing cap, and toolbar styles
|
||||
|
||||
- Images centered with max-width 640px cap (smart sizing)
|
||||
- Dark hover toolbar with blur backdrop
|
||||
- Selection outline for edit mode
|
||||
- Zoom cursor for readonly mode
|
||||
- Upload pulse animation preserved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
**Step 1: Run all checks**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: all pass
|
||||
|
||||
**Step 2: Manual verification checklist**
|
||||
|
||||
Test in browser:
|
||||
|
||||
| # | Test | Expected |
|
||||
|---|------|----------|
|
||||
| 1 | Upload large screenshot | Centered, max 640px wide |
|
||||
| 2 | Upload small image (< 300px) | Natural size, centered |
|
||||
| 3 | Drag image into editor | Blob preview with pulse → real image |
|
||||
| 4 | Hover image | Dark toolbar appears top-right (5 buttons edit, 4 readonly) |
|
||||
| 5 | Toolbar → View image | Full-screen lightbox opens |
|
||||
| 6 | Lightbox → ESC | Closes |
|
||||
| 7 | Lightbox → click backdrop | Closes |
|
||||
| 8 | Toolbar → Download | Browser downloads the image |
|
||||
| 9 | Toolbar → Copy image | Toast "Image copied", image in clipboard |
|
||||
| 10 | Toolbar → Copy link | Toast "Link copied", URL in clipboard |
|
||||
| 11 | Toolbar → Delete | Image removed from editor |
|
||||
| 12 | Click image (edit mode) | Blue selection outline appears |
|
||||
| 13 | Select image → Backspace | Image deleted |
|
||||
| 14 | Click image (readonly mode) | Opens lightbox directly |
|
||||
| 15 | Readonly toolbar | No Delete button, other 4 buttons work |
|
||||
| 16 | Save → reload | Images persist with correct styling |
|
||||
|
||||
**Step 3: Fix any issues, re-run checks**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
**Step 4: Commit fixes (if any)**
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why NodeView instead of CSS-only?
|
||||
|
||||
The toolbar buttons (view/download/copy/delete) require interactive React components overlaid on the image. CSS-only can handle sizing/centering but cannot add click handlers. A NodeView is the standard Tiptap pattern for this — same as `CodeBlockView` (copy button) and `FileCardView` (download button) already in the codebase.
|
||||
|
||||
### Upload flow compatibility
|
||||
|
||||
The existing upload flow in `file-upload.ts` uses `tr.setNodeMarkup()` to update image attributes after upload. This works with NodeView because ProseMirror attribute changes trigger React re-renders via `ReactNodeViewRenderer`. Same mechanism used by `FileCardView`'s `finalizeFileCard()`.
|
||||
|
||||
### Markdown serialization
|
||||
|
||||
No changes needed. Images serialize as `` — standard markdown. The NodeView only affects editor rendering, not serialization. No width/height stored in markdown (sizing is purely CSS).
|
||||
|
||||
### Lightbox implementation
|
||||
|
||||
Uses `createPortal` to render outside the editor DOM tree, with a keyboard listener for ESC. Intentionally NOT using shadcn Dialog to keep it minimal — no focus trapping or complex accessibility needed for a simple image preview overlay.
|
||||
|
||||
### Browser compatibility: Copy image
|
||||
|
||||
`navigator.clipboard.write()` with `ClipboardItem` works in Chrome/Edge. Safari requires the clipboard write to be in the same user gesture (no async fetch before write), so it falls back to copying the link URL with a toast notification.
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| Images stretch to 100% width, left-aligned | Centered, capped at 640px |
|
||||
| No hover actions on images | 5-button toolbar: View, Download, Copy, Link, Delete |
|
||||
| No image preview | Click-to-zoom lightbox (ESC/click to close) |
|
||||
| Readonly images are static | Click to zoom, hover for toolbar (minus Delete) |
|
||||
| Delete image: select + backspace only | Toolbar Delete button + keyboard |
|
||||
| No visual selection feedback | Blue outline on selected image |
|
||||
@@ -1,489 +0,0 @@
|
||||
# Monorepo Extraction Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extract shared code into monorepo packages (`packages/core/`, `packages/ui/`, `packages/views/`), set up Turborepo, ensure `apps/web/` runs identically.
|
||||
|
||||
**Architecture:** Three packages, single-direction dependencies: `views/ → core/ + ui/`. Core is headless (zero react-dom). UI is atomic (zero business logic). Views is shared pages/components.
|
||||
|
||||
**Tech Stack:** pnpm workspaces + catalog, Turborepo, TypeScript internal packages (export TS source, no build), Tailwind CSS v4, shadcn/ui.
|
||||
|
||||
**Scope:** Monorepo extraction only. Desktop app is a separate future plan.
|
||||
|
||||
**Branch:** `feat/monorepo-extraction` (from latest `main` at f57cf44e)
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
| Category | Files | Nature |
|
||||
|---|---|---|
|
||||
| Pure file moves | ~170 | Copy + fix relative imports |
|
||||
| Code changes needed | ~17 | ApiClient callback, store factories, props refactor, nav adapter |
|
||||
| Bulk import updates | ~140 consumer files | Mechanical find-and-replace |
|
||||
| New files to create | ~15 | package.json, tsconfig, turbo.json, platform layer, nav adapter |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure (Tasks 1-3)
|
||||
|
||||
### Task 1: Turborepo + workspace
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-workspace.yaml` — add `"packages/*"` to packages list, add `@tanstack/react-query` to catalog
|
||||
- Create: `turbo.json`
|
||||
- Modify: `package.json` (root) — add turbo devDep, update scripts to use turbo
|
||||
- Modify: `.gitignore` — add `.turbo`
|
||||
|
||||
**turbo.json:**
|
||||
```json
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"dev": { "cache": false, "persistent": true },
|
||||
"typecheck": { "dependsOn": ["^typecheck"] },
|
||||
"test": { "dependsOn": ["^typecheck"] },
|
||||
"lint": { "dependsOn": ["^typecheck"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `pnpm typecheck` passes through turbo.
|
||||
|
||||
**Commit:** `chore: add Turborepo and configure workspace for packages/*`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Shared TypeScript config
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/tsconfig/package.json`
|
||||
- Create: `packages/tsconfig/base.json`
|
||||
- Create: `packages/tsconfig/react-library.json`
|
||||
|
||||
**base.json** — strict, ESNext, bundler resolution, declaration maps.
|
||||
**react-library.json** — extends base, adds jsx: react-jsx and DOM lib.
|
||||
|
||||
All other packages will `"extends": "@multica/tsconfig/react-library.json"`.
|
||||
|
||||
**Commit:** `chore: add shared TypeScript config package`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Clean up empty package dirs
|
||||
|
||||
**Action:** `rm -rf packages/sdk packages/types packages/utils packages/ui`
|
||||
|
||||
These are leftover empty dirs (only contain node_modules).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: packages/core/ (Tasks 4-10)
|
||||
|
||||
### Task 4: Scaffold + move types/utils/logger
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/package.json` (name: @multica/core, deps: react, zustand, @tanstack/react-query, sonner)
|
||||
- Create: `packages/core/tsconfig.json` (extends @multica/tsconfig/react-library.json)
|
||||
- Move: `apps/web/shared/types/` → `packages/core/types/` (11 files, no changes needed)
|
||||
- Move: `apps/web/shared/logger.ts` → `packages/core/logger.ts` (no changes)
|
||||
- Move: `apps/web/shared/utils.ts` → `packages/core/utils.ts` (no changes)
|
||||
|
||||
**Verify:** `cd packages/core && npx tsc --noEmit`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Move API client (with onUnauthorized abstraction)
|
||||
|
||||
**Files:**
|
||||
- Move: `apps/web/shared/api/ws-client.ts` → `packages/core/api/ws-client.ts` (no changes)
|
||||
- Move: `apps/web/shared/api/client.ts` → `packages/core/api/client.ts` (**3 changes**)
|
||||
- Create: `packages/core/api/index.ts`
|
||||
|
||||
**Code changes in client.ts:**
|
||||
1. `import type { ... } from "@/shared/types"` → `from "../types"`
|
||||
2. `import { ... } from "@/shared/logger"` → `from "../logger"`
|
||||
3. Add `onUnauthorized?: () => void` to options, replace `handleUnauthorized()` body:
|
||||
```typescript
|
||||
// Before: localStorage.removeItem + window.location.href = "/"
|
||||
// After: this.token = null; this.workspaceId = null; this.options.onUnauthorized?.();
|
||||
```
|
||||
|
||||
**NOT moved:** `apps/web/shared/api/index.ts` (the singleton) — replaced by `apps/web/platform/api.ts` in Task 9.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Move stores
|
||||
|
||||
**Pure moves (fix imports only):**
|
||||
- `features/issues/store.ts` → `packages/core/issues/store.ts`
|
||||
- `features/issues/config/*.ts` → `packages/core/issues/config/` — fix `@/shared/types` → `../../types`
|
||||
- `features/issues/stores/view-store.ts` → `packages/core/issues/stores/view-store.ts` — fix imports
|
||||
- `features/issues/stores/view-store-context.tsx` → `packages/core/issues/stores/view-store-context.tsx`
|
||||
- `features/issues/stores/draft-store.ts` → `packages/core/issues/stores/draft-store.ts`
|
||||
- `features/issues/stores/issues-scope-store.ts` → `packages/core/issues/stores/issues-scope-store.ts`
|
||||
- `features/issues/stores/selection-store.ts` → `packages/core/issues/stores/selection-store.ts`
|
||||
- `features/navigation/store.ts` → `packages/core/navigation/store.ts` (no changes)
|
||||
- `features/modals/store.ts` → `packages/core/modals/store.ts` (no changes)
|
||||
|
||||
**Factory refactor (code changes):**
|
||||
- `features/auth/store.ts` → `packages/core/auth/store.ts` — change to `createAuthStore({ api, onLogin?, onLogout? })` factory
|
||||
- `features/workspace/store.ts` → `packages/core/workspace/store.ts` — change to `createWorkspaceStore(api)` factory
|
||||
|
||||
**Also move:**
|
||||
- `features/workspace/hooks.ts` → `packages/core/workspace/hooks.ts` — fix imports to relative
|
||||
|
||||
**view-store.ts special handling:** The dynamic `import("@/features/workspace")` for workspace sync — change to accept workspace store instance via `registerViewStoreForWorkspaceSync(viewStore, workspaceStore)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Move TanStack Query modules
|
||||
|
||||
**Pure moves (fix import paths only):**
|
||||
- `apps/web/core/issues/{queries,mutations,ws-updaters}.ts` → `packages/core/issues/`
|
||||
- `apps/web/core/inbox/{queries,mutations,ws-updaters}.ts` → `packages/core/inbox/`
|
||||
- `apps/web/core/workspace/{queries,mutations}.ts` → `packages/core/workspace/`
|
||||
- `apps/web/core/runtimes/queries.ts` → `packages/core/runtimes/`
|
||||
- `apps/web/core/query-client.ts` → `packages/core/query-client.ts`
|
||||
- `apps/web/core/provider.tsx` → `packages/core/provider.tsx`
|
||||
|
||||
All changes: `@/shared/api` → `../api`, `@/shared/types` → `../types`, `@core/xxx` → `./xxx` or `../xxx`
|
||||
|
||||
**Code change:**
|
||||
- `apps/web/core/hooks.ts` → `packages/core/hooks.ts` — refactor `useWorkspaceId()` to use React Context instead of importing workspace store directly:
|
||||
```typescript
|
||||
const WorkspaceIdContext = createContext<string | null>(null);
|
||||
export function WorkspaceIdProvider({ wsId, children }) { ... }
|
||||
export function useWorkspaceId() { return useContext(WorkspaceIdContext); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Move realtime + shared hooks
|
||||
|
||||
**Pure moves (fix imports):**
|
||||
- `features/realtime/hooks.ts` → `packages/core/realtime/hooks.ts`
|
||||
- `features/realtime/use-realtime-sync.ts` → `packages/core/realtime/use-realtime-sync.ts`
|
||||
- `shared/hooks/use-file-upload.ts` → `packages/core/hooks/use-file-upload.ts`
|
||||
|
||||
**Code change:**
|
||||
- `features/realtime/provider.tsx` → `packages/core/realtime/provider.tsx` — accept `wsUrl` prop instead of reading `process.env.NEXT_PUBLIC_WS_URL`
|
||||
|
||||
**Note:** `use-realtime-sync.ts` needs auth/workspace store access. Since these are now factories, the realtime provider should receive the store instances. Simplest: WSProvider accepts `authStore` and `workspaceStore` props, passes them to `useRealtimeSync`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Create platform bridge in apps/web/
|
||||
|
||||
**New files (all new code):**
|
||||
- `apps/web/platform/api.ts` — creates api singleton with `NEXT_PUBLIC_API_URL`, `onUnauthorized` with `window.location.href`
|
||||
- `apps/web/platform/auth.ts` — `export const useAuthStore = createAuthStore({ api, onLogin: setLoggedInCookie, onLogout: clearLoggedInCookie })`
|
||||
- `apps/web/platform/workspace.ts` — `export const useWorkspaceStore = createWorkspaceStore(api)`
|
||||
- `apps/web/platform/index.ts` — re-exports
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Update imports in apps/web/ + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~94 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/shared/types` | `@multica/core/types` |
|
||||
| `@/shared/api"` (singleton usage) | `@/platform/api"` |
|
||||
| `@/shared/logger` | `@multica/core/logger` |
|
||||
| `@/shared/utils` | `@multica/core/utils` |
|
||||
| `@/shared/hooks/` | `@multica/core/hooks/` |
|
||||
| `@core/` | `@multica/core/` |
|
||||
| `@/features/auth"` (useAuthStore) | `@/platform/auth"` |
|
||||
| `@/features/workspace"` (useWorkspaceStore) | `@/platform/workspace"` |
|
||||
| `@/features/workspace"` (useActorName) | `@multica/core/workspace/hooks"` |
|
||||
| `@/features/realtime` | `@multica/core/realtime` |
|
||||
| `@/features/navigation` | `@multica/core/navigation` |
|
||||
| `@/features/modals"` (store) | `@multica/core/modals"` |
|
||||
| `@/features/issues/store` | `@multica/core/issues` |
|
||||
| `@/features/issues/stores/` | `@multica/core/issues/stores/` |
|
||||
| `@/features/issues/config` | `@multica/core/issues/config` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/core": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `transpilePackages: ["@multica/core"]` to `next.config.ts`
|
||||
- Remove `"@core/*"` alias from `apps/web/tsconfig.json`
|
||||
|
||||
**Delete old files:**
|
||||
```
|
||||
apps/web/shared/types/, apps/web/shared/api/, apps/web/shared/logger.ts,
|
||||
apps/web/shared/utils.ts, apps/web/shared/hooks/, apps/web/core/,
|
||||
features/auth/store.ts, features/workspace/store.ts, features/workspace/hooks.ts,
|
||||
features/realtime/, features/navigation/store.ts, features/modals/store.ts,
|
||||
features/issues/store.ts, features/issues/stores/, features/issues/config/
|
||||
```
|
||||
|
||||
**Keep:** `features/auth/auth-cookie.ts`, `features/auth/initializer.tsx`, `features/landing/`
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(core): extract packages/core — headless business logic layer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: packages/ui/ (Tasks 11-16)
|
||||
|
||||
### Task 11: Scaffold packages/ui/
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/ui/package.json` (name: @multica/ui, deps: all @radix-ui/*, clsx, tailwind-merge, lucide-react, emoji-mart, react-markdown, shiki, etc.)
|
||||
- Create: `packages/ui/tsconfig.json` (extends shared config, with `@/lib/utils`, `@/hooks/*`, `@/components/ui/*` path aliases for internal shadcn imports)
|
||||
- Create: `packages/ui/components.json` (shadcn config for this package)
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Move shadcn + lib + hooks
|
||||
|
||||
**Pure moves (no code changes):**
|
||||
- `apps/web/components/ui/*.tsx` (56 files) → `packages/ui/components/ui/`
|
||||
- `apps/web/lib/utils.ts` → `packages/ui/lib/utils.ts`
|
||||
- `apps/web/hooks/{use-auto-scroll,use-mobile,use-scroll-fade}.ts` → `packages/ui/hooks/`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Extract CSS tokens
|
||||
|
||||
- Copy `@theme inline { ... }` + `:root` + `.dark` blocks from `globals.css` → `packages/ui/styles/tokens.css`
|
||||
- Update `globals.css`: replace inline tokens with `@import "@multica/ui/styles/tokens.css"` + add `@source` directives for packages
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Refactor + move common components
|
||||
|
||||
**Code changes (3 files):**
|
||||
- `actor-avatar.tsx` — remove `useActorName()`, accept `name/initials/avatarUrl/isAgent` props
|
||||
- `mention-hover-card.tsx` — remove `useQuery`, accept resolved data props
|
||||
- `reaction-bar.tsx` — remove `useActorName()`, add `getActorName` prop
|
||||
|
||||
**Pure moves (3 files):**
|
||||
- `file-upload-button.tsx`, `emoji-picker.tsx`, `quick-emoji-picker.tsx` → direct copy
|
||||
|
||||
All go to `packages/ui/components/common/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Move markdown components
|
||||
|
||||
**Code change (1 file):**
|
||||
- `Markdown.tsx` — add `renderMention?: (props: { type: string; id: string }) => ReactNode` prop, remove hardcoded `IssueMentionCard` import
|
||||
|
||||
**Pure moves (5 files):**
|
||||
- `CodeBlock.tsx`, `StreamingMarkdown.tsx`, `linkify.ts`, `mentions.ts`, `index.ts`
|
||||
|
||||
All go to `packages/ui/markdown/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Update imports + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~118 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/components/ui/` | `@multica/ui/components/ui/` |
|
||||
| `@/components/common/` | `@multica/ui/components/common/` |
|
||||
| `@/components/markdown` | `@multica/ui/markdown` |
|
||||
| `@/lib/utils` | `@multica/ui/lib/utils` |
|
||||
| `@/hooks/use-mobile` | `@multica/ui/hooks/use-mobile` |
|
||||
| `@/hooks/use-auto-scroll` | `@multica/ui/hooks/use-auto-scroll` |
|
||||
| `@/hooks/use-scroll-fade` | `@multica/ui/hooks/use-scroll-fade` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/ui": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `"@multica/ui"` to `transpilePackages` in `next.config.ts`
|
||||
- Update `apps/web/components.json` aliases to point to `@multica/ui`
|
||||
|
||||
**Delete:** `components/ui/`, `components/common/`, `components/markdown/`, `hooks/`, `lib/utils.ts`
|
||||
|
||||
**Keep:** `components/{theme-provider,theme-toggle,multica-icon,loading-indicator,spinner,locale-sync}.tsx`
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(ui): extract packages/ui — shared atomic UI layer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: packages/views/ + navigation (Tasks 17-22)
|
||||
|
||||
### Task 17: Create navigation adapter
|
||||
|
||||
**New files (all new code, ~60 lines total):**
|
||||
- `packages/views/package.json` (deps: @multica/core, @multica/ui, @dnd-kit/*, @tiptap/*, sonner, recharts)
|
||||
- `packages/views/tsconfig.json`
|
||||
- `packages/views/navigation/types.ts` — `NavigationAdapter` interface (push, replace, back, pathname, searchParams)
|
||||
- `packages/views/navigation/context.tsx` — `NavigationProvider` + `useNavigation()` hook
|
||||
- `packages/views/navigation/app-link.tsx` — `<AppLink>` component (replaces `next/link`)
|
||||
- `packages/views/navigation/index.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Create WebNavigationProvider
|
||||
|
||||
**New file:**
|
||||
- `apps/web/platform/navigation.tsx` — wraps `useRouter`/`usePathname`/`useSearchParams` into `NavigationAdapter`
|
||||
|
||||
Wire into dashboard layout.
|
||||
|
||||
---
|
||||
|
||||
### Task 19: Move feature UI components
|
||||
|
||||
**Next.js decouple (7 files, ~2 lines each):**
|
||||
|
||||
| File | Import change | JSX change |
|
||||
|---|---|---|
|
||||
| `issue-mention-card.tsx` | `next/link` → `../navigation` | `<Link` → `<AppLink` |
|
||||
| `board-card.tsx` | same | same |
|
||||
| `list-row.tsx` | same | same |
|
||||
| `issue-detail.tsx` | `next/link` + `next/navigation` → `../navigation` | `<Link` → `<AppLink`, `router.push` → `nav.push` |
|
||||
| `create-issue.tsx` | `next/navigation` → `../navigation` | `router.push` → `nav.push` |
|
||||
| `create-workspace.tsx` | same | same |
|
||||
|
||||
**Pure moves (~85 files, fix import paths only):**
|
||||
- `features/issues/components/` (24 files) → `packages/views/issues/components/`
|
||||
- `features/issues/hooks/` (3 files) → `packages/views/issues/hooks/`
|
||||
- `features/issues/utils/` (5 files) → `packages/views/issues/utils/`
|
||||
- `features/editor/` (16 files incl CSS) → `packages/views/editor/`
|
||||
- `features/modals/{create-issue,create-workspace,registry}.tsx` → `packages/views/modals/`
|
||||
- `features/my-issues/` (4 files) → `packages/views/my-issues/`
|
||||
- `features/skills/` (5 files) → `packages/views/skills/`
|
||||
- `features/runtimes/` (16 files) → `packages/views/runtimes/`
|
||||
- `features/workspace/components/workspace-avatar.tsx` → `packages/views/workspace/`
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Extract fat pages
|
||||
|
||||
Move logic from page.tsx files into packages/views/:
|
||||
|
||||
| Page | Lines | Target |
|
||||
|---|---|---|
|
||||
| `(dashboard)/agents/page.tsx` | 1,280 | `packages/views/agents/agents-page.tsx` |
|
||||
| `(dashboard)/inbox/page.tsx` | 468 | `packages/views/inbox/inbox-page.tsx` |
|
||||
| `(auth)/login/page.tsx` | 389 | `packages/views/auth/login-page.tsx` |
|
||||
|
||||
Each original page.tsx becomes a 3-line thin shell:
|
||||
```typescript
|
||||
"use client";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
export default function Page() { return <AgentsPage />; }
|
||||
```
|
||||
|
||||
Login page: pass `googleClientId` as prop instead of reading env var.
|
||||
|
||||
---
|
||||
|
||||
### Task 21: Update imports + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~18 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/features/issues/components` | `@multica/views/issues/components` |
|
||||
| `@/features/issues/hooks/` | `@multica/views/issues/hooks/` |
|
||||
| `@/features/editor` | `@multica/views/editor` |
|
||||
| `@/features/modals/` (components) | `@multica/views/modals/` |
|
||||
| `@/features/my-issues` | `@multica/views/my-issues` |
|
||||
| `@/features/skills` | `@multica/views/skills` |
|
||||
| `@/features/runtimes` | `@multica/views/runtimes` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/views": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `"@multica/views"` to `transpilePackages`
|
||||
- Add `@source "../../packages/views/**/*.tsx"` to `globals.css`
|
||||
|
||||
**Delete old feature files.**
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(views): extract packages/views — shared business UI + navigation adapter`
|
||||
|
||||
---
|
||||
|
||||
### Task 22: Final verification
|
||||
|
||||
```bash
|
||||
make check # typecheck + unit tests + Go tests + E2E
|
||||
cd apps/web && npx shadcn@latest add --dry-run badge # shadcn CLI works
|
||||
|
||||
# Package constraints
|
||||
grep -r "@multica/core" packages/ui/ || echo "PASS: ui/ has zero core imports"
|
||||
grep -r "react-dom" packages/core/ || echo "PASS: core/ has zero react-dom"
|
||||
grep -r "from \"next/" packages/views/ || echo "PASS: views/ has zero next/* imports"
|
||||
```
|
||||
|
||||
**Commit:** `chore: monorepo extraction complete — all checks pass`
|
||||
|
||||
---
|
||||
|
||||
## Final Directory Structure
|
||||
|
||||
```
|
||||
multica/
|
||||
├── packages/
|
||||
│ ├── tsconfig/ # Shared TS config
|
||||
│ ├── core/ # @multica/core — 三端共用 (零 react-dom)
|
||||
│ │ ├── api/ # ApiClient class + WSClient
|
||||
│ │ ├── types/ # 所有领域类型
|
||||
│ │ ├── auth/ # createAuthStore factory
|
||||
│ │ ├── workspace/ # createWorkspaceStore factory + useActorName
|
||||
│ │ ├── issues/ # stores, config, queries, mutations, ws-updaters
|
||||
│ │ ├── inbox/ # queries, mutations, ws-updaters
|
||||
│ │ ├── runtimes/ # queries
|
||||
│ │ ├── realtime/ # WSProvider, hooks, sync
|
||||
│ │ ├── navigation/ # useNavigationStore
|
||||
│ │ ├── modals/ # useModalStore
|
||||
│ │ └── hooks.ts # useWorkspaceId (Context-based)
|
||||
│ ├── ui/ # @multica/ui — Web+Desktop 共用 (零业务逻辑)
|
||||
│ │ ├── components/ui/ # 56 shadcn 组件
|
||||
│ │ ├── components/common/ # actor-avatar, emoji-picker... (纯 props)
|
||||
│ │ ├── markdown/ # Markdown, StreamingMarkdown (renderMention slot)
|
||||
│ │ ├── hooks/ # use-auto-scroll, use-mobile, use-scroll-fade
|
||||
│ │ ├── lib/utils.ts # cn()
|
||||
│ │ └── styles/tokens.css
|
||||
│ └── views/ # @multica/views — Web+Desktop 共用页面
|
||||
│ ├── navigation/ # NavigationAdapter + AppLink
|
||||
│ ├── issues/ # IssuesPage, IssueDetail, BoardView...
|
||||
│ ├── editor/ # ContentEditor, TitleEditor
|
||||
│ ├── modals/ # CreateIssue, CreateWorkspace
|
||||
│ ├── agents/ # AgentsPage (从 1280 行 page.tsx 提取)
|
||||
│ ├── inbox/ # InboxPage (从 468 行 page.tsx 提取)
|
||||
│ ├── auth/ # LoginPage (从 389 行 page.tsx 提取)
|
||||
│ ├── my-issues/ # MyIssuesPage
|
||||
│ ├── skills/ # SkillsPage
|
||||
│ └── runtimes/ # RuntimesPage
|
||||
├── apps/
|
||||
│ └── web/
|
||||
│ ├── app/ # Next.js 路由薄壳 (每个 page < 15 行)
|
||||
│ ├── platform/ # Web 平台适配 (api 单例, auth store, nav provider)
|
||||
│ ├── features/
|
||||
│ │ ├── auth/ # auth-cookie.ts (Web 独有) + initializer.tsx
|
||||
│ │ └── landing/ # Landing 页面 (Web 独有, 用 next/image)
|
||||
│ └── components/ # theme-provider, multica-icon 等 app 级组件
|
||||
├── turbo.json
|
||||
└── pnpm-workspace.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Order & Commits
|
||||
|
||||
| # | Commit | 影响范围 | 风险 |
|
||||
|---|---|---|---|
|
||||
| 1 | `chore: Turborepo + workspace` | 配置文件 | 低 |
|
||||
| 2 | `chore: shared TypeScript config` | 新文件 | 低 |
|
||||
| 3 | `feat(core): extract packages/core` | 94 文件 import 变更 | 中 — 最大批量替换 |
|
||||
| 4 | `feat(ui): extract packages/ui` | 118 文件 import 变更 | 中 — 最多文件 |
|
||||
| 5 | `feat(views): extract packages/views` | 18 文件 + 3 胖壳 | 中 |
|
||||
| 6 | `chore: final verification` | 0 | 低 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,868 +0,0 @@
|
||||
# Monorepo Full Extraction Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 让每个 app 只剩路由定义 + NavigationAdapter + 真正独有的功能(landing page、title bar、cookie)。所有业务逻辑、UI、状态管理、API、WS 全部在共享包里,零重复。
|
||||
|
||||
**核心洞察:** Electron renderer 就是浏览器。localStorage、fetch、WebSocket 和 Next.js 客户端页面完全一样。URL 是环境配置不是 app 差异。所以除了 NavigationAdapter(路由框架不同),没有任何东西需要在每个 app 里单独写。
|
||||
|
||||
**Architecture:** `@multica/core` 自带完整初始化(API、stores、WS),不需要每个 app 调用 factory。`@multica/views` 包含所有页面和 layout。每个 app 只提供路由壳子。
|
||||
|
||||
**Tech Stack:** React 19, TanStack Query, Zustand, Tailwind CSS v4, shadcn/ui, TypeScript strict mode.
|
||||
|
||||
**Branch:** `feat/monorepo-extraction` (from latest `feat/desktop-app`)
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
| Phase | Tasks | What it achieves |
|
||||
|---|---|---|
|
||||
| Phase 1: Core 自包含初始化 | 1-2 | core 自己初始化 API/stores/WS,app 不需要写任何 platform 代码 |
|
||||
| Phase 2: Sidebar & Layout | 3-5 | 共享 AppSidebar + DashboardLayout,删除两端重复 |
|
||||
| Phase 3: Login | 6-7 | 共享 LoginPage + AuthInitializer |
|
||||
| Phase 4: Agents | 8-10 | 1,279 行 → 共享模块 |
|
||||
| Phase 5: Inbox | 11-13 | 468 行 → 共享模块 |
|
||||
| Phase 6: Settings | 14-16 | 1,277 行 → 共享模块 |
|
||||
| Phase 7: 清理 | 17-18 | 删除所有 platform 目录、placeholder、死代码 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core 自包含初始化
|
||||
|
||||
### 设计思路
|
||||
|
||||
现在每个 app 都要手动调用 `new ApiClient()`、`createAuthStore()`、`createWorkspaceStore()`、包 `<WSProvider>`。但这些逻辑在两个 app 里完全一样。
|
||||
|
||||
方案:`@multica/core` 导出一个 `<CoreProvider>` 包裹整个应用。它内部自动完成所有初始化。配置通过环境变量(`VITE_API_URL` / `NEXT_PUBLIC_API_URL`)或 prop 注入。SSR-safe 的 localStorage wrapper 内置到 core 里作为默认 storage(`typeof window` 守卫对 Electron 无害)。
|
||||
|
||||
```tsx
|
||||
// 任何 app 的根组件,只需要这样:
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? ""}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
onLogin={setLoggedInCookie} // 可选,Web 独有
|
||||
onLogout={clearLoggedInCookie} // 可选,Web 独有
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
Desktop 更简单(没有可选回调):
|
||||
```tsx
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
### Task 1: 在 `@multica/core` 里创建 CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/platform/storage.ts` — 内置 SSR-safe localStorage
|
||||
- Create: `packages/core/platform/core-provider.tsx` — CoreProvider 组件
|
||||
- Create: `packages/core/platform/auth-initializer.tsx` — 共享 AuthInitializer
|
||||
- Create: `packages/core/platform/types.ts` — CoreProviderProps
|
||||
- Create: `packages/core/platform/index.ts` — barrel export
|
||||
- Modify: `packages/core/package.json` — add `"./platform"` export
|
||||
|
||||
**Step 1: Create built-in SSR-safe storage**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/storage.ts
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
|
||||
export const defaultStorage: StorageAdapter = {
|
||||
getItem: (k) => (typeof window !== "undefined" ? localStorage.getItem(k) : null),
|
||||
setItem: (k, v) => { if (typeof window !== "undefined") localStorage.setItem(k, v); },
|
||||
removeItem: (k) => { if (typeof window !== "undefined") localStorage.removeItem(k); },
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Create types**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/types.ts
|
||||
export interface CoreProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** API base URL. Default: "" (same-origin). */
|
||||
apiBaseUrl?: string;
|
||||
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
|
||||
wsUrl?: string;
|
||||
/** Called after successful login (e.g. set cookie for Next.js middleware). */
|
||||
onLogin?: () => void;
|
||||
/** Called after logout (e.g. clear cookie). */
|
||||
onLogout?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create AuthInitializer**
|
||||
|
||||
Merge the identical logic from both apps. Uses `defaultStorage`, reads from existing singletons.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/auth-initializer.tsx
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
export function AuthInitializer({
|
||||
children,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getApi();
|
||||
api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Create CoreProvider**
|
||||
|
||||
This is the one component that wires everything together. Each app wraps its root with this.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/core-provider.tsx
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { AuthInitializer } from "./auth-initializer";
|
||||
import type { CoreProviderProps } from "./types";
|
||||
|
||||
// Module-level singletons — created once, shared across renders.
|
||||
let initialized = false;
|
||||
let authStore: ReturnType<typeof createAuthStore>;
|
||||
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
|
||||
|
||||
function initCore(apiBaseUrl: string) {
|
||||
if (initialized) return;
|
||||
|
||||
const api = new ApiClient(apiBaseUrl, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
},
|
||||
});
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate token from storage
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
|
||||
authStore = createAuthStore({ api, storage: defaultStorage });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, {
|
||||
storage: defaultStorage,
|
||||
});
|
||||
registerWorkspaceStore(workspaceStore);
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function CoreProvider({
|
||||
children,
|
||||
apiBaseUrl = "",
|
||||
wsUrl = "ws://localhost:8080/ws",
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render
|
||||
useMemo(() => initCore(apiBaseUrl), [apiBaseUrl]);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout}>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={defaultStorage}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
</AuthInitializer>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/index.ts
|
||||
export { CoreProvider } from "./core-provider";
|
||||
export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
```
|
||||
|
||||
Add to `packages/core/package.json` exports:
|
||||
```json
|
||||
"./platform": "./platform/index.ts"
|
||||
```
|
||||
|
||||
**Step 6: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/core/platform/ packages/core/package.json
|
||||
git commit -m "feat(core): add CoreProvider — single component for full app initialization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate both apps to CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/layout.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Modify: `apps/desktop/src/renderer/src/App.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Delete: `apps/web/platform/api.ts`
|
||||
- Delete: `apps/web/platform/auth.ts`
|
||||
- Delete: `apps/web/platform/workspace.ts`
|
||||
- Delete: `apps/web/platform/storage.ts`
|
||||
- Delete: `apps/web/platform/ws-provider.tsx`
|
||||
- Delete: `apps/web/features/auth/initializer.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/api.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/workspace.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/storage.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/ws-provider.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth-initializer.tsx`
|
||||
- Keep: `apps/web/platform/navigation.tsx` — NavigationAdapter (唯一不可共享)
|
||||
- Keep: `apps/desktop/src/renderer/src/platform/navigation.tsx` — NavigationAdapter
|
||||
- Keep: `apps/web/features/auth/auth-cookie.ts` — Web 独有
|
||||
|
||||
**Step 1: Update web root layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/layout.tsx
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
<WebNavigationProvider>
|
||||
{children}
|
||||
</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update desktop App.tsx**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/App.tsx
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { router } from "./router";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL}
|
||||
wsUrl={import.meta.env.VITE_WS_URL}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Fix all `@/platform/*` imports across both apps**
|
||||
|
||||
Search all files for:
|
||||
- `from "@/platform/api"` → `from "@multica/core/api"` (use singleton proxy `api`)
|
||||
- `from "@/platform/auth"` → `from "@multica/core/auth"` (use singleton `useAuthStore`)
|
||||
- `from "@/platform/workspace"` → `from "@multica/core/workspace"` (use singleton `useWorkspaceStore`)
|
||||
|
||||
These singletons already exist and are registered by CoreProvider on init. Every component can import them directly from core.
|
||||
|
||||
**Step 4: Delete all platform files except navigation**
|
||||
|
||||
Web — delete entire `apps/web/platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/web/platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
Desktop — delete entire `apps/desktop/.../platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/desktop/.../platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
**Step 5: Run typecheck + tests**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: migrate both apps to CoreProvider — delete all platform duplication"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Sidebar & Layout
|
||||
|
||||
### Task 3: Extract `AppSidebar` to `@multica/views/layout`
|
||||
|
||||
**Why:** Web and Desktop sidebars are 99% identical (239 vs 236 lines). Only difference: `Link`/`usePathname`/`useRouter` (web) vs `AppLink`/`useNavigation` (desktop). Since `useNavigation` + `AppLink` is the abstraction in views, the desktop version is already the correct shared version.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/app-sidebar.tsx` — copy from desktop version
|
||||
- Create: `packages/views/layout/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./layout"` export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` — import from views
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` — import from views
|
||||
- Delete: `apps/web/app/(dashboard)/_components/app-sidebar.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/components/app-sidebar.tsx`
|
||||
|
||||
**Step 1: Create shared AppSidebar**
|
||||
|
||||
Copy desktop `app-sidebar.tsx` into `packages/views/layout/app-sidebar.tsx`. Key changes:
|
||||
- `import { useAuthStore } from "@multica/core/auth"` (singleton)
|
||||
- `import { useWorkspaceStore } from "@multica/core/workspace"` (singleton)
|
||||
- `import { api } from "@multica/core/api"` (singleton proxy)
|
||||
- `import { useNavigation, AppLink } from "../navigation"` (relative within views)
|
||||
- `import { useModalStore } from "@multica/core/modals"`
|
||||
- All `@multica/ui` imports unchanged
|
||||
|
||||
**Step 2: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/index.ts
|
||||
export { AppSidebar } from "./app-sidebar";
|
||||
```
|
||||
|
||||
Add to `packages/views/package.json`:
|
||||
```json
|
||||
"./layout": "./layout/index.ts"
|
||||
```
|
||||
|
||||
**Step 3: Update both apps, delete old files**
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared AppSidebar to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract `DashboardLayout` to `@multica/views/layout`
|
||||
|
||||
**Why:** Both apps have identical dashboard shell: auth guard → loading → sidebar + workspace provider + content. Only differences: web has `SearchCommand`, desktop has `TitleBar`. These are slots.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/dashboard-layout.tsx`
|
||||
- Modify: `packages/views/layout/index.ts` (add export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` (~10 lines after)
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared DashboardLayout**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/dashboard-layout.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { ModalRegistry } from "../modals/registry";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
/** Above sidebar (e.g. desktop TitleBar) */
|
||||
header?: ReactNode;
|
||||
/** Sibling of SidebarInset (e.g. web SearchCommand) */
|
||||
extra?: ReactNode;
|
||||
/** Loading indicator */
|
||||
loadingIndicator?: ReactNode;
|
||||
/** Redirect path when not authenticated. Default: "/" */
|
||||
loginPath?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
children, header, extra, loadingIndicator, loginPath = "/",
|
||||
}: DashboardLayoutProps) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) push(loginPath);
|
||||
}, [user, isLoading, push, loginPath]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<SidebarProvider className="flex-1">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{workspace ? (
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</WorkspaceIdProvider>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
{extra}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Slim down web layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/layout.tsx
|
||||
"use client";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
extra={<SearchCommand />}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Slim down desktop shell**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/components/dashboard-shell.tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { TitleBar } from "./title-bar";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
export function DashboardShell() {
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<DashboardLayout
|
||||
header={<TitleBar />}
|
||||
loginPath="/login"
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardLayout>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared DashboardLayout to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Build + smoke test
|
||||
|
||||
Run: `pnpm build && make check`
|
||||
|
||||
Fix any issues, commit:
|
||||
```bash
|
||||
git commit -m "fix: fixups from layout extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Shared Login Page
|
||||
|
||||
### Task 6: Extract `LoginPage` to `@multica/views/auth`
|
||||
|
||||
**Why:** Desktop login (139 lines) is a simple email/code form. Web login (393 lines) has extra: CLI callback, Google OAuth, OTP component. Strategy: extract the core email/code form to views. Desktop uses it directly. Web keeps its own richer version (too different to merge).
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/auth/login-page.tsx`
|
||||
- Create: `packages/views/auth/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./auth"` export)
|
||||
- Modify: `apps/desktop/src/renderer/src/pages/login.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared LoginPage**
|
||||
|
||||
Props: `logo?: ReactNode`, `onSuccess: () => void`. Internally uses `useAuthStore`/`useWorkspaceStore`/`api` from core singletons.
|
||||
|
||||
**Step 2: Update desktop login**
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "../components/multica-icon";
|
||||
import { TitleBar } from "../components/title-bar";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<TitleBar />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => navigate("/issues", { replace: true })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Web login stays as-is (CLI callback + Google OAuth = web-only features).
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract shared LoginPage to @multica/views/auth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verify login flow in both apps
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Extract Agents Page (1,279 lines → shared module)
|
||||
|
||||
### Task 8: Create `@multica/views/agents`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/agents/config.ts` — statusConfig, taskStatusConfig
|
||||
- Create: `packages/views/agents/components/agents-page.tsx` — main page
|
||||
- Create: `packages/views/agents/components/create-agent-dialog.tsx`
|
||||
- Create: `packages/views/agents/components/agent-list-item.tsx`
|
||||
- Create: `packages/views/agents/components/agent-detail.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/instructions-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/skills-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/tasks-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/settings-tab.tsx`
|
||||
- Create: `packages/views/agents/components/index.ts`
|
||||
- Create: `packages/views/agents/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./agents"` export)
|
||||
|
||||
**Key migration:** All `@/platform/*` imports → `@multica/core/*` singletons. All `@multica/ui` and `@multica/core` imports stay as-is. `@multica/views` imports become relative.
|
||||
|
||||
**Step 1:** Extract config → components → barrel
|
||||
**Step 2:** Run `pnpm typecheck`
|
||||
**Step 3:** Commit
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract agents page to @multica/views/agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire web agents route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/agents/page.tsx — 1 line replaces 1,279
|
||||
export { AgentsPage as default } from "@multica/views/agents";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace agents page with @multica/views/agents import`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Wire desktop agents route
|
||||
|
||||
```typescript
|
||||
// router.tsx
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
{ path: "agents", element: <AgentsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire agents page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Extract Inbox Page (468 lines → shared module)
|
||||
|
||||
### Task 11: Create `@multica/views/inbox`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/inbox/components/inbox-page.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-list-item.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-detail-label.tsx`
|
||||
- Create: `packages/views/inbox/components/index.ts`
|
||||
- Create: `packages/views/inbox/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./inbox"` export)
|
||||
|
||||
**Key migration:**
|
||||
- `import { useSearchParams } from "next/navigation"` → `import { useNavigation } from "../navigation"` — use `searchParams` from adapter
|
||||
- `window.history.replaceState(null, "", url)` → `replace(url)` from `useNavigation()`
|
||||
- `@/platform/*` → `@multica/core/*` singletons
|
||||
|
||||
Commit: `feat(views): extract inbox page to @multica/views/inbox`
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Wire web inbox route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/inbox/page.tsx — 1 line replaces 468
|
||||
export { InboxPage as default } from "@multica/views/inbox";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace inbox page with @multica/views/inbox import`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Wire desktop inbox route
|
||||
|
||||
```typescript
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
{ path: "inbox", element: <InboxPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire inbox page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Extract Settings Page (1,277 lines → shared module)
|
||||
|
||||
### Task 14: Create `@multica/views/settings`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/settings/components/settings-page.tsx`
|
||||
- Create: `packages/views/settings/components/account-tab.tsx`
|
||||
- Create: `packages/views/settings/components/appearance-tab.tsx`
|
||||
- Create: `packages/views/settings/components/tokens-tab.tsx`
|
||||
- Create: `packages/views/settings/components/workspace-tab.tsx`
|
||||
- Create: `packages/views/settings/components/members-tab.tsx`
|
||||
- Create: `packages/views/settings/components/repositories-tab.tsx`
|
||||
- Create: `packages/views/settings/components/index.ts`
|
||||
- Create: `packages/views/settings/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./settings"` export)
|
||||
|
||||
**Key migration:** Same pattern — `@/platform/*` → `@multica/core/*` singletons.
|
||||
|
||||
Commit: `feat(views): extract settings page to @multica/views/settings`
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Wire web settings route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/settings/page.tsx — 1 line replaces 1,277 (page + 6 tabs)
|
||||
export { SettingsPage as default } from "@multica/views/settings";
|
||||
```
|
||||
|
||||
Delete `apps/web/app/(dashboard)/settings/_components/` (all 6 files).
|
||||
|
||||
Commit: `refactor(web): replace settings page with @multica/views/settings import`
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Wire desktop settings route
|
||||
|
||||
```typescript
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
{ path: "settings", element: <SettingsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire settings page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Cleanup
|
||||
|
||||
### Task 17: Delete dead code
|
||||
|
||||
- Delete `apps/desktop/src/renderer/src/pages/placeholder.tsx`
|
||||
- Delete `apps/web/platform/` directory entirely (only `navigation.tsx` remains — move to `apps/web/app/` or `apps/web/lib/`)
|
||||
- Delete `apps/desktop/src/renderer/src/platform/` directory (only `navigation.tsx` remains — move)
|
||||
- Remove unused imports across both apps
|
||||
- Clean up `apps/web/features/auth/` — only `auth-cookie.ts` should remain
|
||||
|
||||
Commit: `chore: delete dead platform code after monorepo extraction`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Full verification
|
||||
|
||||
Run: `make check`
|
||||
Expected: ALL PASS
|
||||
|
||||
---
|
||||
|
||||
## Final Architecture
|
||||
|
||||
### Each app after extraction
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/
|
||||
│ ├── layout.tsx # CoreProvider + WebNavigationProvider + ThemeProvider
|
||||
│ ├── (auth)/login/page.tsx # Web 独有:CLI callback, Google OAuth
|
||||
│ ├── (dashboard)/
|
||||
│ │ ├── layout.tsx # DashboardLayout + SearchCommand (10 行)
|
||||
│ │ ├── issues/page.tsx # 1 行 re-export
|
||||
│ │ ├── agents/page.tsx # 1 行 re-export
|
||||
│ │ ├── inbox/page.tsx # 1 行 re-export
|
||||
│ │ ├── settings/page.tsx # 1 行 re-export
|
||||
│ │ └── ... (all 1-line)
|
||||
│ └── (landing)/ # Web 独有
|
||||
├── lib/
|
||||
│ └── navigation.tsx # WebNavigationProvider(唯一平台代码)
|
||||
├── features/
|
||||
│ ├── auth/auth-cookie.ts # Web 独有
|
||||
│ ├── landing/ # Web 独有
|
||||
│ └── search/ # Web 独有
|
||||
└── components/ # theme, icon, loading (少量)
|
||||
|
||||
apps/desktop/
|
||||
├── src/main/ # Electron 主进程
|
||||
├── src/preload/ # preload bridge
|
||||
├── src/renderer/src/
|
||||
│ ├── App.tsx # CoreProvider + RouterProvider + ThemeProvider
|
||||
│ ├── router.tsx # 路由表(全部 @multica/views/*)
|
||||
│ ├── lib/
|
||||
│ │ └── navigation.tsx # DesktopNavigationProvider(唯一平台代码)
|
||||
│ ├── components/
|
||||
│ │ ├── dashboard-shell.tsx # DashboardLayout + TitleBar (10 行)
|
||||
│ │ ├── title-bar.tsx # Desktop 独有
|
||||
│ │ └── multica-icon.tsx # Desktop 独有
|
||||
│ └── pages/
|
||||
│ └── login.tsx # LoginPage + TitleBar (10 行)
|
||||
```
|
||||
|
||||
### 数字对比
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| Web platform 文件 | 6 个 | 1 个 (navigation.tsx) |
|
||||
| Desktop platform 文件 | 7 个 | 1 个 (navigation.tsx) |
|
||||
| Web agents/page.tsx | 1,279 行 | 1 行 |
|
||||
| Web inbox/page.tsx | 468 行 | 1 行 |
|
||||
| Web settings/ 总计 | 1,277 行 | 1 行 |
|
||||
| Web sidebar | 239 行 | 0 (共享) |
|
||||
| Desktop sidebar | 236 行 (重复) | 0 (共享) |
|
||||
| Desktop placeholders | 3 个 | 0 |
|
||||
| 共享 views 模块 | 7 个 | 12 个 |
|
||||
| 两端重复代码 | ~1,500 行 | 0 行 |
|
||||
@@ -1,319 +0,0 @@
|
||||
# Upload & Attachment Fixes Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix 5 interrelated upload/attachment issues discovered during drag-upload development (MUL-410).
|
||||
|
||||
**Architecture:** Three backend fixes (content-type sniffing, Content-Disposition, list API optimization) + one frontend fix (decouple description editor uploads from attachment records) + one no-code confirmation (agent file discovery paths). All changes follow existing patterns — no new abstractions.
|
||||
|
||||
**Tech Stack:** Go backend (Chi, sqlc, S3), Next.js frontend (TanStack Query, Tiptap editor), PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| # | Issue | Type | Files |
|
||||
|---|-------|------|-------|
|
||||
| 1 | SVG content-type sniffing | Backend bug | `server/internal/handler/file.go` |
|
||||
| 2 | Content-Disposition inline vs attachment | Backend bug | `server/internal/storage/s3.go` |
|
||||
| 3 | Attachment records / editor sync | Frontend fix | `packages/views/issues/components/issue-detail.tsx` |
|
||||
| 4 | List Issues returns full description | Backend optimization | `server/pkg/db/queries/issue.sql`, `server/internal/handler/issue.go`, `server/pkg/db/generated/issue.sql.go` |
|
||||
| 5 | Agent file discovery redundancy | No code change | Confirmed by #3 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SVG Content-Type Extension Fallback
|
||||
|
||||
**Problem:** `http.DetectContentType()` returns `text/xml` for SVG files. CloudFront serves them with wrong content-type, `<img>` tags can't render.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/file.go:1-16` (imports), `server/internal/handler/file.go:123` (after sniff)
|
||||
|
||||
**Step 1: Add extension-based content-type override map and import**
|
||||
|
||||
After line 16 (imports block), add `"strings"` import and a package-level `extContentTypes` map. After line 123 (`contentType := http.DetectContentType(buf[:n])`), add fallback lookup:
|
||||
|
||||
```go
|
||||
// In imports, add "strings"
|
||||
|
||||
// After the imports block:
|
||||
var extContentTypes = map[string]string{
|
||||
".svg": "image/svg+xml",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
".json": "application/json",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
// After line 123 (contentType := http.DetectContentType(buf[:n])):
|
||||
if ct, ok := extContentTypes[strings.ToLower(path.Ext(header.Filename))]; ok {
|
||||
contentType = ct
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build and verify**
|
||||
|
||||
Run: `cd server && go build ./...`
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/handler/file.go
|
||||
git commit -m "fix(upload): add extension-based content-type fallback for SVG and other sniff-misdetected types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Content-Disposition Inline vs Attachment
|
||||
|
||||
**Problem:** All uploads set `Content-Disposition: inline`. Browsers display CSV/PDF inline instead of downloading.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/storage/s3.go:126-136` (Upload function)
|
||||
|
||||
**Step 1: Add disposition logic in Upload function**
|
||||
|
||||
Before the `PutObject` call (line 128), determine disposition based on content-type. Images, video, audio, and PDF stay `inline`; everything else becomes `attachment`:
|
||||
|
||||
```go
|
||||
// Add before PutObject call:
|
||||
func isInlineContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/") ||
|
||||
strings.HasPrefix(ct, "video/") ||
|
||||
strings.HasPrefix(ct, "audio/") ||
|
||||
ct == "application/pdf"
|
||||
}
|
||||
|
||||
// In Upload(), after sanitizeFilename:
|
||||
disposition := "attachment"
|
||||
if isInlineContentType(contentType) {
|
||||
disposition = "inline"
|
||||
}
|
||||
|
||||
// Change line 133 from:
|
||||
ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)),
|
||||
// To:
|
||||
ContentDisposition: aws.String(fmt.Sprintf(`%s; filename="%s"`, disposition, safe)),
|
||||
```
|
||||
|
||||
**Step 2: Build and verify**
|
||||
|
||||
Run: `cd server && go build ./...`
|
||||
Expected: Clean build.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/storage/s3.go
|
||||
git commit -m "fix(upload): use Content-Disposition attachment for non-media files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Decouple Description Editor Uploads from Attachment Records
|
||||
|
||||
**Problem:** Description editor uploads create attachment records linked to the issue. When users delete images from the editor, attachment records become stale. The URL already lives in the markdown — attachment records are redundant for description content.
|
||||
|
||||
**Fix:** Description editor uploads should NOT pass `issueId`. Comment/reply uploads continue passing `issueId` (comments are not frequently edited, and agents need attachment records for comment file discovery).
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/views/issues/components/issue-detail.tsx:339-341`
|
||||
|
||||
**Step 1: Remove issueId from description upload**
|
||||
|
||||
Change the `handleDescriptionUpload` callback (line 339-341) from:
|
||||
|
||||
```typescript
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
);
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```typescript
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file),
|
||||
[uploadWithToast],
|
||||
);
|
||||
```
|
||||
|
||||
This means description image uploads will still go to S3 and return a URL (which gets embedded in the markdown), but no `attachment` DB record will be linked to the issue. The backend `UploadFile` handler already handles this — when no `issue_id` form field is sent, the attachment record is created without an issue link (or falls back to the no-workspace path for non-workspace uploads, but workspace context is still present via headers so a record IS still created, just without `issue_id` set).
|
||||
|
||||
**Step 2: Verify typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: Clean.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/views/issues/components/issue-detail.tsx
|
||||
git commit -m "fix(editor): decouple description uploads from attachment records"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Omit Description from List Issues Response
|
||||
|
||||
**Problem:** `GET /api/issues` returns full `description` for every issue. With embedded images, descriptions contain CDN URLs making list payloads large. List pages only show titles.
|
||||
|
||||
**Approach:** Change `ListIssues` and `ListOpenIssues` SQL queries to select specific columns (excluding `description`, `acceptance_criteria`, `context_refs`). Regenerate sqlc. Add converter functions for the new row types. Frontend already handles `null` description gracefully.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/pkg/db/queries/issue.sql` (lines 1-8, 60-66)
|
||||
- Regenerate: `server/pkg/db/generated/issue.sql.go`
|
||||
- Modify: `server/internal/handler/issue.go` (add converters, update ListIssues handler)
|
||||
|
||||
**Step 1: Update SQL queries**
|
||||
|
||||
Change `ListIssues` (lines 1-8) from `SELECT *` to explicit columns:
|
||||
|
||||
```sql
|
||||
-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, position, due_date, created_at, updated_at, number, project_id
|
||||
FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
```
|
||||
|
||||
Change `ListOpenIssues` (lines 60-66) similarly:
|
||||
|
||||
```sql
|
||||
-- name: ListOpenIssues :many
|
||||
SELECT id, workspace_id, title, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, position, due_date, created_at, updated_at, number, project_id
|
||||
FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND status NOT IN ('done', 'cancelled')
|
||||
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
```
|
||||
|
||||
**Step 2: Regenerate sqlc**
|
||||
|
||||
Run: `make sqlc`
|
||||
|
||||
This will generate `ListIssuesRow` and `ListOpenIssuesRow` types without `Description`, `AcceptanceCriteria`, `ContextRefs`.
|
||||
|
||||
**Step 3: Add converter functions in issue.go**
|
||||
|
||||
After `issueToResponse` (line 66), add two new converters for the list row types:
|
||||
|
||||
```go
|
||||
func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueResponse {
|
||||
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
||||
return IssueResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
Number: i.Number,
|
||||
Identifier: identifier,
|
||||
Title: i.Title,
|
||||
Status: i.Status,
|
||||
Priority: i.Priority,
|
||||
AssigneeType: textToPtr(i.AssigneeType),
|
||||
AssigneeID: uuidToPtr(i.AssigneeID),
|
||||
CreatorType: i.CreatorType,
|
||||
CreatorID: uuidToString(i.CreatorID),
|
||||
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
||||
ProjectID: uuidToPtr(i.ProjectID),
|
||||
Position: i.Position,
|
||||
DueDate: timestampToPtr(i.DueDate),
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
UpdatedAt: timestampToString(i.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
|
||||
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
||||
return IssueResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
Number: i.Number,
|
||||
Identifier: identifier,
|
||||
Title: i.Title,
|
||||
Status: i.Status,
|
||||
Priority: i.Priority,
|
||||
AssigneeType: textToPtr(i.AssigneeType),
|
||||
AssigneeID: uuidToPtr(i.AssigneeID),
|
||||
CreatorType: i.CreatorType,
|
||||
CreatorID: uuidToString(i.CreatorID),
|
||||
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
||||
ProjectID: uuidToPtr(i.ProjectID),
|
||||
Position: i.Position,
|
||||
DueDate: timestampToPtr(i.DueDate),
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
UpdatedAt: timestampToString(i.UpdatedAt),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Update ListIssues handler**
|
||||
|
||||
In `ListIssues` handler:
|
||||
- Line 257: change `issueToResponse(issue, prefix)` → `openIssueRowToResponse(issue, prefix)`
|
||||
- Line 312: change `issueToResponse(issue, prefix)` → `issueListRowToResponse(issue, prefix)`
|
||||
|
||||
**Step 5: Build and verify**
|
||||
|
||||
Run: `cd server && go build ./...`
|
||||
Expected: Clean build.
|
||||
|
||||
**Frontend impact (no changes needed):**
|
||||
- Board card (board-card.tsx:61): `storeProperties.description && issue.description` — short-circuits on `null`, won't render description. Correct behavior.
|
||||
- Issue detail (issue-detail.tsx:210): `initialData: () => allIssues.find(...)` — the seeded issue will have `null` description, but the detail query fetches full issue with description. Brief loading state is acceptable.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/pkg/db/queries/issue.sql server/pkg/db/generated/issue.sql.go server/internal/handler/issue.go
|
||||
git commit -m "perf(api): omit description from list issues response to reduce payload size"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Confirm Agent File Discovery (No Code Change)
|
||||
|
||||
**Confirmation:** With Task 3 implemented:
|
||||
- **Description files:** Agent reads issue description markdown → finds CDN URLs directly. No attachment record needed.
|
||||
- **Comment files:** Agent uses `GET /api/issues/{id}` → `attachments` array for issue-linked files, plus comment content markdown URLs.
|
||||
- **CLI attachment download:** `multica attachment download <id>` works for files that DO have attachment records (comment uploads).
|
||||
- **No redundancy:** Two paths serve different purposes — markdown URLs for inline content, attachment records for standalone files.
|
||||
|
||||
No code change required. This task is resolved by Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Run Full Verification
|
||||
|
||||
**Step 1: Run all checks**
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
This runs: typecheck → TS tests → Go tests → E2E.
|
||||
|
||||
**Step 2: Fix any failures and re-run**
|
||||
|
||||
**Step 3: Final commit if any fixes needed**
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
Tasks 1, 2, 3 are independent — can be parallelized.
|
||||
Task 4 depends on sqlc regeneration.
|
||||
Task 5 is confirmation only.
|
||||
Task 6 runs after all code changes.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,357 +0,0 @@
|
||||
# Unify Workspace Identity Resolver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix broken file uploads caused by the workspace slug refactor (v2, PR #1138/#1141), and eliminate the structural bug source that allowed it. File uploads from within a workspace on the desktop and web apps currently land in S3 without a corresponding DB attachment record — the file is orphaned and the UI never sees it.
|
||||
|
||||
**Architecture:** The server currently has **two independent implementations** of the same logic — extract the workspace UUID from an HTTP request. One lives in the workspace middleware (post-v2, accepts slug header → DB lookup → UUID). The other lives inside the handler package (pre-v2, only accepts UUID header/query). The v2 refactor updated the middleware one and forgot the handler one; routes that sit *outside* the workspace middleware group (notably `/api/upload-file`) still run through the stale resolver and can't translate the frontend's new `X-Workspace-Slug` header.
|
||||
|
||||
The root cause is duplication. The fix is to collapse both resolvers into a single shared function that middleware and handlers both delegate to, so any future change to "how do we read workspace identity" is impossible to forget. The existing middleware's resolver already has the full logic; we extract it into a package-level function and have the handler helper call it.
|
||||
|
||||
**Tech Stack:** Go (Chi router, sqlc, pgx).
|
||||
|
||||
**Non-goals:**
|
||||
- No frontend changes. The frontend has been sending `X-Workspace-Slug` since v2; this plan makes the server finish accepting it everywhere.
|
||||
- No route reshuffling. `/api/upload-file` stays outside `RequireWorkspaceMember` because it serves two distinct use cases (avatar upload + workspace attachment); the avatar path needs to work without a workspace context.
|
||||
- No change to CLI / daemon clients. They still send `X-Workspace-ID` (UUID); the resolver keeps UUID as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| # | Change | Type | Files |
|
||||
|---|--------|------|-------|
|
||||
| 1 | Extract shared resolver into middleware package | Refactor | `server/internal/middleware/workspace.go` |
|
||||
| 2 | Promote handler `resolveWorkspaceID` to `(h *Handler).resolveWorkspaceID` + delegate to shared | Refactor | `server/internal/handler/handler.go` |
|
||||
| 3 | Rename 47 call sites from `resolveWorkspaceID(r)` → `h.resolveWorkspaceID(r)` | Mechanical | handler/*.go (see exhaustive list in task 3) |
|
||||
| 4 | Add test for upload-file with slug header | Test | `server/internal/handler/file_test.go` |
|
||||
| 5 | Add test for shared resolver | Test | `server/internal/middleware/workspace_test.go` |
|
||||
| 6 | `make check` and commit | Verify | — |
|
||||
|
||||
---
|
||||
|
||||
## Background: what's broken and why
|
||||
|
||||
**Frontend (current, post-v2):** `ApiClient.authHeaders()` in `packages/core/api/client.ts:121` sends:
|
||||
```
|
||||
X-Workspace-Slug: <slug>
|
||||
```
|
||||
|
||||
**Server middleware resolver** (`server/internal/middleware/workspace.go:53-86`, `resolveWorkspaceUUID`): accepts the slug header, looks up the slug via `queries.GetWorkspaceBySlug`, and writes the resolved UUID into the request context. Every handler behind `RequireWorkspaceMember` / `RequireWorkspaceRole` / `RequireWorkspaceMemberFromURL` sees the UUID in context and works correctly.
|
||||
|
||||
**Handler resolver** (`server/internal/handler/handler.go:155-165`, `resolveWorkspaceID`): a parallel implementation used by handlers that are NOT behind the workspace middleware. It only checks:
|
||||
1. `middleware.WorkspaceIDFromContext(r.Context())`
|
||||
2. `?workspace_id` query param
|
||||
3. `X-Workspace-ID` header
|
||||
|
||||
Never touches slug, because it has no `*db.Queries` access (it's a package-level function, not a method).
|
||||
|
||||
**Impact:** `/api/upload-file` (registered at `server/cmd/server/router.go:166`, in the user-scoped group, outside workspace middleware) calls `resolveWorkspaceID(r)`, gets `""` because the frontend only sends slug, thinks "no workspace context", and silently skips the DB attachment record creation (`server/internal/handler/file.go:235-245`). The file reaches S3; the UI never sees it.
|
||||
|
||||
**Why `/api/upload-file` is outside workspace middleware:** it serves both "avatar upload (no workspace)" and "attachment upload (with workspace)", branching on the resolved workspace ID inside the handler. Moving it under `RequireWorkspaceMember` would break avatar uploads.
|
||||
|
||||
**Structural root cause:** two resolvers, same job, divergent capabilities. The duplication is what let v2 ship "mostly working" — most handlers live behind middleware, so the broken handler resolver had a low blast radius that wasn't caught in review.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract shared resolver into middleware package
|
||||
|
||||
**Problem:** The middleware's `resolveWorkspaceUUID` closure captures `*db.Queries` and can look up slugs. The handler's `resolveWorkspaceID` is a bare package-level function without queries access. We need a single implementation both sides can reuse. Putting it in the `middleware` package is fine — the `handler` package already imports `middleware`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/middleware/workspace.go`
|
||||
|
||||
**Step 1: Add `ResolveWorkspaceIDFromRequest` export**
|
||||
|
||||
After `errWorkspaceNotFound` (around line 45), add a package-level exported function that takes `(r *http.Request, queries *db.Queries)` and returns the workspace UUID as a string (empty if none found or slug doesn't resolve).
|
||||
|
||||
Priority order (mirrors `resolveWorkspaceUUID`, plus a context lookup first so handlers behind middleware still get the fast path):
|
||||
|
||||
```go
|
||||
// ResolveWorkspaceIDFromRequest returns the workspace UUID for an HTTP
|
||||
// request, using the same priority order as the workspace middleware.
|
||||
// Handlers behind workspace middleware get it from context (cheap); handlers
|
||||
// outside middleware (e.g. /api/upload-file) still resolve slug → UUID via
|
||||
// a DB lookup instead of silently falling through to "no workspace".
|
||||
//
|
||||
// Priority:
|
||||
// 1. middleware-injected context (if the route is behind workspace middleware)
|
||||
// 2. X-Workspace-Slug header → GetWorkspaceBySlug → UUID (post-refactor frontend)
|
||||
// 3. ?workspace_slug query → GetWorkspaceBySlug → UUID
|
||||
// 4. X-Workspace-ID header (CLI/daemon compat)
|
||||
// 5. ?workspace_id query (CLI/daemon compat)
|
||||
//
|
||||
// Returns "" when no identifier was provided OR a slug was provided but doesn't
|
||||
// resolve to any workspace. Callers that need the "slug provided but invalid"
|
||||
// distinction should use the resolver inside the middleware directly.
|
||||
func ResolveWorkspaceIDFromRequest(r *http.Request, queries *db.Queries) string {
|
||||
if id := WorkspaceIDFromContext(r.Context()); id != "" {
|
||||
return id
|
||||
}
|
||||
if slug := r.Header.Get("X-Workspace-Slug"); slug != "" {
|
||||
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
|
||||
return util.UUIDToString(ws.ID)
|
||||
}
|
||||
}
|
||||
if slug := r.URL.Query().Get("workspace_slug"); slug != "" {
|
||||
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
|
||||
return util.UUIDToString(ws.ID)
|
||||
}
|
||||
}
|
||||
if id := r.Header.Get("X-Workspace-ID"); id != "" {
|
||||
return id
|
||||
}
|
||||
return r.URL.Query().Get("workspace_id")
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Refactor `resolveWorkspaceUUID` to delegate**
|
||||
|
||||
The existing middleware closure has slightly different semantics (returns `errWorkspaceNotFound` when a slug was provided but doesn't resolve, so middleware can 404 instead of 400). Keep that, but share the resolution logic:
|
||||
|
||||
Leave `resolveWorkspaceUUID` as-is for now — it distinguishes "no identifier" (400) from "invalid slug" (404). `ResolveWorkspaceIDFromRequest` returns "" in both cases because handler-level callers don't need that distinction (they just check for empty).
|
||||
|
||||
Document in a comment near `resolveWorkspaceUUID` that it's an internal variant that preserves the error distinction for middleware gating, and point to `ResolveWorkspaceIDFromRequest` as the handler-facing API.
|
||||
|
||||
**Step 3: Build and verify**
|
||||
|
||||
```bash
|
||||
cd server && go build ./...
|
||||
```
|
||||
Expected: clean build.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
refactor(server): extract ResolveWorkspaceIDFromRequest from middleware
|
||||
|
||||
Introduces a shared helper that consolidates the workspace-identity
|
||||
resolution logic used by both the workspace middleware and the handler
|
||||
package. No behavior change yet — callers still use the old functions.
|
||||
Sets up the next commit to fix the /api/upload-file slug bug by routing
|
||||
the handler-side resolver through this shared function.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Promote handler resolver to a method + delegate
|
||||
|
||||
**Problem:** The package-level `resolveWorkspaceID(r *http.Request)` in `handler.go` can't call `GetWorkspaceBySlug` because it has no queries access. Promoting it to a method on `*Handler` gives it access to `h.Queries` at no syntactic cost elsewhere.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/handler.go:155-165`
|
||||
|
||||
**Step 1: Replace `resolveWorkspaceID` with a Handler method**
|
||||
|
||||
```go
|
||||
// resolveWorkspaceID resolves the workspace UUID for this request.
|
||||
// Delegates to middleware.ResolveWorkspaceIDFromRequest so routes inside
|
||||
// and outside workspace middleware see identical resolution behavior.
|
||||
//
|
||||
// Returns "" when no workspace identifier was provided or a slug was
|
||||
// provided but doesn't match any workspace.
|
||||
func (h *Handler) resolveWorkspaceID(r *http.Request) string {
|
||||
return middleware.ResolveWorkspaceIDFromRequest(r, h.Queries)
|
||||
}
|
||||
```
|
||||
|
||||
Delete the old package-level `resolveWorkspaceID` function.
|
||||
|
||||
**Step 2: Build — expect errors at 47 call sites**
|
||||
|
||||
```bash
|
||||
cd server && go build ./... 2>&1 | head -60
|
||||
```
|
||||
|
||||
Expected: `resolveWorkspaceID is not a value` or `undefined: resolveWorkspaceID` errors at each existing call site. That's the signal to run Task 3.
|
||||
|
||||
**Do not commit yet.** Task 2 and 3 are a single logical change; they commit together after Task 3 fixes the compile.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rename 47 call sites to `h.resolveWorkspaceID(r)`
|
||||
|
||||
**Problem:** Every `resolveWorkspaceID(r)` call in the handler package now fails to compile because the function became a method. All 47 call sites are inside methods on `*Handler` (or similar receiver types that have access to `h`), so the rename is mechanical.
|
||||
|
||||
**Files affected** (verified via `grep -rn "resolveWorkspaceID" server/internal/handler/`):
|
||||
|
||||
- `server/internal/handler/handler.go:275, 365, 388` (3 sites)
|
||||
- `server/internal/handler/issue.go:447, 559, 731, 783, 1294, 1476` (6 sites)
|
||||
- `server/internal/handler/activity.go:133` (1 site)
|
||||
- `server/internal/handler/autopilot.go:178, 203, 255, 306, 386, 414, 490, 578, 615, 662` (10 sites)
|
||||
- `server/internal/handler/project.go:80, 127, 150, 192, 273, 430` (6 sites)
|
||||
- `server/internal/handler/comment.go:443, 510` (2 sites)
|
||||
- `server/internal/handler/runtime.go:207, 247, 296` (3 sites)
|
||||
- `server/internal/handler/pin.go:59, 105, 175, 202` (4 sites)
|
||||
- `server/internal/handler/reaction.go:43, 110` (2 sites)
|
||||
- `server/internal/handler/skill.go:126, 146, 187, 384, 815` (5 sites)
|
||||
- `server/internal/handler/agent.go:158, 254` (2 sites)
|
||||
- `server/internal/handler/file.go:83, 115, 282, 306` (4 sites)
|
||||
|
||||
Total: 48 (the resolver declaration itself + 47 callers).
|
||||
|
||||
**Step 1: Mechanical rename**
|
||||
|
||||
For each file above, change every `resolveWorkspaceID(r)` to `h.resolveWorkspaceID(r)`. In the one case in `file.go:83` inside `groupAttachments`, the receiver is already `*Handler`, so the method is accessible.
|
||||
|
||||
**Semantic check:** all 47 call sites are on methods with an `h *Handler` receiver (verifiable by scrolling up a few lines from each grep match). If any call site is inside a non-method function, that site needs to either take `*Handler` as a parameter or be skipped from this rename. Spot-check three sites before doing the rename.
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
```bash
|
||||
cd server && go build ./...
|
||||
```
|
||||
Expected: clean build.
|
||||
|
||||
**Step 3: Run Go tests**
|
||||
|
||||
```bash
|
||||
cd server && go test ./...
|
||||
```
|
||||
Expected: all pass. The 46 call sites behind workspace middleware hit the context branch (identical behavior to before). Only `UploadFile` gains new capability (slug resolution); it wasn't tested before, will be covered in Task 4.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
fix(server): resolve X-Workspace-Slug in /api/upload-file and other middleware-less handlers
|
||||
|
||||
The v2 workspace URL refactor updated the workspace middleware to accept
|
||||
X-Workspace-Slug but left the handler-package resolveWorkspaceID helper
|
||||
(used by handlers outside the middleware group) stuck on X-Workspace-ID.
|
||||
The frontend switched to the slug header, so /api/upload-file was
|
||||
receiving a slug it couldn't translate to a UUID, silently falling
|
||||
through to the avatar-upload branch and skipping DB attachment record
|
||||
creation — files were landing in S3 with no database reference.
|
||||
|
||||
Promote resolveWorkspaceID to a Handler method and delegate to the new
|
||||
middleware.ResolveWorkspaceIDFromRequest so middleware-behind and
|
||||
middleware-outside handlers share the same resolution logic. The 46
|
||||
call sites that live inside the workspace middleware group are
|
||||
unaffected (context lookup still wins). /api/upload-file now correctly
|
||||
recognizes slug requests and creates the attachment record.
|
||||
|
||||
Fixes: missing DB attachment rows for files uploaded since v2 (#1141)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add handler test for upload-file with slug header
|
||||
|
||||
**Problem:** The bug manifested exactly because there was no test covering the "upload-file with only a slug header" code path. Prevent regression.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/file_test.go` (or create if absent)
|
||||
|
||||
**Step 1: Locate existing upload-file test infrastructure**
|
||||
|
||||
```bash
|
||||
grep -rn "UploadFile\|upload-file" server/internal/handler/*_test.go
|
||||
```
|
||||
|
||||
If there's an existing upload-file test, add a new test case alongside it. If not, scaffold one using the same `handler_test.go` fixture pattern (`testWorkspaceID`, `testUserID`, seeded workspace).
|
||||
|
||||
**Step 2: Write the test**
|
||||
|
||||
Test name: `TestUploadFile_ResolvesWorkspaceViaSlugHeader`.
|
||||
|
||||
Flow:
|
||||
1. Seed a workspace with a known slug and the default test user as a member.
|
||||
2. POST a multipart form to `/api/upload-file` with an `issue_id` field referencing a seeded issue, with only `X-Workspace-Slug: <slug>` in headers (no `X-Workspace-ID`).
|
||||
3. Assert response is 200.
|
||||
4. Assert a DB row exists in `attachments` with the expected `workspace_id`, `uploader_id`, `issue_id`, and `filename`.
|
||||
|
||||
Anti-regression: also add `TestUploadFile_ResolvesWorkspaceViaIDHeaderStill` to confirm legacy `X-Workspace-ID` header still works (CLI / daemon compat).
|
||||
|
||||
**Step 3: Run the new test**
|
||||
|
||||
```bash
|
||||
cd server && go test ./internal/handler/ -run UploadFile
|
||||
```
|
||||
Expected: both pass.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
test(server): cover upload-file slug and UUID header resolution
|
||||
|
||||
Regression test for the v2 refactor bug: uploads from the frontend
|
||||
(which sends X-Workspace-Slug) now reach the workspace-aware branch
|
||||
and create attachment records.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add unit test for the shared resolver
|
||||
|
||||
**Problem:** The shared function will be the single point through which all workspace identity resolution flows. It deserves table-driven test coverage for each priority level.
|
||||
|
||||
**Files:**
|
||||
- Create or modify: `server/internal/middleware/workspace_test.go`
|
||||
|
||||
**Step 1: Table test**
|
||||
|
||||
Cases to cover:
|
||||
- Context UUID present → returns context UUID, ignores headers/query
|
||||
- Only `X-Workspace-Slug` → DB lookup succeeds → returns UUID
|
||||
- Only `X-Workspace-Slug` → DB lookup fails → returns ""
|
||||
- Only `?workspace_slug` → DB lookup succeeds → returns UUID
|
||||
- Only `X-Workspace-ID` → returns UUID
|
||||
- Only `?workspace_id` → returns UUID
|
||||
- Slug header + UUID header both present → slug wins (frontend priority)
|
||||
- Nothing → returns ""
|
||||
|
||||
**Step 2: Run**
|
||||
|
||||
```bash
|
||||
cd server && go test ./internal/middleware/ -run ResolveWorkspaceIDFromRequest
|
||||
```
|
||||
Expected: all cases pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```
|
||||
test(server): table-driven coverage for ResolveWorkspaceIDFromRequest
|
||||
|
||||
Pins down the priority order (context > slug header > slug query >
|
||||
UUID header > UUID query) so future changes can't silently diverge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full verification
|
||||
|
||||
**Step 1: `make check`**
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
Expected: typecheck, TS tests, Go tests, E2E (if backend+frontend up) all green.
|
||||
|
||||
**Step 2: Manual smoke test**
|
||||
|
||||
1. Start desktop dev environment.
|
||||
2. Open an issue, attach a file via drag-and-drop or the file picker.
|
||||
3. Refresh the issue. The attachment should appear in the attachments list.
|
||||
|
||||
Before this fix: attachment silently disappears on refresh (file is in S3, DB has no row).
|
||||
|
||||
**Step 3: Open PR**
|
||||
|
||||
Branch name: `fix/unify-workspace-identity-resolver`.
|
||||
|
||||
Title: `fix(server): resolve X-Workspace-Slug in middleware-less handlers`
|
||||
|
||||
Body should:
|
||||
- Link to the symptom PR (v2 refactor #1141) and reference that it's a latent follow-up.
|
||||
- Describe the structural change (two resolvers → one).
|
||||
- Note that 46 of 47 call sites see zero behavior change (context branch wins); only `/api/upload-file` gains capability.
|
||||
|
||||
---
|
||||
|
||||
## Risk / blast radius
|
||||
|
||||
**Low risk.** The 46 middleware-protected callers hit the context branch in `ResolveWorkspaceIDFromRequest` identically to how they hit `WorkspaceIDFromContext` before — zero semantic change. The only new code path exercised in production is the slug-header branch for `/api/upload-file`, which is already exercised by every other slug-header-carrying request (just via the middleware's version of the same logic). Task 4 and 5 lock the behavior down with tests.
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If a regression surfaces after deploy, revert the single commit from Task 3. `ResolveWorkspaceIDFromRequest` and the Handler method remain but are unused — harmless dead code until the next attempt.
|
||||
983
docs/product-overview.md
Normal file
983
docs/product-overview.md
Normal file
@@ -0,0 +1,983 @@
|
||||
# Multica 产品全景文档
|
||||
|
||||
> **文档说明**
|
||||
>
|
||||
> 这份文档的目的是:**让任何没有写过代码的新同事,在 30 分钟内完全理解 Multica 这个产品到底有哪些功能、每个功能在整体中处于什么位置、一个功能和另一个功能如何协同**。
|
||||
>
|
||||
> 它的受众包括:
|
||||
>
|
||||
> - **新加入的工程师 / 产品 / 设计 / 运营**——用它做 onboarding 的第一份材料
|
||||
> - **产品介绍工作**——需要对外讲解 Multica 时的事实基础
|
||||
> - **文案工作者**——写交互文案、营销文案、帮助文档时,需要知道某个词(比如 "Skill"、"Runtime"、"Autopilot")在产品体系里代表什么
|
||||
> - **任何需要在修改某个局部前,先理解它与整体关系的人**
|
||||
>
|
||||
> 它**不是**:开发者文档、架构决策记录(ADR)、或者销售话术。它是**功能事实的汇总**——每一条描述都能在代码、schema 或 API 里找到对应。
|
||||
>
|
||||
> 文档基于对整个 monorepo(server、apps、packages、migrations、daemon、CLI)的系统性调研生成,数据截止日期 2026-04-21。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [Multica 是什么](#1-multica-是什么)
|
||||
2. [核心概念词典](#2-核心概念词典)
|
||||
3. [功能全景(按模块)](#3-功能全景按模块)
|
||||
- 3.1 [Workspace 工作区](#31-workspace-工作区)
|
||||
- 3.2 [Issue 议题管理](#32-issue-议题管理)
|
||||
- 3.3 [Project 项目](#33-project-项目)
|
||||
- 3.4 [Agent 智能体](#34-agent-智能体)
|
||||
- 3.5 [Runtime 运行时 & Daemon 守护进程](#35-runtime-运行时--daemon-守护进程)
|
||||
- 3.6 [Skill 技能](#36-skill-技能)
|
||||
- 3.7 [Autopilot 自动驾驶](#37-autopilot-自动驾驶)
|
||||
- 3.8 [Chat 对话](#38-chat-对话)
|
||||
- 3.9 [Inbox 收件箱与通知](#39-inbox-收件箱与通知)
|
||||
- 3.10 [成员、邀请与权限](#310-成员邀请与权限)
|
||||
- 3.11 [搜索与命令面板](#311-搜索与命令面板)
|
||||
- 3.12 [认证、登录与 Onboarding](#312-认证登录与-onboarding)
|
||||
- 3.13 [设置与个人资料](#313-设置与个人资料)
|
||||
- 3.14 [CLI 命令行工具](#314-cli-命令行工具)
|
||||
4. [系统架构全景](#4-系统架构全景)
|
||||
5. [产品地图(全部路由)](#5-产品地图全部路由)
|
||||
6. [跨平台差异:Web vs 桌面](#6-跨平台差异web-vs-桌面)
|
||||
7. [附录:关键数据表速查](#7-附录关键数据表速查)
|
||||
|
||||
---
|
||||
|
||||
## 1. Multica 是什么
|
||||
|
||||
### 一句话定位
|
||||
|
||||
**Multica 把编码智能体变成真正的团队成员。**
|
||||
|
||||
像给同事分配任务一样,把一个 issue 指派给一个 agent,它会自己认领、写代码、汇报进度、更新状态——不需要你一直守着。
|
||||
|
||||
### 解决的问题
|
||||
|
||||
传统方式用 AI coding agent 的痛点:
|
||||
|
||||
- 每次都要复制粘贴 prompt
|
||||
- 必须盯着终端,看它跑不跑得完
|
||||
- 没有跨任务的记忆,每次都从零开始
|
||||
- 多个 agent 同时工作时,没有一个"看板"能看到全局
|
||||
|
||||
Multica 做的事:
|
||||
|
||||
- Agent 和人**共用同一个任务看板**(issue board)
|
||||
- Agent **有 profile**,会出现在 assignee 下拉里、会在评论区发言、会自己创建 issue
|
||||
- 同一个 (agent, issue) 的多轮对话**自动恢复会话**——上一次的上下文、工作目录都保留
|
||||
- **Skill 系统**让历史上解决过的问题沉淀成可复用的能力
|
||||
- **Autopilot** 让 agent 按定时规则自动开工(比如每天早上 9 点做 bug triage)
|
||||
|
||||
### 定位一句话版本
|
||||
|
||||
> Multica 不是一个 AI 工具,而是一个**人 + AI 协作的任务管理平台**。agent 是一等公民,和人在同一个工作流里。
|
||||
|
||||
### 部署形态
|
||||
|
||||
- **云版本(Multica Cloud)**:官方托管服务,agent 通过你本地跑的 daemon 执行
|
||||
- **自托管(Self-Host)**:完整后端可以部署在自己的服务器
|
||||
- **客户端**:Next.js web 版 + Electron 桌面版(两端体验基本一致,桌面独有:多标签、原生托盘、自动更新)
|
||||
|
||||
### 支持的 Coding Agent
|
||||
|
||||
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
|
||||
|
||||
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent
|
||||
|
||||
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心概念词典
|
||||
|
||||
**理解这些名词是理解产品的前提。每个概念的定义都严格对应数据库表。**
|
||||
|
||||
| 概念 | 定义 | 映射的数据表 |
|
||||
|------|------|-------------|
|
||||
| **User 用户** | 一个人类账号,可以登录,属于多个 workspace | `user` |
|
||||
| **Workspace 工作区** | 一切资源的容器。issue、agent、project、skill 全部隔离在 workspace 里。就是 Linear/Notion 里的 workspace/team 概念 | `workspace` |
|
||||
| **Member 成员** | 用户在某个 workspace 里的身份。一个用户在不同 workspace 可以有不同角色(owner/admin/member) | `member` |
|
||||
| **Agent 智能体** | 可被指派任务的 AI 工作者。有 profile(名字、头像、说明)、会指定 runtime 和 provider、可以配自定义 prompt 和技能 | `agent` |
|
||||
| **Runtime 运行时** | Agent 实际跑在哪里的**执行环境**。可以是用户本地机器(通过 daemon)或云端实例。**一个 runtime = 一台可以跑 agent 的机器** | `agent_runtime` |
|
||||
| **Daemon 守护进程** | 用户本地运行的后台程序,自动发现已安装的 coding CLI 并注册为 runtime,然后不停轮询 server 认领任务 | (进程,不是表) |
|
||||
| **Issue 议题** | 一个工作单元——任务、bug、feature。最核心的产品对象。可以分配给人或 agent | `issue` |
|
||||
| **Comment 评论** | Issue 下的讨论回复。人和 agent 都能发。在评论里 `@某个 agent` 会自动触发这个 agent 的新任务 | `comment` |
|
||||
| **Task 任务** | Agent 执行一次 issue 所产生的一次运行。本质是"一次 agent 跑起来的会话"。队列化执行 | `agent_task_queue` |
|
||||
| **Skill 技能** | 工作区级别的可复用说明文档。作用是给 agent 提供"怎么做某件事"的上下文。Agent 开跑时会把挂载的 skill 内容注入到工作目录让 CLI 能读到 | `skill`, `skill_file`, `agent_skill` |
|
||||
| **Project 项目** | 议题的高层归属,类似"里程碑"或"版本"。issue 可以归属到 project | `project` |
|
||||
| **Autopilot 自动驾驶** | 定时或被触发的自动化规则。按 cron 或 webhook 触发,自动创建 issue 并分配给 agent | `autopilot`, `autopilot_trigger`, `autopilot_run` |
|
||||
| **Chat 对话** | 用户和 agent 的持久化多轮对话。不依附于 issue | `chat_session`, `chat_message` |
|
||||
| **Inbox 收件箱** | 个人通知中心。被 @、被分配、订阅的 issue 有更新都会进这里 | `inbox_item` |
|
||||
| **Subscriber 订阅者** | 谁关注某个 issue。被分配、被 @、评论过都会自动订阅。订阅者会收到 inbox 通知 | `issue_subscriber` |
|
||||
| **Activity 活动 / Timeline 时间线** | 所有关键动作的审计记录。issue 详情页的"时间线"就是这个表的数据 | `activity_log` |
|
||||
| **Pin 固定** | 个人侧边栏快捷方式,把常用的 issue/project 置顶 | `pinned_item` |
|
||||
| **Reaction 反应** | Issue 或评论上的 emoji 反应,跟 GitHub/Slack 一样 | `issue_reaction`, `comment_reaction` |
|
||||
| **Attachment 附件** | Issue 或评论的文件上传,支持 S3/CloudFront 或本地存储 | `attachment` |
|
||||
| **Personal Access Token (PAT)** | 用户级 API token,CLI 和自动化用。`mul_` 前缀 | `personal_access_token` |
|
||||
| **Daemon Token** | 单 workspace 单 daemon 的 token。`mdt_` 前缀,比 PAT 权限范围更小 | `daemon_token` |
|
||||
| **Session Resumption 会话恢复** | 同一对 (agent, issue) 的下一次任务会自动复用上次 Claude Code 的 `session_id` 和工作目录——历史对话、文件状态都保留 | `agent_task_queue.session_id`, `.work_dir` |
|
||||
| **MCP (Model Context Protocol)** | Anthropic 提出的协议,让 agent 通过标准接口调用外部工具。每个 agent 可配自己的 MCP server 列表 | `agent.mcp_config` (JSONB) |
|
||||
| **Workspace Context 工作区上下文** | 工作区级别的 agent 系统提示词。所有该工作区的 agent 都会感知到它 | `workspace.context` |
|
||||
| **Polymorphic Actor 多态行动者** | 设计范式:几乎所有"谁做了什么"的字段都是 `actor_type` (`member`/`agent`) + `actor_id`。这就是为什么 agent 能像人一样创建 issue、发评论、被订阅 | 贯穿所有表 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能全景(按模块)
|
||||
|
||||
### 3.1 Workspace 工作区
|
||||
|
||||
> **角色**:一切的容器。Multica 的多租户边界。
|
||||
|
||||
#### 功能
|
||||
|
||||
- **多工作区**:一个用户可以属于多个 workspace,每个 workspace 完全隔离(issue、agent、skill、成员都独立)。
|
||||
- **创建工作区**:只需要一个名字;自动生成 slug(URL 中使用的短 ID)。
|
||||
- **切换工作区**:侧边栏下拉;桌面端每个工作区有独立的标签组。
|
||||
- **离开工作区**:非 owner 成员可自行离开。
|
||||
- **删除工作区**:只有 owner 可以,硬删除+级联。
|
||||
- **Workspace 设置**:名称、slug、描述、**Workspace Context**(给该工作区所有 agent 的统一系统提示)、**仓库列表**(workspace 允许 agent 访问的 Git 仓库 URL 白名单)。
|
||||
- **Workspace 头像 / issue 前缀**:每个工作区可以有自己的 issue 编号前缀(如 `ACME-42`)。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Workspace 不是一个功能,而是**所有功能的坐标系**。URL 的形态永远是 `/{workspace-slug}/...`,API 请求永远带 `X-Workspace-Slug` 头。一个 issue、一个 agent、一个 skill,脱离了 workspace 就没有意义。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`workspace`, `member`, `workspace_invitation`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Issue 议题管理
|
||||
|
||||
> **角色**:Multica 的核心工作对象。
|
||||
|
||||
Issue 对应的概念在 Linear 叫 Issue、在 Jira 叫 Ticket、在 GitHub 叫 Issue——就是一个任务单元。Multica 的特色在于**issue 可以分配给 agent,和分配给人完全对等**。
|
||||
|
||||
#### 核心字段
|
||||
|
||||
- 标题、描述(Tiptap 富文本)、状态、优先级
|
||||
- 编号(自动递增,带 workspace 前缀)
|
||||
- **Assignee(可以是 member 或 agent)**
|
||||
- **Creator(可以是 member 或 agent)**——agent 也能创建 issue
|
||||
- Parent issue(用来做子任务)
|
||||
- Project(归属的项目)
|
||||
- Due date(截止日期)
|
||||
- Labels(多对多标签)
|
||||
- Dependencies(依赖/阻塞关系)
|
||||
- Acceptance criteria(验收标准,JSONB)
|
||||
- Origin(如果是 autopilot 创建的,会记录来源 autopilot run)
|
||||
|
||||
#### 视图
|
||||
|
||||
- **List 列表视图**:表格形式,可按 status/priority/assignee/creator/project 过滤、按名称/优先级/截止日/手动位置排序;支持开放和已完成分页。
|
||||
- **Board 看板视图**:Kanban,按状态分列;支持拖拽(拖动会自动切到"手动排序"模式)。
|
||||
- **My Issues 我的议题**:专属视图,三个 scope:分配给我 / 我创建的 / 我的 agent 负责的。
|
||||
|
||||
#### 交互
|
||||
|
||||
- **快速创建**:侧边栏单行快速创建、或弹窗富文本创建(支持草稿本地持久化)
|
||||
- **批量操作**:多选后批量改 status/priority/assignee/删除
|
||||
- **子 issue**:父 issue 显示子任务完成比例圆环
|
||||
- **订阅(subscribe)**:默认 creator、assignee、被 @ 的人会自动订阅
|
||||
- **Reaction**:issue 和评论都能加 emoji 反应
|
||||
- **Pin 固定**:把 issue 置顶到侧边栏快捷栏
|
||||
- **复制链接 / 快捷键跳转(Cmd+K)**
|
||||
- **Timeline 时间线**:所有关键动作(状态变更、指派变更、评论)按时间顺序展示,混合 `activity_log` + `comment` 两类记录
|
||||
|
||||
#### 评论与讨论
|
||||
|
||||
- Tiptap 富文本编辑器,支持 `@` 提到 member 或 agent
|
||||
- 嵌套回复(一层)
|
||||
- emoji 反应
|
||||
- **@agent 触发任务**:在评论里提到某个 agent,会自动生成一个新的 agent task,让它来回复/处理
|
||||
|
||||
#### 附件
|
||||
|
||||
- 拖拽上传或按钮上传
|
||||
- 图片内联预览
|
||||
- 存储后端:S3/CloudFront 或本地磁盘(自托管)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Issue 是**所有工作流的载体**:
|
||||
- Agent 通过"被分配到 issue"获得任务
|
||||
- Autopilot 通过"创建 issue"来触发 agent
|
||||
- 评论通过"@agent" 追加任务
|
||||
- Inbox 通知围绕 issue 生成
|
||||
|
||||
#### 对应表
|
||||
|
||||
`issue`, `comment`, `issue_label`, `issue_to_label`, `issue_dependency`, `issue_subscriber`, `issue_reaction`, `comment_reaction`, `attachment`, `activity_log`, `pinned_item`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Project 项目
|
||||
|
||||
> **角色**:多个 issue 的高层容器,类似 Linear 的 Project、Jira 的 Epic。
|
||||
|
||||
#### 功能
|
||||
|
||||
- 标题、描述、图标(emoji 或标识符)
|
||||
- 状态:`planned` / `in_progress` / `paused` / `completed` / `cancelled`
|
||||
- 优先级:urgent / high / medium / low / none
|
||||
- **Lead 负责人**:可以是 member 或 agent(跟 issue 的 assignee 一样是多态)
|
||||
- 详情页展示项目内的所有 issue
|
||||
- 支持搜索项目
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于任何 project,但如果属于,会在列表页的筛选、侧边栏导航、面包屑里集中展示。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`project`
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Agent 智能体
|
||||
|
||||
> **角色**:AI 工作者。Multica 最独特的对象。
|
||||
|
||||
一个 Agent 不是一个"AI 模型",而是一个**带配置的工作者身份**。它有名字、头像、个人描述、说明书(系统提示词)、绑定的运行时、挂载的技能。在 UI 上它和人一样会出现在 assignee 下拉、评论作者、订阅者列表里。
|
||||
|
||||
#### 配置字段
|
||||
|
||||
- **基本信息**:名字、描述、头像(自动生成)
|
||||
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个
|
||||
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
|
||||
- **Instructions 说明书**:agent 的系统提示词("你是一个资深工程师...")
|
||||
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`CLAUDE_CODE_USE_BEDROCK`)
|
||||
- **Custom Args**:附加给 CLI 的启动参数(如 `--model`, `--thinking`)
|
||||
- **MCP Config**:Model Context Protocol 服务器列表(让 agent 有额外工具能力)
|
||||
- **Max Concurrent Tasks**:同时最多跑几个任务
|
||||
- **Skills**:关联多个 skill(见 3.6)
|
||||
- **Visibility**:`workspace`(工作区可见)或 `private`(仅创建者可见)
|
||||
|
||||
#### 状态
|
||||
|
||||
- `idle` / `working` / `blocked` / `error` / `offline`——由 runtime heartbeat 决定
|
||||
- 可以被 archive(软删除)
|
||||
|
||||
#### 交互
|
||||
|
||||
- 在 **Settings → Agents** 页面创建、编辑、归档
|
||||
- 在 issue 的 assignee 下拉里选择
|
||||
- 在评论里 `@agent` 触发
|
||||
- 在 chat 面板里直接聊
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent 干活"展开:
|
||||
- Issue 通过分配触发 agent
|
||||
- Skill 通过挂载赋能 agent
|
||||
- Runtime 提供 agent 的运行环境
|
||||
- Autopilot 调度 agent 自动开工
|
||||
- Chat 提供 agent 的对话界面
|
||||
|
||||
#### 对应表
|
||||
|
||||
`agent`, `agent_skill`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Runtime 运行时 & Daemon 守护进程
|
||||
|
||||
> **角色**:Agent 真正跑起来的物理/虚拟机器。
|
||||
|
||||
这是 Multica **分布式执行架构**的核心设计:**agent 不在 server 上运行,而在用户自己的机器上运行**。Server 只做任务调度、状态同步、数据存储。
|
||||
|
||||
#### Daemon 是什么
|
||||
|
||||
`multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它:
|
||||
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`)
|
||||
2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime)
|
||||
3. 每 3 秒 **轮询** 一次 server,有任务就认领
|
||||
4. 每 15 秒 **心跳**(keepalive),报告自己还活着
|
||||
5. 认领任务后,在本机的隔离工作目录里**启动 agent CLI**,把 agent 的输出流**实时推回 server**
|
||||
6. 任务完成后上报结果、token 用量、session id 和工作目录(用于下次恢复)
|
||||
|
||||
#### Runtime 展示
|
||||
|
||||
在 **Settings → Runtimes** 页面可以看到:
|
||||
|
||||
- 每个 runtime 的名字、提供方(图标)、owner(谁的机器)、状态指示(在线/离线)、last seen 时间
|
||||
- Ping 诊断:手动戳一下看响应
|
||||
- Usage 用量:近期的 token 消耗统计
|
||||
- Activity:任务活动情况
|
||||
- CLI 安装指引(自托管模式下)
|
||||
- 桌面端独有:**本地 daemon 卡片**,显示本机 daemon 状态、可一键重启
|
||||
|
||||
#### Runtime 的生命周期
|
||||
|
||||
- **注册**:daemon 启动时 POST `/api/daemon/register` 得到 runtime ID
|
||||
- **在线**:15 秒一次心跳
|
||||
- **离线**:如果 server 45 秒没收到心跳,把 runtime 标记为离线(server 后台 sweeper 每 30 秒巡检)
|
||||
- **孤儿任务回收**:超过 5 分钟还在 dispatched 或超过 2.5 小时还在 running 的任务,sweeper 会把它标记为失败
|
||||
- **长期离线 GC**:7 天没心跳且没活跃 agent 的 runtime 会被回收
|
||||
|
||||
#### CLI 与 Daemon 的关系
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `multica setup` | 一键配置:填 URL + 登录 + 启动 daemon |
|
||||
| `multica login` | 浏览器打开 OAuth 登录,保存 90 天 PAT 到 `~/.multica/config.json` |
|
||||
| `multica login --token <pat>` | 无头登录(SSH/CI) |
|
||||
| `multica daemon start` | 后台启动 daemon(写 PID 到 `~/.multica/daemon.pid`,日志到 `~/.multica/daemon.log`) |
|
||||
| `multica daemon stop` | 发 SIGTERM,优雅关闭(等待进行中的任务完成,超时 30s) |
|
||||
| `multica daemon status` | 打印 daemon 状态、探测到的 agent、watch 中的 workspace |
|
||||
| `multica daemon logs -f` | 实时跟随日志 |
|
||||
| `multica daemon start --profile <name>` | 启动独立配置的 daemon(用于多环境,比如同时连 staging 和生产) |
|
||||
|
||||
#### 安全边界
|
||||
|
||||
- 每个任务一个**独立工作目录** `~/multica_workspaces/{ws}/{task_short_id}/workdir/`
|
||||
- 环境变量**过滤**:阻止 agent 覆盖 daemon 的认证变量(`MULTICA_TOKEN` 等)
|
||||
- 仓库访问**白名单**:agent 只能 checkout workspace 配置的仓库
|
||||
- Codex 有**版本相关的 sandbox 策略**
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Runtime 是让"给 agent 分配任务"这件事**能真正发生**的基础设施。没有 runtime,所有 agent 就是空壳。用户第一次 onboarding 时必须至少有一个 runtime 在线,否则 agent 没法干活。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`agent_runtime`, `daemon_token`, `daemon_pairing_session`(弃用中), `daemon_connection`(弃用中), `runtime_usage`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Skill 技能
|
||||
|
||||
> **角色**:让 agent "学会"某种工作方式的可复用说明文档。
|
||||
|
||||
Skill 是一组 Markdown 文档 + 配套文件。它**不是代码**,**不是 prompt 模板**,而是**给 agent CLI 读的说明**。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
```
|
||||
skill
|
||||
├─ name: "react-patterns"
|
||||
├─ description: "Common React patterns and best practices"
|
||||
├─ content: "## Overview\n..." # 主要说明文档
|
||||
└─ files:
|
||||
├─ examples/hooks.md
|
||||
└─ examples/useState.jsx
|
||||
```
|
||||
|
||||
#### 它怎么工作
|
||||
|
||||
1. **创建**:在 **Settings → Skills** 页面创建或从 URL 导入(如 clawhub.ai、skills.sh)
|
||||
2. **挂载**:给某个 agent 勾选要用的 skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/agent/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
- 其他 → `.agent_context/skills/{name}/SKILL.md`
|
||||
4. **使用**:agent CLI 自己按照 provider 约定发现并读取这些文件
|
||||
|
||||
> 💡 **Skill 是静态的**——不是 AI 生成的,也不会随执行变化。它是人写的经验文档。未来可能扩展成"AI 从历史任务中沉淀技能",但当前版本不是。
|
||||
|
||||
#### CLI 对应命令
|
||||
|
||||
```bash
|
||||
multica skill list
|
||||
multica skill get <id>
|
||||
multica skill create --title ...
|
||||
multica skill import --url https://...
|
||||
multica skill files upsert <skill-id> --path ...
|
||||
```
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Skill 是 Multica 区别于"每次都要写长 prompt"的关键机制。它让团队的专业知识**沉淀成可复用的组件**,绑在 agent 上就生效——就像给员工写的 SOP/playbook。
|
||||
|
||||
从架构角度:skill 不参与执行逻辑,只参与**上下文注入**。它在整个任务生命周期里只出现一次——在 daemon 启动 CLI 之前的环境准备阶段。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`skill`, `skill_file`, `agent_skill`
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Autopilot 自动驾驶
|
||||
|
||||
> **角色**:让 agent 在没人触发的时候也能自己开工的调度器。
|
||||
|
||||
Autopilot 解决的问题:很多工作是**周期性**的——每天早上的 bug triage、每周的依赖审计、每月的安全扫描。人手动触发太烦,Autopilot 是规则化自动触发。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
```
|
||||
autopilot
|
||||
├─ title, description
|
||||
├─ assignee: <agent_id> # 指定哪个 agent 跑
|
||||
├─ execution_mode: create_issue | run_only
|
||||
├─ issue_title_template: "Daily triage - {{date}}"
|
||||
├─ concurrency_policy: skip | queue | replace
|
||||
└─ triggers (多个):
|
||||
├─ kind: schedule | webhook | api
|
||||
├─ cron_expression
|
||||
├─ timezone
|
||||
└─ webhook_token
|
||||
```
|
||||
|
||||
#### 两种执行模式
|
||||
|
||||
- **`create_issue`(默认)**:触发时先创建一个新 issue(标题用 `issue_title_template` 渲染),再把 issue 分配给 agent,走正常 agent 任务流程
|
||||
- **`run_only`**:直接创建 task,不关联 issue(适合"只执行不留下 ticket"的场景,比如每小时检查某状态)
|
||||
|
||||
#### 三种触发方式
|
||||
|
||||
- **Schedule(cron)**:server 后台每 30 秒扫一次 `autopilot_trigger`,到点的触发出去
|
||||
- **Webhook**:给出一个带 `webhook_token` 的 URL,外部 POST 即可触发
|
||||
- **API / Manual**:UI 上点"立即运行"按钮,或用 CLI `multica autopilot trigger <id>`
|
||||
|
||||
#### 并发策略
|
||||
|
||||
- `skip`:同一个 autopilot 上一次还没跑完,跳过这次(去重)
|
||||
- `queue`:排队等上一次跑完
|
||||
- `replace`:中止上一次,换成这次
|
||||
|
||||
#### 运行记录
|
||||
|
||||
每次触发都在 `autopilot_run` 里留一条记录:`pending → issue_created → running → completed/failed/skipped`。在 UI 的 autopilot 详情页可以看全部历史。
|
||||
|
||||
#### 内置模板
|
||||
|
||||
产品提供一些现成的 autopilot 模板,一键创建:
|
||||
|
||||
- Daily news digest(每天 9:00)
|
||||
- PR review reminder(工作日 10:00)
|
||||
- Bug triage(工作日 9:00)
|
||||
- Weekly progress report(每周 17:00)
|
||||
- Dependency audit(每周 10:00)
|
||||
- Security scan(每周 02:00)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Autopilot 让 Multica 从"你分配 → agent 做"升级到"agent 自己发起工作"。配合 `run_only` 模式,甚至可以在没有 issue 的前提下跑定时任务。Issue 上的 `origin_type=autopilot` + `origin_id` 字段留下了"这个 issue 是哪个 autopilot run 创建的"的追溯链。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`autopilot`, `autopilot_trigger`, `autopilot_run`
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Chat 对话
|
||||
|
||||
> **角色**:用户和 agent 的持久多轮对话界面,不依附于 issue。
|
||||
|
||||
有时候你不想为了和 agent 说一句话就开一个 issue。Chat 就是为这种"轻量对话"准备的——像 ChatGPT 的对话界面,但是你在和你工作区的某个 agent 对话。
|
||||
|
||||
#### 功能
|
||||
|
||||
- **创建会话**:选一个 agent 开始
|
||||
- **消息列表**:支持 Markdown 渲染、代码块高亮
|
||||
- **发送消息**:消息会被 queue 成一个 task,agent 执行后把响应作为消息写回
|
||||
- **流式响应**:通过 WebSocket 实时推送
|
||||
- **未读跟踪**:`unread_since` 字段记录第一条未读消息的时间戳
|
||||
- **归档**:把旧会话移出活跃列表
|
||||
- **Session 复用**:同一个 chat session 下的多轮消息会复用底层 CLI 的 `session_id`(Claude Code 能保留对话上下文)
|
||||
|
||||
#### 和 Issue 评论的区别
|
||||
|
||||
| | Chat | Issue 评论 |
|
||||
|---|---|---|
|
||||
| 上下文载体 | 独立 session(chat_session) | 某个 issue |
|
||||
| 是否公开 | 个人和 agent 对话(私有) | 工作区所有成员可见 |
|
||||
| 触发 agent | 每条 user 消息都触发 | 需要 `@agent` |
|
||||
| 用途 | 探索、提问、一次性任务 | 和 issue 强绑定的工作推进 |
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Chat 填补了"不够正式到需要开 issue、但又需要持久化"的对话空白。同时也是体验上更像常规聊天软件的入口。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`chat_session`, `chat_message`;底层执行仍走 `agent_task_queue`(`chat_session_id` 字段区分)
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Inbox 收件箱与通知
|
||||
|
||||
> **角色**:每个人的个人通知中心。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
`inbox_item` 是推给特定"recipient"的条目:
|
||||
|
||||
- recipient_type = `member` 或 `agent`(agent 也能有 inbox!)
|
||||
- type(e.g. `issue_assigned`, `comment_mention`, `task_completed`, `invitation_created`)
|
||||
- severity(`action_required` / `attention` / `info`)
|
||||
- 关联的 issue(如果有)
|
||||
- read / archived 状态
|
||||
|
||||
#### 通知触发场景
|
||||
|
||||
- Issue 被分配给你
|
||||
- 被 @ 提到
|
||||
- 订阅的 issue 状态变化
|
||||
- 订阅的 issue 有新评论
|
||||
- 工作区邀请
|
||||
- 你的 agent 任务完成/失败
|
||||
|
||||
#### 订阅机制(自动)
|
||||
|
||||
Server 的 subscriber listener 自动把以下人加入 `issue_subscriber`:
|
||||
|
||||
- issue creator
|
||||
- 当前 assignee(变更会同步更新)
|
||||
- 评论里被 @ 的人
|
||||
- 手动订阅的人
|
||||
|
||||
#### UI
|
||||
|
||||
- **Inbox 页面**:两栏布局,左边列表 + 右边 issue 详情
|
||||
- **批量操作**:全部标记已读 / 仅归档已读 / 归档已完成 issue 的通知
|
||||
- **徽标**:侧边栏导航上显示未读数
|
||||
- **WebSocket 推送**:新 inbox 条目实时到达(`inbox:new` 事件只发给目标用户)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Inbox 是"主动注意力系统",让用户不必一直盯着看板也知道哪些事要自己处理。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`inbox_item`, `issue_subscriber`
|
||||
|
||||
---
|
||||
|
||||
### 3.10 成员、邀请与权限
|
||||
|
||||
#### 角色体系
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| **Owner** | 全部;唯一能删除工作区的角色 |
|
||||
| **Admin** | 管理成员、管理设置;不能删工作区,不能移除其他 admin |
|
||||
| **Member** | 创建 issue、评论、自我分配、使用 agent |
|
||||
|
||||
#### 邀请流程
|
||||
|
||||
- Admin 在 **Settings → Members** 输入邮箱邀请
|
||||
- Server 生成 `workspace_invitation` 记录(7 天过期)
|
||||
- 发送邮件(Resend 集成,未配置时打到 stderr)
|
||||
- 被邀请人收到邀请:如果已有账号,会出现在个人 Inbox;如果没账号,邮件里有注册链接
|
||||
- 接受 / 拒绝 / 过期
|
||||
|
||||
#### UI
|
||||
|
||||
- 成员列表:头像、邮箱、角色徽章、操作菜单(改角色、移除)
|
||||
- 待处理邀请列表:可 resend、revoke
|
||||
- Invite 接受页面(`/invite/[id]`):展示工作区信息、接受/拒绝按钮
|
||||
|
||||
#### 邀请接受的桌面特殊处理
|
||||
|
||||
桌面端的 `multica://invite/{id}` 深链接**不是走路由**,而是触发 `WindowOverlay`——共享视图组件 `InvitePage` 装在原生窗口覆盖层里,保证拖拽移动窗口等原生体验。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
成员管理是**一切协作的前提**。但在 Multica 里它有一个独特之处:成员系统也管 agent。之所以要有 `assignee_type` 区分 member 和 agent,就是为了让两者在同一套 API 里表达"谁可以被分配"。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`member`, `workspace_invitation`
|
||||
|
||||
---
|
||||
|
||||
### 3.11 搜索与命令面板
|
||||
|
||||
#### 命令面板(Cmd+K)
|
||||
|
||||
全局搜索入口,覆盖:
|
||||
|
||||
- **Issues**(按标题、编号匹配)
|
||||
- **Projects**(按名称匹配)
|
||||
- **Workspaces**(按名称匹配,用于快速切换)
|
||||
- **Navigation**(跳转到设置、runtimes、skills 等)
|
||||
- **Actions**(新建 issue、新建 project、切换主题)
|
||||
- **Recent Issues**(最近访问过的,自动记录)
|
||||
|
||||
#### 列表过滤
|
||||
|
||||
Issue 列表、project 列表、inbox 等都有本地 filter chips 和 search input。
|
||||
|
||||
#### 全文搜索
|
||||
|
||||
`GET /api/issues/search` 支持对 issue 的标题、描述、评论内容做全文搜索,返回命中片段。
|
||||
|
||||
> **当前没有基于向量的语义搜索**——产品宣传是 AI-native,但没有用 pgvector。Schema 里也没启用向量扩展。未来可能扩展。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Cmd+K 是 keyboard-first 用户(Linear-style)的主要导航方式,比点击侧边栏更快。
|
||||
|
||||
---
|
||||
|
||||
### 3.12 认证、登录与 Onboarding
|
||||
|
||||
#### 登录方式
|
||||
|
||||
- **邮箱验证码(Magic Link 风格)**:输入邮箱 → 收 6 位验证码 → 输入验证码登录
|
||||
- **Google OAuth**:一键 Google 登录
|
||||
- **PAT(CLI)**:用户在 Settings → API Tokens 里生成的 token,CLI/脚本场景
|
||||
|
||||
#### Onboarding 流程(正在重设计中)
|
||||
|
||||
位于 `packages/views/onboarding/` 和 `apps/web/app/(auth)/onboarding/`。
|
||||
|
||||
经典 5 步:
|
||||
|
||||
1. **Welcome** — 欢迎页
|
||||
2. **Workspace** — 创建工作区(或跳过,如果已有)
|
||||
3. **Runtime** — 展示可用的 runtime 和 CLI 安装指引
|
||||
4. **Agent** — 创建第一个 agent(需要有 runtime)
|
||||
5. **Complete** — 展示创建好的 workspace 和 agent,跳转到 dashboard
|
||||
|
||||
#### 邀请接受(Zero-workspace)
|
||||
|
||||
如果新用户是被邀请进来的(还没有自己的 workspace),接受邀请后直接进入该工作区,跳过 onboarding。
|
||||
|
||||
#### 认证后的跳转规则
|
||||
|
||||
- 已登录且有至少一个 workspace:跳到 `/{slug}/issues`
|
||||
- 已登录但没有 workspace:进入 `/workspaces/new` 或 onboarding
|
||||
- 未登录:跳到 `/login`
|
||||
|
||||
#### Signup 限流
|
||||
|
||||
Server 支持:
|
||||
- `ALLOW_SIGNUP=false` 关闭注册
|
||||
- `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 白名单
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Onboarding 是新用户能不能成功把 agent 跑起来的关键漏斗。任何一步没完成(尤其是 runtime 没连上),后续功能都是空壳。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`user`, `verification_code`, `personal_access_token`
|
||||
|
||||
---
|
||||
|
||||
### 3.13 设置与个人资料
|
||||
|
||||
#### My Account 标签
|
||||
|
||||
- **Profile**:名字、头像(不可上传,系统生成)、邮箱(只读)
|
||||
- **Appearance**:主题(light / dark / system)
|
||||
- **API Tokens**:创建/查看/撤销 PAT;创建时一次性展示完整 token
|
||||
- **Daemon**(桌面独有):本机 daemon 状态、重启、开机自启开关
|
||||
- **Updates**(桌面独有):当前版本、检查更新、自动更新开关
|
||||
|
||||
#### Workspace 标签
|
||||
|
||||
- **General**:名字、描述、**Workspace Context**(agent 系统级提示)
|
||||
- **Members**:见 3.10
|
||||
- **Repositories**:GitHub 集成,连接仓库列表,agent 白名单
|
||||
- **Agents / Runtimes / Skills / Autopilots**:各自独立页面(实际上这些在侧边栏直接有入口,settings 里也有对应管理 tab)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Settings 是所有"配置即工作"动作的汇总:agent 的 prompt、workspace 的 context、仓库白名单、skill 的内容——都在这里。**对运营和文案来说最重要的一句话**:用户在 Multica 的 settings 页面做的配置,每一项都会影响 agent 实际执行时读到的上下文。
|
||||
|
||||
---
|
||||
|
||||
### 3.14 CLI 命令行工具
|
||||
|
||||
`multica` 不只是启动 daemon 的工具,也是完整的命令行操作层。很多用户喜欢在终端里推进工作而不是开 UI。
|
||||
|
||||
#### 工作区 / 议题
|
||||
|
||||
```bash
|
||||
multica workspace list | get | watch | unwatch
|
||||
multica issue list | get | create | update | assign | status
|
||||
multica issue comment list | add | delete
|
||||
multica issue runs <id> # 查看任务执行历史
|
||||
multica issue run-messages <task-id> # 查看某次执行的消息
|
||||
```
|
||||
|
||||
#### Agent / Skill / Autopilot / Project / Repo
|
||||
|
||||
```bash
|
||||
multica agent list | get | create | update | archive
|
||||
multica skill list | get | create | update | delete | import | files upsert
|
||||
multica autopilot list | get | create | update | trigger
|
||||
multica autopilot trigger-add --cron "0 9 * * 1-5"
|
||||
multica project list | get | create | update
|
||||
multica repo list | add | update | delete
|
||||
```
|
||||
|
||||
#### Runtime
|
||||
|
||||
```bash
|
||||
multica runtime list | usage | activity | update
|
||||
```
|
||||
|
||||
#### 配置 / 更新
|
||||
|
||||
```bash
|
||||
multica config show | set server_url ...
|
||||
multica auth status | logout
|
||||
multica version | update
|
||||
```
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
CLI 是 Multica 对开发者友好度的体现。对于 agent 自己来说,也同等重要——**agent 在执行任务时能调用 `multica` 命令读写 issue、评论、查文档**,这正是 CLI 在 "agent 作为一等公民"架构里的作用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 系统架构全景
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────────┐
|
||||
│ Next.js Web App │ │ Electron Desktop │ │ multica CLI │
|
||||
│ apps/web │ │ apps/desktop │ │ server/cmd/ │
|
||||
└──────────┬──────────┘ └──────────┬─────────┘ └────────┬─────────┘
|
||||
│ HTTP + WebSocket │ │ HTTP
|
||||
│ │ │
|
||||
└──────────────┬────────────────┴───────────────┬───────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Go Backend (server/) │
|
||||
│ • Chi HTTP router • gorilla/websocket hub │
|
||||
│ • sqlc generated queries │
|
||||
│ • In-process event bus │
|
||||
│ • Background workers (sweeper / scheduler) │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ PostgreSQL 17 │
|
||||
│ + pgcrypto │
|
||||
│ (28 tables) │
|
||||
└──────────────────────┘
|
||||
|
||||
▲
|
||||
│ HTTPS poll + heartbeat
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Local Daemon (用户机器上运行) │
|
||||
│ • 每 3s 认领任务 • 每 15s 心跳 │
|
||||
│ • 探测并启动 agent CLI 子进程 │
|
||||
│ • 为任务准备隔离工作目录 │
|
||||
└───────────────┬─────────────────────────────────┘
|
||||
│ spawns
|
||||
┌───────────────┼─────────────────────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
Claude Code Codex OpenCode …其他 CLI
|
||||
(子进程) (子进程) (子进程)
|
||||
```
|
||||
|
||||
### 分层职责
|
||||
|
||||
| 层 | 负责什么 | 不负责什么 |
|
||||
|---|---|---|
|
||||
| **Web / Desktop 客户端** | UI、本地客户端状态(Zustand)、服务器状态缓存(TanStack Query)、WebSocket 订阅 | 业务规则、AI 调用 |
|
||||
| **Server** | 持久化、权限、任务编排、事件广播、Autopilot 调度、Runtime 健康监测 | 不直接执行 agent、不调 LLM |
|
||||
| **Daemon** | 探测并启动本地 CLI、管理任务工作目录、流式上报消息、session 恢复 | 不做业务决策、只认 server 给它的任务 |
|
||||
| **Agent CLI(Claude Code 等)** | 实际调用 LLM、执行工具调用、写文件、跑测试 | 不感知 Multica 的数据模型(所有上下文通过 `multica` CLI 命令读回) |
|
||||
|
||||
### 实时层(WebSocket)
|
||||
|
||||
Server 启动一个 WebSocket hub:
|
||||
|
||||
- **鉴权**:URL 参数里的 JWT 或 PAT + workspace_slug
|
||||
- **房间模型**:按 workspace 分房间,一个 workspace 的事件只广播给该房间的连接
|
||||
- **个人定向推送**:`inbox:new`, `invitation:created` 等个人事件用 `SendToUser`
|
||||
- **心跳**:server 每 54 秒 ping,客户端 60 秒内必须 pong
|
||||
|
||||
**全部事件类型(供文案参考,共约 60+ 个)**:
|
||||
- `issue:created` / `issue:updated` / `issue:deleted`
|
||||
- `comment:created` / `comment:updated` / `comment:deleted` / `reaction:added` / `issue_reaction:added`
|
||||
- `agent:created` / `agent:status` / `agent:archived`
|
||||
- `task:dispatch` / `task:progress` / `task:message` / `task:completed` / `task:failed` / `task:cancelled`
|
||||
- `inbox:new` / `inbox:read` / `inbox:archived` / `inbox:batch-*`
|
||||
- `workspace:updated` / `workspace:deleted` / `member:added` / `member:updated` / `member:removed`
|
||||
- `invitation:created` / `invitation:accepted` / `invitation:declined` / `invitation:revoked`
|
||||
- `chat:message` / `chat:done` / `chat:session_read`
|
||||
- `skill:created` / `skill:updated` / `skill:deleted`
|
||||
- `project:created` / `project:updated` / `project:deleted`
|
||||
- `autopilot:created` / `autopilot:updated` / `autopilot:run_start` / `autopilot:run_done`
|
||||
- `subscriber:added` / `activity:created`
|
||||
- `daemon:heartbeat` / `daemon:register`
|
||||
|
||||
客户端收到事件后的模式:要么直接 patch 本地缓存(issue / comment / task 这类需要即时更新的),要么触发对应 query 的失效重拉(less-critical 数据)。
|
||||
|
||||
### AI / LLM 在哪里
|
||||
|
||||
**Multica 本身不直接调 LLM API**。所有 LLM 调用都在 agent CLI 子进程里发生(Claude Code 调 Anthropic API、Codex 调 OpenAI API 等)。
|
||||
|
||||
Server 和 daemon 做的事情是:
|
||||
|
||||
1. 准备 prompt(见 `server/internal/daemon/prompt.go`)
|
||||
2. 准备环境变量(agent.custom_env 注入)
|
||||
3. 准备工作目录(注入 CLAUDE.md / AGENTS.md / skills / issue context)
|
||||
4. 启动 CLI 子进程
|
||||
5. 流式读 CLI 的 stdout,把消息分类并转发
|
||||
|
||||
**所以看不到大段的 prompt 工程代码**——prompt 只有几个模板(task prompt、chat prompt、comment-triggered prompt),核心内容是 agent instructions + issue context + skill files,真正的 LLM 对话由 CLI 自己管理。
|
||||
|
||||
### 后台任务
|
||||
|
||||
Server 启动三个 goroutine:
|
||||
|
||||
1. **Runtime Sweeper**(每 30s):标记离线 runtime、回收孤儿任务、GC 长期离线 runtime
|
||||
2. **Autopilot Scheduler**(每 30s):扫 cron 触发器,到点就 dispatch
|
||||
3. **DB Stats Logger**:周期性打印 pgxpool 连接池状态
|
||||
|
||||
---
|
||||
|
||||
## 5. 产品地图(全部路由)
|
||||
|
||||
### 公共 / 认证
|
||||
|
||||
- `/` — 首页
|
||||
- `/login` — 登录
|
||||
- `/auth/callback` — OAuth 回调
|
||||
- `/workspaces/new` — 创建工作区
|
||||
- `/invite/[id]` — 接受邀请
|
||||
- `/onboarding` — 首次引导
|
||||
|
||||
### 工作区内(`/{slug}/...`)
|
||||
|
||||
- `/issues` — Issue 列表(board / list 视图)
|
||||
- `/issues/[id]` — Issue 详情
|
||||
- `/my-issues` — 我的 issue(三 scope)
|
||||
- `/projects` — 项目列表
|
||||
- `/projects/[id]` — 项目详情
|
||||
- `/autopilots` — Autopilot 列表
|
||||
- `/autopilots/[id]` — Autopilot 详情
|
||||
- `/agents` — Agent 列表
|
||||
- `/runtimes` — Runtime 列表
|
||||
- `/skills` — Skill 库
|
||||
- `/inbox` — 收件箱
|
||||
- `/settings` — 设置(包含多个 tab:profile / appearance / tokens / workspace / members / repos / daemon / updates)
|
||||
|
||||
### 桌面端特有(不是路由,是 WindowOverlay)
|
||||
|
||||
- **Create workspace overlay**
|
||||
- **Invite accept overlay**(来自 `multica://invite/{id}` 深链接)
|
||||
- **Onboarding overlay**(首次或零工作区时)
|
||||
|
||||
---
|
||||
|
||||
## 6. 跨平台差异:Web vs 桌面
|
||||
|
||||
### 共享(绝大部分功能)
|
||||
|
||||
所有业务页面(issues / projects / autopilots / agents / runtimes / skills / inbox / settings / chat / login / onboarding)的实际 UI 都在 `packages/views/` 里,web 和桌面共用同一套组件。
|
||||
|
||||
### Web 特有
|
||||
|
||||
- 地址栏 + 浏览器前进后退
|
||||
- 服务端渲染(SSR)
|
||||
- `/login` 的 OAuth 回调处理 localhost 端口(方便 CLI 登录)
|
||||
|
||||
### 桌面特有
|
||||
|
||||
- **多标签**:每个 workspace 独立标签组,可以拖拽重排
|
||||
- **WindowOverlay**:邀请接受、创建工作区、onboarding 不走路由,而是原生窗口层
|
||||
- **Daemon 集成**:设置里能直接重启本机 daemon、看状态
|
||||
- **本地 daemon runtime 卡片**:在 Runtimes 页面自动显示本机 daemon
|
||||
- **自动更新**:`Settings → Updates` 检查/下载/安装新版本
|
||||
- **Immersive mode**:全屏模式,隐藏侧边栏
|
||||
- **深链接**:`multica://auth/callback?token=...` 和 `multica://invite/{id}`
|
||||
- **拖动区**:macOS 的红绿灯 + 顶部 48px 拖拽条(`h-12`)用来移动窗口
|
||||
- **Workspace 单例守护**:`setCurrentWorkspace()` 管理当前活跃工作区的全局身份
|
||||
|
||||
### 为什么两端要做差异
|
||||
|
||||
Web 有 URL 栏——错误状态(比如"你没有访问这个 workspace 的权限")作为一个可分享的 URL 页面是有意义的。桌面没有 URL 栏——同样的状态只会把用户困住,所以桌面选择**静默自愈**:把失效的 tab 从 store 里移除即可。这个差异直接影响多个细节:
|
||||
|
||||
- Web 有 `NoAccessPage`,桌面没有
|
||||
- Web 有 `/workspaces/new` 页面,桌面把它做成 overlay
|
||||
- Web 的 deep link 直接路由,桌面的深链接转 WindowOverlay
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录:关键数据表速查
|
||||
|
||||
共 **28 张表**,覆盖 10 个产品域。以下按域列出最重要的字段,供文案/产品查询"某个功能背后到底存了什么"。
|
||||
|
||||
### 身份 / 认证
|
||||
|
||||
- `user` — 基础账号(id, email, name, avatar_url)
|
||||
- `verification_code` — 邮箱验证码(code, expires_at, attempts)
|
||||
- `personal_access_token` — 用户 API token(token_hash, token_prefix, revoked)
|
||||
|
||||
### 工作区 / 成员
|
||||
|
||||
- `workspace` — 容器(name, slug, description, context, settings, repos, issue_prefix, issue_counter)
|
||||
- `member` — 成员身份(role: owner/admin/member)
|
||||
- `workspace_invitation` — 邀请(invitee_email, status: pending/accepted/declined/expired)
|
||||
|
||||
### Agent / Runtime / Skill
|
||||
|
||||
- `agent` — Agent 主表(instructions, custom_env, custom_args, mcp_config, runtime_mode, visibility, status)
|
||||
- `agent_runtime` — 运行时(daemon_id, provider, status: online/offline, last_seen_at)
|
||||
- `agent_skill` — agent 挂载 skill 的 n-n 关联
|
||||
- `skill` — 技能主文档(name, description, content)
|
||||
- `skill_file` — 技能附带文件(path, content)
|
||||
- `daemon_token` — 守护进程级 token
|
||||
- `daemon_connection` / `daemon_pairing_session` — 早期设计(弃用中)
|
||||
|
||||
### Issue / 协作
|
||||
|
||||
- `issue` — 议题(status, priority, assignee_type+assignee_id, creator_type+creator_id, parent_issue_id, project_id, origin_type, origin_id, acceptance_criteria, due_date, position)
|
||||
- `issue_label` / `issue_to_label` — 标签
|
||||
- `issue_dependency` — 依赖关系(blocks / blocked_by / related)
|
||||
- `issue_subscriber` — 订阅者(reason: creator/assignee/commenter/mentioned/manual)
|
||||
- `issue_reaction` / `comment_reaction` — emoji 反应
|
||||
- `comment` — 评论(type: comment/status_change/progress_update/system, parent_id for threading)
|
||||
- `attachment` — 附件
|
||||
|
||||
### 任务执行
|
||||
|
||||
- `agent_task_queue` — 任务主表(status: queued/dispatched/running/completed/failed/cancelled, context, result, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id)
|
||||
- `task_message` — 每次执行的消息流水(seq, type, tool, input, output)
|
||||
- `task_usage` — Token 用量(input/output/cache_read/cache_write tokens)
|
||||
|
||||
### 对话
|
||||
|
||||
- `chat_session` — 聊天会话(unread_since, session_id, work_dir)
|
||||
- `chat_message` — 消息(role: user/assistant)
|
||||
|
||||
### 项目与组织
|
||||
|
||||
- `project` — 项目(status, priority, lead_type+lead_id, icon)
|
||||
- `pinned_item` — 侧边栏置顶(item_type, item_id, position)
|
||||
|
||||
### 自动化
|
||||
|
||||
- `autopilot` — 规则(assignee_id, execution_mode: create_issue/run_only, issue_title_template, concurrency_policy)
|
||||
- `autopilot_trigger` — 触发器(kind: schedule/webhook/api, cron_expression, timezone, next_run_at, webhook_token)
|
||||
- `autopilot_run` — 执行记录(status: pending/issue_created/running/skipped/completed/failed)
|
||||
|
||||
### 通知与审计
|
||||
|
||||
- `inbox_item` — 收件箱条目(recipient_type, type, severity, read, archived)
|
||||
- `activity_log` — 审计日志(actor_type: member/agent/system, action, details)
|
||||
- `runtime_usage` — 运行时按日聚合 token 用量(给计费/容量规划用)
|
||||
|
||||
---
|
||||
|
||||
## 尾声
|
||||
|
||||
Multica 的设计可以归结为一句话:**把"人在一个看板上协作"这件事,扩展到了"人 + AI agent 在同一个看板上协作"**。
|
||||
|
||||
所有功能都是围绕这个核心展开:
|
||||
- 为了让 agent 能像人一样被分配任务 → polymorphic actor(`assignee_type`)
|
||||
- 为了让 agent 能自己开工 → Autopilot
|
||||
- 为了让 agent 的工作方式能沉淀复用 → Skill
|
||||
- 为了让 agent 执行在用户控制的环境里 → Runtime + Daemon
|
||||
- 为了让人不被通知淹没 → Inbox + 自动订阅
|
||||
- 为了让一次会话有连续性 → Session Resumption
|
||||
|
||||
当你读到某段文案、某个 UI 模块、某张表时,请把它放回这个"人 + AI 协作"的坐标系里去理解它的位置。
|
||||
@@ -1,109 +0,0 @@
|
||||
# Workspace URL 化重构 — 项目汇报
|
||||
|
||||
**日期**:2026-04-15
|
||||
**作者**:Naiyuan
|
||||
**状态**:调研完成,待评审
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里**不含任何 workspace 信息**。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
这个设计已经在产品里直接表现为 3 个已知问题:
|
||||
|
||||
1. **分享链接不可靠**(MUL-43):`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
|
||||
2. **手机端无法切 workspace**(MUL-509):切换只靠 sidebar UI,手机端不展开 sidebar 就没有切换入口
|
||||
3. **多 tab 互相覆盖**:`multica_workspace_id` 是全局 localStorage key,两个 tab 打开不同 workspace 会互相污染
|
||||
|
||||
除了这 3 个显性 bug,架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
|
||||
|
||||
行业惯例(Linear / Notion / Vercel / GitHub)都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
|
||||
|
||||
## 二、调研结论
|
||||
|
||||
### 好消息:基础设施已经就位
|
||||
|
||||
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
|
||||
- 后端已有 `GetWorkspaceBySlug` 查询
|
||||
- 前端 `Workspace` 类型已包含 `slug` 字段
|
||||
- Web 端认证已经切换为 HttpOnly cookie 模式,Next.js middleware 可读到登录态
|
||||
|
||||
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
|
||||
|
||||
### 坏消息:范围比最初估计大
|
||||
|
||||
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
|
||||
|
||||
1. **URL 路由重组**:web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`;desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
|
||||
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions(切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
|
||||
3. **所有路径引用替换**:`push("/issues")` 改为 path builder(`paths.issues()`),影响 ~25 个组件文件
|
||||
4. **Mutation 副作用重构**:`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
|
||||
5. **桌面端 tab 系统适配**:tab 路径天然包含 workspace,切 workspace = 开新 tab 或导航,不再有全局切换动作
|
||||
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug),需要更新
|
||||
7. **后端保留词校验**:slug 不能和前端顶级路由冲突(`login`、`onboarding`、`invite`、`api`、`settings` 等),后端创建时校验
|
||||
8. **内部 markdown 链接兼容**:issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
|
||||
|
||||
### 不需要改的(边界已确认)
|
||||
|
||||
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
|
||||
- `mention://type/id` 协议 — 只存 UUID,workspace-agnostic
|
||||
- CLI 登录 URL — `/login` 也是 pre-workspace,不需要 slug
|
||||
- 后端 API 路径 — 保持 `/api/workspaces/{id}`,slug 仅用于前端 URL
|
||||
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
**核心原则**:URL 是 workspace 上下文的唯一 source of truth,其他状态都是派生态。
|
||||
|
||||
**URL 形状**:`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
|
||||
|
||||
**切换 workspace = 导航**:sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
|
||||
|
||||
**预估影响面**:~30-35 个文件,其中约 20 个是机械替换(hardcoded 路径 → path builder),真正需要思考的核心逻辑改动集中在 5-6 个文件。
|
||||
|
||||
**一个 PR 合并**:中间状态不可运行(URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
|
||||
|
||||
## 四、执行与测试计划
|
||||
|
||||
### 执行阶段
|
||||
|
||||
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
|
||||
2. **下一步**:在独立 worktree 上开发,AI 辅助写代码,过程中人工 review
|
||||
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E)
|
||||
|
||||
### 测试阶段
|
||||
|
||||
1. **本地自测**:
|
||||
- 已知功能路径(创建 / 浏览 / 搜索 issue,切换 workspace,接受邀请,分享链接)
|
||||
- 已知 bug 场景(MUL-43 / MUL-509 / MUL-727 / MUL-820)逐一验证已修复
|
||||
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
|
||||
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
|
||||
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程)
|
||||
- URL 使用感受(分享、收藏、刷新)
|
||||
3. **灰度 / 生产**:测试环境稳定后推生产
|
||||
|
||||
### 风险提示
|
||||
|
||||
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404(产品还没正式 ship、用户量可忽略,所以不做兼容性重定向)
|
||||
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
|
||||
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate(可能性极低,因 slug 限制较严)
|
||||
|
||||
## 五、附注
|
||||
|
||||
这次重构会**顺带修掉**以下已登记 issue,不需要单独开 PR:
|
||||
|
||||
| Issue | 修复方式 |
|
||||
|---|---|
|
||||
| MUL-43(切换 workspace 报错 / 分享链接失效) | URL 带 slug,根本解决 |
|
||||
| MUL-509(手机端无法切 workspace) | 切换变导航,手机能点链接就能切 |
|
||||
| MUL-723(workspace 不在 URL) | 核心目标 |
|
||||
| MUL-727(创建 workspace 闪 /issues) | 删除 mutation 里的 switchWorkspace 副作用 |
|
||||
| MUL-728(删除 workspace 后留在 /settings) | 删除成功后 navigate 到下一个 workspace |
|
||||
| MUL-820(sidebar Join 不切 workspace) | Join 改成跳转到 `/invite/{id}` 走统一路径 |
|
||||
|
||||
不在本次范围内的:Issue #951(WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
|
||||
|
||||
---
|
||||
|
||||
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"dev:desktop": "turbo dev --filter=@multica/desktop",
|
||||
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
|
||||
113
packages/core/analytics/download.ts
Normal file
113
packages/core/analytics/download.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Download funnel instrumentation.
|
||||
*
|
||||
* Complements the onboarding events added in PR #1489 by covering
|
||||
* every surface that advertises the desktop app — landing hero,
|
||||
* landing footer, login, Welcome (web branch), Step 3 — and the
|
||||
* /download page itself. Without this layer we can see Step 3
|
||||
* path selection but not the touchpoint that got the user there,
|
||||
* nor the /download → installer conversion.
|
||||
*
|
||||
* Event names and property shapes are governed by docs/analytics.md;
|
||||
* keep the two in sync when adding a new source or field.
|
||||
*/
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { captureEvent, setPersonProperties } from "./index";
|
||||
|
||||
/**
|
||||
* Where the user clicked a CTA that points at `/download`. Typed union
|
||||
* prevents drift across the five touchpoints and lets PostHog funnels
|
||||
* split cleanly by top-of-funnel entry.
|
||||
*/
|
||||
export type DownloadIntentSource =
|
||||
| "landing_hero"
|
||||
| "landing_footer"
|
||||
| "login"
|
||||
| "welcome"
|
||||
| "step3";
|
||||
|
||||
/**
|
||||
* OS + arch detect result for the /download page. Mirrors the shape of
|
||||
* `@/features/landing/utils/os-detect.ts` without importing it (that
|
||||
* module lives in the web app; core packages can't depend on it). Keep
|
||||
* these enums in lockstep.
|
||||
*/
|
||||
export interface DownloadDetectPayload {
|
||||
detected_os: "mac" | "windows" | "linux" | "unknown";
|
||||
detected_arch: "arm64" | "x64" | "unknown";
|
||||
detect_confident: boolean;
|
||||
version_available: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific installer the user chose on /download. Version is the GitHub
|
||||
* tag name (e.g. "v0.2.13") so we can correlate adoption-by-release.
|
||||
*/
|
||||
export interface DownloadInitiatedPayload {
|
||||
platform: "mac" | "windows" | "linux";
|
||||
arch: "arm64" | "x64";
|
||||
format: "dmg" | "zip" | "exe" | "appimage" | "deb" | "rpm";
|
||||
version: string;
|
||||
primary_cta: boolean;
|
||||
matched_detect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when a user clicks any CTA that navigates to `/download`. We
|
||||
* also write `platform_preference` to person properties so the backend
|
||||
* can segment subsequent events — same convention the Step 3 handler
|
||||
* already uses (see `step-platform-fork.tsx`).
|
||||
*/
|
||||
export function captureDownloadIntent(source: DownloadIntentSource): void {
|
||||
captureEvent("download_intent_expressed", {
|
||||
source,
|
||||
});
|
||||
setPersonProperties({ platform_preference: "desktop" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires once on /download page mount, after OS detection resolves. The
|
||||
* first detection for a given person is mirrored into person properties
|
||||
* via `$set_once` so every downstream event gains a platform dimension
|
||||
* without re-emitting.
|
||||
*/
|
||||
export function captureDownloadPageViewed(
|
||||
payload: DownloadDetectPayload,
|
||||
): void {
|
||||
captureEvent("download_page_viewed", {
|
||||
detected_os: payload.detected_os,
|
||||
detected_arch: payload.detected_arch,
|
||||
detect_confident: payload.detect_confident,
|
||||
version_available: payload.version_available,
|
||||
});
|
||||
setPersonPropertiesOnce({
|
||||
first_detected_os: payload.detected_os,
|
||||
first_detected_arch: payload.detected_arch,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when the user clicks a concrete installer link on `/download`.
|
||||
* `primary_cta` marks the hero-level recommendation versus a manual
|
||||
* pick from the All Platforms matrix; `matched_detect` captures
|
||||
* whether the click matched what we detected (miss = detect got it
|
||||
* wrong / user overrode).
|
||||
*/
|
||||
export function captureDownloadInitiated(
|
||||
payload: DownloadInitiatedPayload,
|
||||
): void {
|
||||
captureEvent("download_initiated", { ...payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* $set_once wire form. Mirrors the backend's `Event.SetOnce` path —
|
||||
* first write wins, subsequent ones are no-ops on PostHog's side.
|
||||
* Wrapping it here keeps call sites free of the no-op `$set_once`
|
||||
* envelope quirk.
|
||||
*/
|
||||
function setPersonPropertiesOnce(props: Record<string, unknown>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
posthog.capture("$set", { $set_once: props });
|
||||
}
|
||||
34
packages/core/analytics/feedback.ts
Normal file
34
packages/core/analytics/feedback.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Feedback funnel instrumentation.
|
||||
*
|
||||
* Pairs with the backend's `feedback_submitted` event (emitted from
|
||||
* `CreateFeedback` after a successful insert) so we can compute a
|
||||
* completion rate: users who open the modal → users who actually send.
|
||||
* The message content itself is never sent to PostHog; see
|
||||
* docs/analytics.md and the backend `FeedbackSubmitted` helper for the
|
||||
* PII contract.
|
||||
*/
|
||||
|
||||
import { captureEvent } from "./index";
|
||||
|
||||
/**
|
||||
* Entry point the user took to reach the Feedback modal. Typed union so
|
||||
* future surfaces (keyboard shortcut, error-toast CTA, sidebar menu
|
||||
* item) have to extend this list explicitly rather than drift.
|
||||
*/
|
||||
export type FeedbackOpenedSource = "help_menu";
|
||||
|
||||
/**
|
||||
* Fires once on FeedbackModal mount. Workspace id is attached when the
|
||||
* modal opens inside a workspace; pre-workspace surfaces (e.g. inbox,
|
||||
* onboarding transitions) omit it rather than sending an empty string.
|
||||
*/
|
||||
export function captureFeedbackOpened(
|
||||
source: FeedbackOpenedSource,
|
||||
workspaceId?: string,
|
||||
): void {
|
||||
captureEvent("feedback_opened", {
|
||||
source,
|
||||
...(workspaceId ? { workspace_id: workspaceId } : {}),
|
||||
});
|
||||
}
|
||||
88
packages/core/analytics/index.test.ts
Normal file
88
packages/core/analytics/index.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock posthog-js before importing the module under test so the module's
|
||||
// top-level `import posthog from "posthog-js"` resolves to the mock.
|
||||
vi.mock("posthog-js", () => {
|
||||
const mock = {
|
||||
init: vi.fn(),
|
||||
register: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
};
|
||||
return { default: mock };
|
||||
});
|
||||
|
||||
// Re-import per test so module-level `initialized` / cached super-props
|
||||
// don't leak between cases.
|
||||
async function loadModule() {
|
||||
vi.resetModules();
|
||||
const analytics = await import("./index");
|
||||
const posthog = (await import("posthog-js")).default as unknown as {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
posthog.init.mockClear();
|
||||
posthog.register.mockClear();
|
||||
posthog.reset.mockClear();
|
||||
return { analytics, posthog };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", {});
|
||||
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("initAnalytics super-properties", () => {
|
||||
it("registers client_type and app_version after posthog.init", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits app_version when not provided", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "web" });
|
||||
});
|
||||
|
||||
it("detects desktop when window.electron is present", async () => {
|
||||
vi.stubGlobal("window", { electron: {} });
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "desktop" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAnalytics", () => {
|
||||
it("re-registers super-properties after reset so subsequent events keep client_type", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
||||
posthog.register.mockClear();
|
||||
|
||||
analytics.resetAnalytics();
|
||||
|
||||
// reset() wipes persisted super-props; we re-register the cached set so
|
||||
// the next session's events keep client_type + app_version.
|
||||
expect(posthog.reset).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when analytics was never initialized", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.resetAnalytics();
|
||||
expect(posthog.reset).not.toHaveBeenCalled();
|
||||
expect(posthog.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
340
packages/core/analytics/index.ts
Normal file
340
packages/core/analytics/index.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// Frontend analytics glue. Thin wrapper over posthog-js.
|
||||
//
|
||||
// The source-of-truth event catalog is `docs/analytics.md`. This module only
|
||||
// handles the two things the backend can't do itself: attribution capture on
|
||||
// first anonymous pageview, and person-identity merge on login. Every funnel
|
||||
// event (signup, workspace_created, runtime_registered, issue_executed,
|
||||
// invite_sent, invite_accepted) is emitted server-side — see
|
||||
// `server/internal/analytics`.
|
||||
//
|
||||
// Configuration comes from the backend's `/api/config` response (populated
|
||||
// from POSTHOG_API_KEY on the server), NOT from NEXT_PUBLIC_* envs. That
|
||||
// keeps self-hosted Docker images from leaking our project key — their
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
|
||||
// Per-value cap keeps a long utm_content from blowing the budget. We drop
|
||||
// the entire cookie if the JSON still exceeds the overall limit — partial
|
||||
// JSON is worse than no attribution because PostHog can't parse it.
|
||||
const SIGNUP_SOURCE_VALUE_MAX_LEN = 96;
|
||||
const SIGNUP_SOURCE_MAX_LEN = 512;
|
||||
const UTM_KEYS = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
] as const;
|
||||
|
||||
let initialized = false;
|
||||
// auth-initializer fetches /api/config and /api/me in parallel — on a
|
||||
// slow-config path, identify() can fire before initAnalytics(). Buffer the
|
||||
// most recent pending identify (only one matters, since it's per-session)
|
||||
// and flush it inside initAnalytics.
|
||||
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
|
||||
// Likewise pageviews: the initial "/" pageview is the anchor of the
|
||||
// acquisition funnel, and the Next.js router fires it on mount before the
|
||||
// config fetch resolves. We keep the first pending pageview so that step
|
||||
// doesn't silently drop.
|
||||
let pendingPageview: string | undefined | null = null;
|
||||
// Frontend-emitted events (captureEvent) and person-property updates
|
||||
// (setPersonProperties) can also arrive before init — same config-race as
|
||||
// identify/pageview. We replay them in order once init succeeds. These
|
||||
// only ever carry user-triggered signals on identified users, so the
|
||||
// buffer stays small (~one step-transition worth).
|
||||
type PendingOp =
|
||||
| { kind: "event"; name: string; props?: Record<string, unknown> }
|
||||
| { kind: "set"; props: Record<string, unknown> };
|
||||
const pendingOps: PendingOp[] = [];
|
||||
// Cached super-properties so resetAnalytics() can re-register them after
|
||||
// posthog.reset() wipes the persisted set. Without this, logout / account
|
||||
// switch silently drops client_type + app_version from every subsequent
|
||||
// event until a full reload.
|
||||
let superProperties: Record<string, unknown> = {};
|
||||
|
||||
export {
|
||||
captureDownloadIntent,
|
||||
captureDownloadPageViewed,
|
||||
captureDownloadInitiated,
|
||||
type DownloadIntentSource,
|
||||
type DownloadDetectPayload,
|
||||
type DownloadInitiatedPayload,
|
||||
} from "./download";
|
||||
|
||||
export {
|
||||
captureFeedbackOpened,
|
||||
type FeedbackOpenedSource,
|
||||
} from "./feedback";
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
key: string;
|
||||
host: string;
|
||||
/**
|
||||
* Client app version — attached to every event as an `app_version`
|
||||
* super-property. Web injects the build-time tag / sha; desktop reads from
|
||||
* the Electron API. Optional because local dev may not have a version
|
||||
* available.
|
||||
*/
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export type ClientType = "desktop" | "web";
|
||||
|
||||
/**
|
||||
* Classify the current runtime as desktop (Electron renderer) or web. Used as
|
||||
* a super-property so every event can be split by client without relying on
|
||||
* PostHog's `$lib`, which reports "web" in both the Next.js app and the
|
||||
* Electron renderer (both Chromium).
|
||||
*
|
||||
* Signals we trust:
|
||||
* - `window.electron` is exposed by the preload script in every renderer.
|
||||
* - `navigator.userAgent` contains "Electron" as a fallback.
|
||||
*/
|
||||
export function detectClientType(): ClientType {
|
||||
if (typeof window === "undefined") return "web";
|
||||
const w = window as unknown as { electron?: unknown; desktopAPI?: unknown };
|
||||
if (w.electron || w.desktopAPI) return "desktop";
|
||||
if (typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent)) {
|
||||
return "desktop";
|
||||
}
|
||||
return "web";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize posthog-js if a key is present. Safe to call multiple times;
|
||||
* subsequent calls with the same config are no-ops.
|
||||
*
|
||||
* Returns `true` when analytics is actually running; `false` when disabled
|
||||
* (no key, SSR, or already initialized with a conflicting key — which we
|
||||
* treat as "use the existing instance").
|
||||
*/
|
||||
export function initAnalytics(config: AnalyticsConfig | null | undefined): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!config?.key) return false;
|
||||
if (initialized) return true;
|
||||
|
||||
posthog.init(config.key, {
|
||||
api_host: config.host || "https://us.i.posthog.com",
|
||||
// person_profiles=identified_only keeps anonymous drive-by traffic off
|
||||
// the billed events until they actually identify, which aligns with how
|
||||
// our funnel is set up: signup is the first real funnel step.
|
||||
person_profiles: "identified_only",
|
||||
// Turn off every on-by-default auto-capture surface. Our funnel is
|
||||
// narrow and explicit (the events in docs/analytics.md + a manual
|
||||
// $pageview). Autocapture floods the Activity view with anonymous
|
||||
// "clicked button" / "clicked link" noise, burns the billed event
|
||||
// budget, and risks capturing user-typed content in input values.
|
||||
// Turn things back on deliberately if we ever want them.
|
||||
capture_pageview: false,
|
||||
autocapture: false,
|
||||
capture_heatmaps: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_exceptions: false,
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
// Register super-properties — attached to every event emitted from this
|
||||
// client. `client_type` is the canonical split between desktop and web
|
||||
// (PostHog's own `$lib` reports "web" for both because Electron renderers
|
||||
// are Chromium). `app_version` is optional so self-hosted or local dev
|
||||
// builds without a version don't pollute the property.
|
||||
// We cache the set so resetAnalytics() can re-apply it after
|
||||
// posthog.reset() — reset() clears persisted super-properties otherwise.
|
||||
superProperties = { client_type: detectClientType() };
|
||||
if (config.appVersion) superProperties.app_version = config.appVersion;
|
||||
posthog.register(superProperties);
|
||||
initialized = true;
|
||||
|
||||
// Flush any identify() that arrived before init resolved.
|
||||
if (pendingIdentify) {
|
||||
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
|
||||
pendingIdentify = null;
|
||||
}
|
||||
// And any first pageview we captured while config was loading.
|
||||
if (pendingPageview !== null) {
|
||||
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
|
||||
pendingPageview = null;
|
||||
}
|
||||
// Replay buffered events / person-property updates in their original
|
||||
// order — funnel correctness depends on sequence (e.g. a user submits
|
||||
// the questionnaire and then finishes onboarding within the same
|
||||
// config-race window).
|
||||
while (pendingOps.length > 0) {
|
||||
const op = pendingOps.shift()!;
|
||||
if (op.kind === "event") {
|
||||
posthog.capture(op.name, op.props);
|
||||
} else {
|
||||
capturePersonSet(op.props);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the current anonymous session into the logged-in person. Must be
|
||||
* called exactly once per auth transition (login / session-resume). Pulling
|
||||
* attribution properties into person_properties on identify is how we keep
|
||||
* UTM / referrer on the user profile without re-emitting them per event.
|
||||
*
|
||||
* Calls before initAnalytics() are buffered — auth-initializer fetches
|
||||
* config and user in parallel, so identify can arrive first.
|
||||
*/
|
||||
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
|
||||
if (!initialized) {
|
||||
pendingIdentify = { userId, props: userProperties };
|
||||
return;
|
||||
}
|
||||
posthog.identify(userId, userProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the client-side identity on logout so the next login merges cleanly
|
||||
* and doesn't bleed the previous user's events into a new session.
|
||||
*/
|
||||
export function resetAnalytics(): void {
|
||||
pendingIdentify = null;
|
||||
pendingPageview = null;
|
||||
pendingOps.length = 0;
|
||||
if (!initialized) return;
|
||||
posthog.reset();
|
||||
// reset() wipes persisted super-properties too, so re-register the ones
|
||||
// set at init time. Otherwise every event after logout / account-switch
|
||||
// would be missing client_type + app_version until a full reload.
|
||||
if (Object.keys(superProperties).length > 0) {
|
||||
posthog.register(superProperties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a frontend-emitted event. Most funnel events fire server-side
|
||||
* (see `server/internal/analytics`); this wrapper is reserved for the
|
||||
* handful of signals the backend can't see — primarily the Step 3
|
||||
* platform-fork choice on web, where the user's click never round-trips
|
||||
* to a handler.
|
||||
*
|
||||
* Calls before initAnalytics() buffer in order so a late-arriving config
|
||||
* doesn't silently swallow a step transition.
|
||||
*/
|
||||
export function captureEvent(
|
||||
name: string,
|
||||
props?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!initialized) {
|
||||
pendingOps.push({ kind: "event", name, props });
|
||||
return;
|
||||
}
|
||||
posthog.capture(name, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (overwrite) person properties on the currently identified user.
|
||||
* Mirrors the backend's `Event.Set` path — keep these aligned so the
|
||||
* same cohort signals (role, use_case, platform_preference) are
|
||||
* queryable regardless of which side emitted last. Use for mutable
|
||||
* signals; use `identify(userId, { $set_once: {...} })` style for
|
||||
* attribution fields that must never be overwritten.
|
||||
*/
|
||||
export function setPersonProperties(props: Record<string, unknown>): void {
|
||||
if (!initialized) {
|
||||
pendingOps.push({ kind: "set", props });
|
||||
return;
|
||||
}
|
||||
capturePersonSet(props);
|
||||
}
|
||||
|
||||
// The public wire-level contract for `$set` is a no-op event carrying a
|
||||
// `$set` property. Wrapping it here (rather than calling
|
||||
// `posthog.setPersonProperties` directly) keeps us version-independent —
|
||||
// older posthog-js builds expose the same protocol under `posthog.people.set`,
|
||||
// and the capture form works uniformly.
|
||||
function capturePersonSet(props: Record<string, unknown>): void {
|
||||
posthog.capture("$set", { $set: props });
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a page view. Call once per client-side navigation. We disable
|
||||
* posthog's automatic pageview tracking in init() so this module owns the
|
||||
* event shape — that makes it trivial to add properties (e.g. workspace
|
||||
* slug) without fighting the SDK.
|
||||
*
|
||||
* Calls before initAnalytics() buffer the most-recent path so the first
|
||||
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
|
||||
* pageviews overwrite the buffer; after init flushes, every navigation
|
||||
* captures synchronously as expected.
|
||||
*/
|
||||
export function capturePageview(path?: string): void {
|
||||
if (!initialized) {
|
||||
pendingPageview = path ?? "";
|
||||
return;
|
||||
}
|
||||
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* On the very first anonymous pageview in a browser session, read UTM +
|
||||
* referrer and stash them in a cookie that the backend reads during signup.
|
||||
*
|
||||
* Never use raw `document.referrer` as attribution — it can leak OAuth
|
||||
* callback URLs with `code` / `state` in the query string. We keep only the
|
||||
* referrer's origin (scheme + host), which is what a funnel actually needs.
|
||||
*
|
||||
* This cookie is what `signup_source` in the backend's signup event reads
|
||||
* from; both fields are intentionally opaque JSON so the schema can evolve
|
||||
* without a backend deploy.
|
||||
*/
|
||||
export function captureSignupSource(): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
if (readCookie(SIGNUP_SOURCE_COOKIE)) return;
|
||||
|
||||
const source: Record<string, string> = {};
|
||||
const cap = (v: string) =>
|
||||
v.length > SIGNUP_SOURCE_VALUE_MAX_LEN ? v.slice(0, SIGNUP_SOURCE_VALUE_MAX_LEN) : v;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const key of UTM_KEYS) {
|
||||
const v = params.get(key);
|
||||
if (v) source[key] = cap(v);
|
||||
}
|
||||
} catch {
|
||||
// URL APIs unavailable — skip silently.
|
||||
}
|
||||
|
||||
const refOrigin = safeReferrerOrigin(document.referrer);
|
||||
if (refOrigin) source.referrer_origin = cap(refOrigin);
|
||||
|
||||
if (Object.keys(source).length === 0) return;
|
||||
|
||||
const payload = JSON.stringify(source);
|
||||
// Drop rather than mid-JSON truncate — a half-string would fail to parse
|
||||
// on the backend and the attribution would be worse than missing.
|
||||
if (payload.length > SIGNUP_SOURCE_MAX_LEN) return;
|
||||
|
||||
// 30-day expiry covers the typical signup consideration window. Lax is
|
||||
// the right default — the cookie is only consumed by same-origin auth.
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(payload)}; path=/; max-age=${maxAge}; samesite=lax`;
|
||||
}
|
||||
|
||||
function safeReferrerOrigin(referrer: string): string {
|
||||
if (!referrer) return "";
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
if (url.origin === window.location.origin) return "";
|
||||
return url.origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const parts = document.cookie ? document.cookie.split("; ") : [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -106,4 +106,42 @@ describe("ApiClient", () => {
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits X-Client-* headers when identity is configured", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test", {
|
||||
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
|
||||
});
|
||||
await client.listWorkspaces();
|
||||
|
||||
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
||||
expect(headers["X-Client-Platform"]).toBe("desktop");
|
||||
expect(headers["X-Client-Version"]).toBe("1.2.3");
|
||||
expect(headers["X-Client-OS"]).toBe("macos");
|
||||
});
|
||||
|
||||
it("omits X-Client-* headers when identity is not configured", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.listWorkspaces();
|
||||
|
||||
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
||||
expect(headers["X-Client-Platform"]).toBeUndefined();
|
||||
expect(headers["X-Client-Version"]).toBeUndefined();
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user