Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
46e0b7481f docs(make): add help description for db-reset target
Follow-up to #1434. The merge-in of db-reset from main happened during
#1434's conflict resolution and didn't get a `##` description, so it
doesn't appear in `make help`. Add the one-line description so the
target surfaces under the Database grouping alongside the other `db-*`
commands.
2026-04-22 14:08:24 +08:00
190 changed files with 11389 additions and 9893 deletions

View File

@@ -36,14 +36,6 @@ 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).
@@ -52,12 +44,10 @@ 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=
@@ -100,9 +90,10 @@ 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
# 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.
# 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
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
@@ -118,3 +109,4 @@ POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=

View File

@@ -10,14 +10,10 @@ on:
permissions:
contents: write
packages: write
jobs:
verify:
release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.release_meta.outputs.tag_name }}
is_stable: ${{ steps.release_meta.outputs.is_stable }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -25,8 +21,6 @@ jobs:
fetch-depth: 0
- name: Validate tag name
id: release_meta
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
@@ -38,12 +32,6 @@ jobs:
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
if [[ "$tag" == *-* ]]; then
echo "is_stable=false" >> "$GITHUB_OUTPUT"
else
echo "is_stable=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
uses: actions/setup-go@v5
@@ -54,21 +42,6 @@ jobs:
- name: Run tests
run: cd server && go test ./...
release:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -78,244 +51,6 @@ jobs:
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

1
.gitignore vendored
View File

@@ -57,4 +57,3 @@ _features/
server/server
data/
.kilo
.idea

View File

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

View File

@@ -191,28 +191,64 @@ 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 context
### Workspace identity singleton
`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.
`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.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
Leave / Delete workspace flows must follow this order:
1. Read destination from cached workspace list.
1. Read destination from cached workspace list (no extra fetch).
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)`.
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
4. THEN `await mutation.mutateAsync(workspaceId)`.
Reversing step 4 with steps 13 (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)
### Drag region (macOS window-move)
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.
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
```tsx
import { DragStrip } from "@multica/views/platform";
return (
<div className="flex min-h-svh flex-col bg-background">
<DragStrip />
<div className="flex flex-1 flex-col px-6 pb-12">
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
</div>
</div>
);
```
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
### UX vs platform chrome
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
## UI/UX Rules

View File

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

View File

@@ -1,4 +1,4 @@
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
.PHONY: 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-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -52,7 +52,7 @@ 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
selfhost: ## Create .env if needed, then build and start the local self-hosted stack
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -64,58 +64,8 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
echo " make selfhost-build"; \
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d
@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
docker compose -f docker-compose.selfhost.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -132,9 +82,6 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
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"; \

View File

@@ -85,8 +85,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---

View File

@@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
@@ -54,10 +54,6 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
@@ -73,8 +69,6 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
### Step 3 — Install CLI & Start Daemon
@@ -162,15 +156,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Upgrading
## Rebuilding After Updates
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
make selfhost
```
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
Migrations run automatically on backend startup.
---
@@ -193,7 +186,6 @@ JWT_SECRET=$(openssl rand -hex 32)
Then start everything:
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```

View File

@@ -42,18 +42,6 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -246,7 +234,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -262,15 +250,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then restart the stack:
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d --build
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
## Health Check
@@ -286,9 +274,8 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

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

View File

@@ -10,7 +10,6 @@ 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";
@@ -123,21 +122,15 @@ 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 (!workspaceListFetched) return;
if (!workspaces) return;
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (!activeWorkspaceSlug && workspaces.length > 0) {
switchWorkspace(workspaces[0].slug);
const tabStore = useTabStore.getState();
tabStore.validateWorkspaceSlugs(validSlugs);
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
tabStore.switchWorkspace(workspaces[0].slug);
}
}, [workspaces, workspaceListFetched]);
}, [workspaces]);
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
@@ -167,15 +160,8 @@ function AppContent() {
);
}
// Pageview tracker sits at the app root so it covers every visible
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
// would miss the logged-out and overlay states.
return (
<>
<PageviewTracker />
{user ? <DesktopShell /> : <DesktopLoginPage />}
</>
);
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
}
// Backend the daemon should connect to — same URL the renderer talks to.

View File

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

View File

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

View File

@@ -110,25 +110,12 @@ export function UpdateNotification() {
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<div className="mt-2 flex items-center gap-1.5">
{/* Secondary "See changes" — gives the user a reason to
restart by surfacing what they're about to get. Opens
in the default browser via the shared openExternal
bridge so the URL hits the same allow-list as every
other outbound link. */}
<button
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
See changes
</button>
<button
onClick={handleInstall}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
<button
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
)}

View File

@@ -114,32 +114,18 @@ export function DesktopNavigationProvider({
// resolve the active router here only to subscribe once per tab switch.
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
// Mirror the active tab router's full location (pathname + search) so
// shell-level consumers of useNavigation() — ChatWindow in particular —
// can read URL search params. Must stay in sync with TabNavigationProvider
// below; a partial shape here (just pathname) silently broke focus-mode
// anchor resolution on `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",
search: router?.state.location.search ?? "",
}),
const [pathname, setPathname] = useState(
router?.state.location.pathname ?? "/",
);
useEffect(() => {
if (!router) {
setLocation({ pathname: "/", search: "" });
setPathname("/");
return;
}
setLocation({
pathname: router.state.location.pathname,
search: router.state.location.search,
});
setPathname(router.state.location.pathname);
return router.subscribe((state) => {
setLocation({
pathname: state.location.pathname,
search: state.location.search,
});
setPathname(state.location.pathname);
});
}, [activeTabId, router]);
@@ -164,8 +150,8 @@ export function DesktopNavigationProvider({
back: () => {
currentActiveTab()?.router.navigate(-1);
},
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
pathname,
searchParams: new URLSearchParams(),
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
@@ -181,7 +167,7 @@ export function DesktopNavigationProvider({
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
[location],
[pathname],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -13,8 +13,9 @@ import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
@@ -113,7 +114,7 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <DesktopRuntimesPage />,
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -53,17 +53,13 @@ make selfhost
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
@@ -74,8 +70,6 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
<Callout>
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
</Callout>
@@ -157,15 +151,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Upgrading
## Rebuilding After Updates
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
make selfhost
```
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
Migrations run automatically on backend startup.
---
@@ -198,18 +191,6 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:

View File

@@ -4,7 +4,6 @@ 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,
@@ -22,15 +21,14 @@ 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();
@@ -174,22 +172,6 @@ function LoginPageContent() {
: undefined
}
onTokenObtained={setLoggedInCookie}
extra={
// Web-only nudge toward the desktop app. Copy is hardcoded EN
// for now because the login route sits outside the landing
// group's LocaleProvider — if this page ever becomes
// locale-aware, the strings live in positioning doc §3.3.
<span className="text-xs text-muted-foreground">
Prefer the desktop app?{" "}
<Link
href="/download"
onClick={() => captureDownloadIntent("login")}
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
>
Download
</Link>
</span>
}
/>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
@@ -21,9 +21,11 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
* 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.
* The CLI install card is wired here so its `multica setup` command
* points at THIS server — dev landing on localhost gets a localhost
* self-host command, prod cloud gets the plain `multica setup`, prod
* self-host gets one with explicit URLs. `appUrl` lives in useState
* so SSR doesn't error on `window` — it fills in on mount.
*/
export default function OnboardingPage() {
const router = useRouter();
@@ -34,6 +36,11 @@ export default function OnboardingPage() {
...workspaceListOptions(),
enabled: !!user && hasOnboarded,
});
const [appUrl, setAppUrl] = useState<string | undefined>(undefined);
useEffect(() => {
setAppUrl(window.location.origin);
}, []);
useEffect(() => {
if (isLoading || !user) {
@@ -65,7 +72,12 @@ export default function OnboardingPage() {
router.push(paths.root());
}
}}
runtimeInstructions={<CliInstallInstructions />}
runtimeInstructions={
<CliInstallInstructions
apiUrl={process.env.NEXT_PUBLIC_API_URL}
appUrl={appUrl}
/>
}
/>
</div>
);

View File

@@ -1,140 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { LandingHeader } from "@/features/landing/components/landing-header";
import { LandingFooter } from "@/features/landing/components/landing-footer";
import { DownloadHero } from "@/features/landing/components/download/hero";
import { AllPlatforms } from "@/features/landing/components/download/all-platforms";
import { CliSection } from "@/features/landing/components/download/cli-section";
import { CloudSection } from "@/features/landing/components/download/cloud-section";
import { useLocale } from "@/features/landing/i18n";
import {
detectOS,
type DetectResult,
} from "@/features/landing/utils/os-detect";
import type { LatestRelease } from "@/features/landing/utils/github-release";
import { captureDownloadPageViewed } from "@multica/core/analytics";
const ALL_RELEASES_URL =
"https://github.com/multica-ai/multica/releases";
export function DownloadClient({ release }: { release: LatestRelease }) {
const [detected, setDetected] = useState<DetectResult | null>(null);
const versionUnavailable = release.version === null;
useEffect(() => {
let cancelled = false;
detectOS().then((result) => {
if (cancelled) return;
setDetected(result);
// Fires once per page mount after detect resolves. Carries the
// detect outcome + version-unavailable flag so PostHog can split
// Safari-mac-arm64 fallback rate, Intel-Mac dead-end rate, and
// rate-limit degraded sessions. `first_detected_os/arch` is
// $set_once'd on the person so every downstream event gains a
// platform dimension (useful for "Android visitors who later
// downloaded Windows" style cross-device queries once we land
// the desktop install closure).
captureDownloadPageViewed({
detected_os: result.os,
detected_arch: result.arch,
detect_confident: result.archConfident,
version_available: !versionUnavailable,
});
});
return () => {
cancelled = true;
};
}, [versionUnavailable]);
const releaseHtmlUrl = release.htmlUrl ?? ALL_RELEASES_URL;
return (
<>
{/* Positioning context for the dark-variant LandingHeader —
mirrors multica-landing.tsx. The header is `absolute top-0
inset-x-0`, so it anchors to this `relative` wrapper and
scrolls off together with the dark hero below. Without the
wrapper, `absolute` would escape to the initial containing
block and read as fixed. */}
<div className="relative">
<LandingHeader variant="dark" />
<DownloadHero
detected={detected}
assets={release.assets}
versionUnavailable={versionUnavailable}
version={release.version}
/>
</div>
<AllPlatforms
assets={release.assets}
fallbackHref={ALL_RELEASES_URL}
version={release.version}
detected={detected}
/>
<CliSection />
<CloudSection />
<VersionInfoFooter
version={release.version}
releaseHtmlUrl={releaseHtmlUrl}
/>
<LandingFooter />
</>
);
}
function VersionInfoFooter({
version,
releaseHtmlUrl,
}: {
version: string | null;
releaseHtmlUrl: string;
}) {
const { t } = useLocale();
const d = t.download.footer;
return (
<section className="bg-white pb-16 text-[#0a0d12] sm:pb-20">
<div className="mx-auto flex max-w-[920px] flex-wrap items-center gap-x-6 gap-y-2 border-t border-[#0a0d12]/8 px-4 pt-8 text-[13px] text-[#0a0d12]/60 sm:px-6 lg:px-8">
{version ? (
<>
<span>
{d.currentVersion.replace("{version}", version)}
</span>
<span aria-hidden className="text-[#0a0d12]/25">
·
</span>
<Link
href={releaseHtmlUrl}
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
target="_blank"
rel="noreferrer"
>
{d.releaseNotes.replace("{version}", version)}
</Link>
<span aria-hidden className="text-[#0a0d12]/25">
·
</span>
</>
) : (
<>
<span>{d.versionUnavailable}</span>
<span aria-hidden className="text-[#0a0d12]/25">
·
</span>
</>
)}
<Link
href={ALL_RELEASES_URL}
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
target="_blank"
rel="noreferrer"
>
{d.allReleases}
</Link>
</div>
</section>
);
}

View File

@@ -1,29 +0,0 @@
import type { Metadata } from "next";
import { fetchLatestRelease } from "@/features/landing/utils/github-release";
import { DownloadClient } from "./download-client";
// Vercel ISR: the server fetch inside fetchLatestRelease carries
// `next: { revalidate: 300 }`, which makes GitHub API cost at most
// one request per region per 5 minutes. Page-level revalidate mirrors
// that window so the first paint also refreshes every 5 minutes.
export const revalidate = 300;
export const metadata: Metadata = {
title: "Download Multica",
description:
"Download Multica for macOS, Windows, or Linux — or install the CLI for servers and remote dev boxes.",
openGraph: {
title: "Download Multica",
description:
"Get the Multica desktop app with a bundled daemon, or install the CLI for servers and remote dev boxes.",
url: "/download",
},
alternates: {
canonical: "/download",
},
};
export default async function DownloadPage() {
const release = await fetchLatestRelease();
return <DownloadClient release={release} />;
}

View File

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

View File

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

View File

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

View File

@@ -1,239 +0,0 @@
import Link from "next/link";
import {
captureDownloadInitiated,
type DownloadInitiatedPayload,
} from "@multica/core/analytics";
import { useLocale } from "../../i18n";
import type { DetectResult } from "../../utils/os-detect";
import type { DownloadAssets } from "../../utils/parse-release-assets";
import { AppleIcon, LinuxIcon, WindowsIcon } from "./os-icons";
type Platform = DownloadInitiatedPayload["platform"];
type Arch = DownloadInitiatedPayload["arch"];
type Format = DownloadInitiatedPayload["format"];
interface Props {
assets: DownloadAssets;
/** Link to GitHub releases page, used when individual asset URLs
* couldn't be resolved (API down / parse failure). */
fallbackHref: string;
/** Release tag (e.g. "v0.2.13"); null on fetch failure. */
version: string | null;
/** Current OS/arch guess. Used only to compute `matched_detect` on
* the download_initiated event — the row UI itself is static. */
detected: DetectResult | null;
}
/**
* Full matrix of platform + arch + format links. Always visible
* regardless of which platform the Hero resolved to — lets power
* users grab any build directly.
*/
export function AllPlatforms({
assets,
fallbackHref,
version,
detected,
}: Props) {
const { t } = useLocale();
const d = t.download.allPlatforms;
const trackClick = (platform: Platform, arch: Arch, format: Format) => {
if (!version) return;
captureDownloadInitiated({
platform,
arch,
format,
version,
// Manual pick from the matrix — Hero is the primary CTA.
primary_cta: false,
// True only when the row matches what we guessed client-side.
// Lets us measure detect accuracy from the miss rate on this
// event alone (no need to cross-join to download_page_viewed).
matched_detect:
!!detected &&
detected.os === platform &&
detected.arch === arch,
});
};
return (
<section
id="all-platforms"
className="bg-white py-20 text-[#0a0d12] sm:py-24"
>
<div className="mx-auto max-w-[920px] px-4 sm:px-6 lg:px-8">
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
{d.title}
</h2>
<div className="mt-10 overflow-hidden rounded-2xl border border-[#0a0d12]/10">
<Row
icon={<AppleIcon className="text-[#0a0d12]" />}
label={d.macLabel}
formats={[
{
label: d.formatDmg,
href: assets.macArm64Dmg,
onClick: () => trackClick("mac", "arm64", "dmg"),
},
{
label: d.formatZip,
href: assets.macArm64Zip,
onClick: () => trackClick("mac", "arm64", "zip"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<WindowsIcon className="text-[#0a0d12]" />}
label={d.winX64Label}
formats={[
{
label: d.formatExe,
href: assets.winX64Exe,
onClick: () => trackClick("windows", "x64", "exe"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<WindowsIcon className="text-[#0a0d12]" />}
label={d.winArm64Label}
formats={[
{
label: d.formatExe,
href: assets.winArm64Exe,
onClick: () => trackClick("windows", "arm64", "exe"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<LinuxIcon className="text-[#0a0d12]" />}
label={d.linuxX64Label}
formats={[
{
label: d.formatAppImage,
href: assets.linuxAmd64AppImage,
onClick: () => trackClick("linux", "x64", "appimage"),
},
{
label: d.formatDeb,
href: assets.linuxAmd64Deb,
onClick: () => trackClick("linux", "x64", "deb"),
},
{
label: d.formatRpm,
href: assets.linuxAmd64Rpm,
onClick: () => trackClick("linux", "x64", "rpm"),
},
]}
unavailable={d.unavailable}
/>
<Row
icon={<LinuxIcon className="text-[#0a0d12]" />}
label={d.linuxArm64Label}
formats={[
{
label: d.formatAppImage,
href: assets.linuxArm64AppImage,
onClick: () => trackClick("linux", "arm64", "appimage"),
},
{
label: d.formatDeb,
href: assets.linuxArm64Deb,
onClick: () => trackClick("linux", "arm64", "deb"),
},
{
label: d.formatRpm,
href: assets.linuxArm64Rpm,
onClick: () => trackClick("linux", "arm64", "rpm"),
},
]}
unavailable={d.unavailable}
isLast
/>
</div>
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.intelNote}</p>
{isFallbackNeeded(assets) ? (
<p className="mt-2 text-[13px] text-[#0a0d12]/60">
<Link
href={fallbackHref}
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
target="_blank"
rel="noreferrer"
>
{t.download.footer.allReleases}
</Link>
</p>
) : null}
</div>
</section>
);
}
// ------------------------------------------------------------
// Row
// ------------------------------------------------------------
interface RowProps {
icon: React.ReactNode;
label: string;
formats: {
label: string;
href: string | undefined;
onClick: () => void;
}[];
unavailable: string;
isLast?: boolean;
}
function Row({ icon, label, formats, unavailable, isLast }: RowProps) {
return (
<div
className={`flex flex-wrap items-center gap-x-6 gap-y-3 px-6 py-5 ${isLast ? "" : "border-b border-[#0a0d12]/8"}`}
>
<div className="flex min-w-[220px] items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#0a0d12]/5">
{icon}
</span>
<span className="text-[14.5px] font-medium">{label}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{formats.map((f) =>
f.href ? (
<a
key={f.label}
href={f.href}
onClick={f.onClick}
className="inline-flex items-center gap-1.5 rounded-lg border border-[#0a0d12]/12 bg-white px-3 py-1.5 text-[13px] font-medium transition-colors hover:border-[#0a0d12]/30 hover:bg-[#0a0d12]/5"
>
{f.label}
</a>
) : (
<span
key={f.label}
aria-disabled="true"
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-lg border border-[#0a0d12]/8 bg-[#0a0d12]/5 px-3 py-1.5 text-[13px] text-[#0a0d12]/40"
title={unavailable}
>
{f.label}
</span>
),
)}
</div>
</div>
);
}
// Ten desktop artifacts are expected per release (two Mac,
// two Windows, six Linux). If any are missing, surface the GitHub
// fallback link so users on an orphaned row have a way out.
const EXPECTED_ASSET_COUNT = 10;
function isFallbackNeeded(assets: DownloadAssets): boolean {
return Object.values(assets).filter(Boolean).length < EXPECTED_ASSET_COUNT;
}

View File

@@ -1,108 +0,0 @@
"use client";
import { useState } from "react";
import { Check, Copy, Terminal } from "lucide-react";
import { useLocale } from "../../i18n";
const INSTALL_CMD =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
const SETUP_CMD = "multica setup";
/**
* Scenario-first CLI section. Copy leans into servers / remote dev
* boxes / headless setups rather than positioning CLI as a
* lightweight Desktop. Two copy-and-paste command blocks.
*/
export function CliSection() {
const { t } = useLocale();
const d = t.download.cli;
return (
<section id="cli" className="bg-[#f7f7f5] py-20 text-[#0a0d12] sm:py-24">
<div className="mx-auto max-w-[820px] px-4 sm:px-6 lg:px-8">
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
{d.title}
</h2>
<p className="mt-4 max-w-[620px] text-[15px] leading-7 text-[#0a0d12]/72">
{d.sub}
</p>
<div className="mt-10 flex flex-col gap-5">
<CommandBlock
label={d.installLabel}
cmd={INSTALL_CMD}
copyLabel={d.copyLabel}
copiedLabel={d.copiedLabel}
/>
<CommandBlock
label={d.startLabel}
cmd={SETUP_CMD}
copyLabel={d.copyLabel}
copiedLabel={d.copiedLabel}
/>
</div>
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.sshNote}</p>
</div>
</section>
);
}
function CommandBlock({
label,
cmd,
copyLabel,
copiedLabel,
}: {
label: string;
cmd: string;
copyLabel: string;
copiedLabel: string;
}) {
const [copied, setCopied] = useState(false);
const onCopy = async () => {
try {
await navigator.clipboard.writeText(cmd);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
// clipboard may be unavailable (insecure context) — silent no-op
}
};
return (
<div>
<p className="mb-2 text-[12px] font-medium uppercase tracking-[0.08em] text-[#0a0d12]/55">
{label}
</p>
<div className="flex items-start gap-3 rounded-xl border border-[#0a0d12]/10 bg-white px-4 py-3 font-mono text-[13.5px]">
<Terminal
className="mt-0.5 size-4 shrink-0 text-[#0a0d12]/55"
aria-hidden
/>
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
{cmd}
</code>
<button
type="button"
onClick={onCopy}
aria-label={copied ? copiedLabel : copyLabel}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium text-[#0a0d12]/70 transition-colors hover:bg-[#0a0d12]/5 hover:text-[#0a0d12]"
>
{copied ? (
<>
<Check className="size-3.5" />
{copiedLabel}
</>
) : (
<>
<Copy className="size-3.5" />
{copyLabel}
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
"use client";
import { useState } from "react";
import { CloudWaitlistExpand } from "@multica/views/onboarding";
import { useLocale } from "../../i18n";
/**
* Cloud runtime waitlist — thin wrapper around the shared
* CloudWaitlistExpand form with a download-page-appropriate title
* and subtitle. Submission persists via `joinCloudWaitlist` inside
* the child; the submitted flag here only prevents double-submits
* for the lifetime of the page.
*/
export function CloudSection() {
const { t } = useLocale();
const d = t.download.cloud;
const [submitted, setSubmitted] = useState(false);
return (
<section className="bg-white py-20 text-[#0a0d12] sm:py-24">
<div className="mx-auto max-w-[720px] px-4 sm:px-6 lg:px-8">
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
{d.title}
</h2>
<p className="mt-4 max-w-[560px] text-[15px] leading-7 text-[#0a0d12]/72">
{d.sub}
</p>
<div className="mt-10">
<CloudWaitlistExpand
submitted={submitted}
onSubmitted={() => setSubmitted(true)}
/>
</div>
</div>
</section>
);
}

View File

@@ -1,285 +0,0 @@
import Link from "next/link";
import { ArrowRight, Download } from "lucide-react";
import {
captureDownloadInitiated,
type DownloadInitiatedPayload,
} from "@multica/core/analytics";
import { useLocale } from "../../i18n";
import type { DetectResult } from "../../utils/os-detect";
import type { DownloadAssets } from "../../utils/parse-release-assets";
import { heroButtonClassName } from "../shared";
interface Props {
detected: DetectResult | null;
assets: DownloadAssets;
/** True when the GitHub API fetch failed; disables all CTAs and
* surfaces a "version unavailable" line. */
versionUnavailable: boolean;
/** Release tag (e.g. "v0.2.13"). Null when version lookup failed —
* in that case CTAs are already disabled, no tracking fires. */
version: string | null;
}
/**
* Top CTA section. Server-renders a generic "Choose your platform"
* placeholder (SEO + flash-before-hydration), then swaps to a
* platform-specific CTA once the client detection resolves.
*/
export function DownloadHero({
detected,
assets,
versionUnavailable,
version,
}: Props) {
const { t } = useLocale();
const d = t.download.hero;
const content = resolveContent(detected, assets, versionUnavailable, d);
// Fires download_initiated on primary CTA click. `primary_cta: true`
// identifies the hero-recommended path; `matched_detect: true` is
// always true here by construction (the primary is computed from
// the detect result). All Platforms rows below emit with
// matched_detect=false when the user overrides.
const onPrimaryClick = (tracking: HeroTracking | undefined) => {
if (!tracking || !version) return;
captureDownloadInitiated({
...tracking,
version,
primary_cta: true,
matched_detect: true,
});
};
return (
<section className="relative overflow-hidden bg-[#05070b] text-white">
<BackdropGradient />
<div className="relative z-10 mx-auto max-w-[1120px] px-4 pb-24 pt-32 text-center sm:px-6 sm:pt-40 lg:px-8 lg:pb-28">
<h1 className="mx-auto max-w-[880px] font-[family-name:var(--font-serif)] text-[3rem] leading-[1.02] tracking-[-0.035em] drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4rem] lg:text-[5rem]">
{content.title}
</h1>
<p className="mx-auto mt-6 max-w-[620px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
{content.sub}
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
{content.primary ? (
<PrimaryCta
href={content.primary.href}
disabled={content.primary.disabled}
onClick={() => onPrimaryClick(content.primary?.tracking)}
>
<Download className="size-4" aria-hidden />
{content.primary.label}
{!content.primary.disabled && (
<ArrowRight className="size-4" aria-hidden />
)}
</PrimaryCta>
) : null}
{content.alt ? (
<Link
href={content.alt.href}
className={heroButtonClassName("ghost")}
onClick={() => onPrimaryClick(content.alt?.tracking)}
>
{content.alt.label}
</Link>
) : null}
</div>
{content.hint ? (
<p className="mx-auto mt-5 max-w-[520px] text-[13px] text-white/64">
{content.hint}
</p>
) : null}
{versionUnavailable ? (
<p className="mx-auto mt-6 max-w-[520px] text-[12px] uppercase tracking-[0.14em] text-white/50">
{t.download.footer.versionUnavailable}
</p>
) : null}
</div>
</section>
);
}
// ------------------------------------------------------------
// Content resolver — maps (detect, assets) → CTA props
// ------------------------------------------------------------
type HeroTracking = Pick<
DownloadInitiatedPayload,
"platform" | "arch" | "format"
>;
interface HeroContent {
title: string;
sub: string;
primary?: {
href: string;
label: string;
disabled: boolean;
tracking?: HeroTracking;
};
alt?: { href: string; label: string; tracking?: HeroTracking };
hint?: string;
}
type HeroDict = ReturnType<typeof useLocale>["t"]["download"]["hero"];
function resolveContent(
detected: DetectResult | null,
assets: DownloadAssets,
versionUnavailable: boolean,
d: HeroDict,
): HeroContent {
// Before hydration resolves, render a neutral prompt. Same copy
// also catches `os === "unknown"`.
if (!detected || detected.os === "unknown") {
return { title: d.unknown.title, sub: d.unknown.sub };
}
if (detected.os === "mac") {
// Only Chromium high-entropy returns arch confidently. Safari
// always reports Intel even on Apple Silicon, so we treat
// "non-confident" as arm64 + add a small Intel disclaimer.
if (detected.arch === "x64" && detected.archConfident) {
return {
title: d.macIntel.title,
sub: d.macIntel.sub,
primary: {
href: "#cli",
label: d.macIntel.disabledCta,
disabled: true,
},
hint: d.macIntel.intelHint,
};
}
const dmg = assets.macArm64Dmg;
const zip = assets.macArm64Zip;
return {
title: d.macArm64.title,
sub: d.macArm64.sub,
primary: dmg
? {
href: dmg,
label: d.macArm64.primary,
disabled: false,
tracking: { platform: "mac", arch: "arm64", format: "dmg" },
}
: versionUnavailable
? { href: "#", label: d.macArm64.primary, disabled: true }
: undefined,
alt: zip
? {
href: zip,
label: d.macArm64.altZip,
tracking: { platform: "mac", arch: "arm64", format: "zip" },
}
: undefined,
hint: detected.archConfident ? undefined : d.safariMacHint,
};
}
if (detected.os === "windows") {
// Trust arch whenever the UA hints at it (even non-confident);
// Windows-on-ARM can still run x64 via emulation so this is low
// risk either way. Surface the arch-fallback hint when we're
// guessing so users on uncommon setups know to scroll down.
const isArm = detected.arch === "arm64";
const copy = isArm ? d.winArm64 : d.winX64;
const url = isArm ? assets.winArm64Exe : assets.winX64Exe;
return {
title: copy.title,
sub: copy.sub,
primary: url
? {
href: url,
label: copy.primary,
disabled: false,
tracking: {
platform: "windows",
arch: isArm ? "arm64" : "x64",
format: "exe",
},
}
: versionUnavailable
? { href: "#", label: copy.primary, disabled: true }
: undefined,
hint: detected.archConfident ? undefined : d.archFallbackHint,
};
}
// Linux — same principle: trust the arm64 signal, surface a hint
// when we're not confident. Linux ARM has no binary emulation so
// the hint matters more here than on Windows.
const isArmLinux = detected.arch === "arm64";
const primaryUrl = isArmLinux
? assets.linuxArm64AppImage
: assets.linuxAmd64AppImage;
return {
title: d.linux.title,
sub: d.linux.sub,
primary: primaryUrl
? {
href: primaryUrl,
label: d.linux.primary,
disabled: false,
tracking: {
platform: "linux",
arch: isArmLinux ? "arm64" : "x64",
format: "appimage",
},
}
: versionUnavailable
? { href: "#", label: d.linux.primary, disabled: true }
: undefined,
alt: { href: "#all-platforms", label: d.linux.altFormats },
hint: detected.archConfident ? undefined : d.archFallbackHint,
};
}
// ------------------------------------------------------------
// Pieces
// ------------------------------------------------------------
function PrimaryCta({
href,
disabled,
onClick,
children,
}: {
href: string;
disabled: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
if (disabled) {
return (
<span
aria-disabled="true"
className="inline-flex cursor-not-allowed items-center justify-center gap-2 rounded-[12px] border border-white/15 bg-white/8 px-5 py-3 text-[14px] font-semibold text-white/60"
>
{children}
</span>
);
}
return (
<a href={href} onClick={onClick} className={heroButtonClassName("solid")}>
{children}
</a>
);
}
function BackdropGradient() {
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 70% 50% at 50% 0%, rgba(80,120,255,0.18), transparent 60%), radial-gradient(ellipse 50% 40% at 50% 80%, rgba(255,90,90,0.08), transparent 60%)",
}}
/>
);
}

View File

@@ -1,54 +0,0 @@
/**
* Inline SVG marks for macOS / Windows / Linux.
* Lucide lacks real Apple / Tux marks, and the download page needs
* the recognizable brand glyphs next to platform rows. Kept as
* minimal monochrome outlines so they inherit currentColor.
*/
type IconProps = React.SVGProps<SVGSVGElement> & { size?: number };
export function AppleIcon({ size = 18, ...props }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
aria-hidden
{...props}
>
<path d="M16.37 12.8c.02-1.9 1.56-2.83 1.63-2.87-.89-1.3-2.28-1.48-2.77-1.5-1.18-.12-2.3.69-2.9.69-.6 0-1.52-.68-2.5-.66-1.28.02-2.47.74-3.13 1.88-1.33 2.3-.34 5.7.96 7.57.63.92 1.38 1.94 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.14 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.1 1.04-2.15-.02-.01-2-.77-2.02-3.05-.02-1.9 1.55-2.81 1.63-2.87zm-2.05-5.24c.52-.63.88-1.52.78-2.4-.75.03-1.66.5-2.2 1.12-.48.55-.9 1.44-.79 2.32.84.06 1.69-.42 2.21-1.04z" />
</svg>
);
}
export function WindowsIcon({ size = 18, ...props }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
aria-hidden
{...props}
>
<path d="M3 5.5 10.5 4.5v6.75H3V5.5Zm0 7.25h7.5v6.75L3 18.5v-5.75Zm8.75-8.4L21 3v9H11.75V4.35ZM11.75 12h9.25v9L11.75 19.65V12Z" />
</svg>
);
}
export function LinuxIcon({ size = 18, ...props }: IconProps) {
// Simplified Tux silhouette — round head + body.
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
aria-hidden
{...props}
>
<path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.3 2.3.8 3.2-.7.7-1.3 1.8-1.6 3-.4 1.4-.7 3.3-1.8 4.4-.6.6-1 .9-1 1.6 0 .9.8 1.3 2 1.6 1.5.3 2.6.1 3.6-.3.6-.2 1.3-.4 2-.4s1.4.2 2 .4c1 .4 2.1.6 3.6.3 1.2-.3 2-.7 2-1.6 0-.7-.4-1-1-1.6-1.1-1.1-1.4-3-1.8-4.4-.3-1.2-.9-2.3-1.6-3 .5-.9.8-2 .8-3.2 0-2.7-1.6-4.6-4-4.6Zm-1.5 5.2c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm3 0c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm-3 2.6c.7.5 1.5.8 1.5.8s.8-.3 1.5-.8c0 .6-.7 1-1.5 1s-1.5-.4-1.5-1Z" />
</svg>
);
}

View File

@@ -4,7 +4,6 @@ import Link from "next/link";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { cn } from "@multica/ui/lib/utils";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
import { useLocale, locales, localeLabels } from "../i18n";
@@ -72,11 +71,6 @@ export function LandingFooter() {
{...(link.href.startsWith("http")
? { target: "_blank", rel: "noreferrer" }
: {})}
onClick={
link.href === "/download"
? () => captureDownloadIntent("landing_footer")
: undefined
}
className="text-[14px] text-white/50 transition-colors hover:text-white"
>
{link.label}

View File

@@ -2,9 +2,7 @@
import Image from "next/image";
import Link from "next/link";
import { Download } from "lucide-react";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
@@ -44,11 +42,25 @@ export function LandingHero() {
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href="/download"
href="https://github.com/multica-ai/multica/releases/latest"
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
onClick={() => captureDownloadIntent("landing_hero")}
>
<Download className="size-4" aria-hidden />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
{t.hero.downloadDesktop}
</Link>
</div>

View File

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

View File

@@ -1,8 +1,9 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createEnDict(allowSignup: boolean): LandingDict {
return {
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const en: LandingDict = {
header: {
github: "GitHub",
login: "Log in",
@@ -121,8 +122,8 @@ export function createEnDict(allowSignup: boolean): LandingDict {
headlineFaded: "in the next hour.",
steps: [
{
title: allowSignup ? "Sign up & create your workspace" : "Login to your workspace",
description: allowSignup
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
description: ALLOW_SIGNUP
? "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.",
},
@@ -226,7 +227,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{ label: "Features", href: "#features" },
{ label: "How it Works", href: "#how-it-works" },
{ label: "Changelog", href: "/changelog" },
{ label: "Download", href: "/download" },
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
],
},
resources: {
@@ -275,38 +276,12 @@ export function createEnDict(allowSignup: boolean): 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",
@@ -751,80 +726,4 @@ export function createEnDict(allowSignup: boolean): LandingDict {
},
],
},
download: {
hero: {
macArm64: {
title: "Multica for macOS",
sub: "Apple Silicon · bundled daemon, zero setup",
primary: "Download (.dmg)",
altZip: "or download .zip",
},
macIntel: {
title: "Multica for macOS",
sub: "Apple Silicon required — Intel Macs not yet supported.",
disabledCta: "Apple Silicon required",
intelHint:
"On an Intel Mac? Use the CLI below — it runs the same daemon.",
},
winX64: {
title: "Multica for Windows",
sub: "Bundled daemon, zero setup",
primary: "Download (.exe)",
},
winArm64: {
title: "Multica for Windows",
sub: "ARM · bundled daemon, zero setup",
primary: "Download (.exe)",
},
linux: {
title: "Multica for Linux",
sub: "Bundled daemon, zero setup",
primary: "Download AppImage",
altFormats: "or .deb / .rpm",
},
unknown: {
title: "Choose your platform",
sub: "All installers are listed below.",
},
safariMacHint: "On an Intel Mac? Use the CLI below.",
archFallbackHint: "Wrong architecture? See all formats below.",
},
allPlatforms: {
title: "All platforms",
macLabel: "macOS · Apple Silicon",
winX64Label: "Windows · x64",
winArm64Label: "Windows · ARM64",
linuxX64Label: "Linux · x64",
linuxArm64Label: "Linux · ARM64",
formatDmg: ".dmg",
formatZip: ".zip",
formatExe: ".exe",
formatAppImage: ".AppImage",
formatDeb: ".deb",
formatRpm: ".rpm",
intelNote:
"Apple Silicon only — Intel Macs not supported in this release.",
unavailable: "Not available",
},
cli: {
title: "Prefer the CLI?",
sub: "For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal.",
installLabel: "Install",
startLabel: "Start daemon",
sshNote: "Already on a server? Same commands work over SSH.",
copyLabel: "Copy",
copiedLabel: "Copied",
},
cloud: {
title: "Cloud runtime (waitlist)",
sub: "Well host the runtime for you. Not live yet — leave your email to be notified.",
},
footer: {
releaseNotes: "Whats new in {version}",
allReleases: "View all releases",
currentVersion: "Current version: {version}",
versionUnavailable: "Version unavailable — check GitHub",
},
},
};
}
};

View File

@@ -86,7 +86,6 @@ export type LandingDict = {
changelog: {
title: string;
subtitle: string;
toc: string;
categories: {
features: string;
improvements: string;
@@ -102,63 +101,4 @@ export type LandingDict = {
fixes?: string[];
}[];
};
download: {
hero: {
macArm64: {
title: string;
sub: string;
primary: string;
altZip: string;
};
macIntel: {
title: string;
sub: string;
disabledCta: string;
intelHint: string;
};
winX64: { title: string; sub: string; primary: string };
winArm64: { title: string; sub: string; primary: string };
linux: {
title: string;
sub: string;
primary: string;
altFormats: string;
};
unknown: { title: string; sub: string };
safariMacHint: string;
archFallbackHint: string;
};
allPlatforms: {
title: string;
macLabel: string;
winX64Label: string;
winArm64Label: string;
linuxX64Label: string;
linuxArm64Label: string;
formatDmg: string;
formatZip: string;
formatExe: string;
formatAppImage: string;
formatDeb: string;
formatRpm: string;
intelNote: string;
unavailable: string;
};
cli: {
title: string;
sub: string;
installLabel: string;
startLabel: string;
sshNote: string;
copyLabel: string;
copiedLabel: string;
};
cloud: { title: string; sub: string };
footer: {
releaseNotes: string;
allReleases: string;
currentVersion: string;
versionUnavailable: string;
};
};
};

View File

@@ -1,8 +1,9 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export function createZhDict(allowSignup: boolean): LandingDict {
return {
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const zh: LandingDict = {
header: {
github: "GitHub",
login: "\u767b\u5f55",
@@ -121,8 +122,8 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: allowSignup
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: ALLOW_SIGNUP
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
},
@@ -226,7 +227,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
{ label: "更新日志", href: "/changelog" },
{ label: "下载", href: "/download" },
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
],
},
resources: {
@@ -275,38 +276,12 @@ export function createZhDict(allowSignup: boolean): 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",
@@ -751,78 +726,4 @@ export function createZhDict(allowSignup: boolean): LandingDict {
},
],
},
download: {
hero: {
macArm64: {
title: "Multica for macOS",
sub: "Apple Silicon · 内置 daemon无需配置",
primary: "下载 (.dmg)",
altZip: "或下载 .zip",
},
macIntel: {
title: "Multica for macOS",
sub: "需要 Apple Silicon——暂不支持 Intel Mac。",
disabledCta: "需要 Apple Silicon",
intelHint: "在 Intel Mac 上?请使用下方 CLI——底层跑的是同一个 daemon。",
},
winX64: {
title: "Multica for Windows",
sub: "内置 daemon无需配置",
primary: "下载 (.exe)",
},
winArm64: {
title: "Multica for Windows",
sub: "ARM · 内置 daemon无需配置",
primary: "下载 (.exe)",
},
linux: {
title: "Multica for Linux",
sub: "内置 daemon无需配置",
primary: "下载 AppImage",
altFormats: "或 .deb / .rpm",
},
unknown: {
title: "选择你的平台",
sub: "下方是所有支持的安装包。",
},
safariMacHint: "在 Intel Mac 上?请使用下方 CLI。",
archFallbackHint: "架构不对?下方是所有可选格式。",
},
allPlatforms: {
title: "所有平台",
macLabel: "macOS · Apple Silicon",
winX64Label: "Windows · x64",
winArm64Label: "Windows · ARM64",
linuxX64Label: "Linux · x64",
linuxArm64Label: "Linux · ARM64",
formatDmg: ".dmg",
formatZip: ".zip",
formatExe: ".exe",
formatAppImage: ".AppImage",
formatDeb: ".deb",
formatRpm: ".rpm",
intelNote: "仅支持 Apple Silicon——Intel Mac 目前暂不支持。",
unavailable: "暂不可用",
},
cli: {
title: "想用 CLI",
sub: "适合服务器、远程开发机、无图形界面环境。底层 daemon 与 Desktop 相同,通过终端安装。",
installLabel: "安装",
startLabel: "启动 daemon",
sshNote: "已经在服务器上?通过 SSH 执行同样的命令即可。",
copyLabel: "复制",
copiedLabel: "已复制",
},
cloud: {
title: "Cloud runtime等待名单",
sub: "我们将为你托管 runtime目前尚未上线——留下邮箱上线后通知你。",
},
footer: {
releaseNotes: "v{version} 更新内容",
allReleases: "查看所有版本",
currentVersion: "当前版本:{version}",
versionUnavailable: "版本获取失败——请前往 GitHub 查看",
},
},
};
}
};

View File

@@ -1,149 +0,0 @@
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({});
});
});

View File

@@ -1,114 +0,0 @@
import {
parseReleaseAssets,
type DownloadAssets,
} from "./parse-release-assets";
/**
* Server-side fetcher for the latest Multica release, designed to
* run inside a Next.js server component. Response is cached by the
* Next.js fetch cache for 5 minutes (Vercel ISR) so hitting /download
* costs at most one GitHub API call per region per 5 minutes.
*
* 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: {},
};
}

View File

@@ -1,97 +0,0 @@
/**
* Client-side OS + architecture detection for the /download page.
*
* Prefers the modern `navigator.userAgentData.getHighEntropyValues`
* API (Chromium), falling back to the UA string.
*
* Known limitation: Safari on macOS always reports `Intel Mac OS X`
* in the UA string even on Apple Silicon, and Safari does not
* implement userAgentData. This function therefore returns `arm64`
* as the best default for any Mac — UI surfaces a small "On Intel
* Mac? Use CLI." hint to cover the Intel minority.
*/
export type OSName = "mac" | "windows" | "linux" | "unknown";
export type Arch = "arm64" | "x64" | "unknown";
export interface DetectResult {
os: OSName;
arch: Arch;
/** True when arch came from userAgentData high-entropy values
* (i.e. we can trust the Intel vs arm distinction). False when
* we defaulted — UI should show the Intel Mac disclaimer. */
archConfident: boolean;
}
interface UADataRecord {
platform: string;
architecture: string;
}
interface UserAgentDataLike {
getHighEntropyValues?: (hints: string[]) => Promise<UADataRecord>;
}
function normalizePlatform(raw: string): OSName {
const p = raw.toLowerCase();
if (p.includes("mac") || p === "darwin") return "mac";
if (p.includes("win")) return "windows";
if (p.includes("linux")) return "linux";
return "unknown";
}
function normalizeArch(raw: string): Arch {
const a = raw.toLowerCase();
if (a === "arm" || a === "arm64" || a === "aarch64") return "arm64";
if (a === "x86" || a === "x86_64" || a === "amd64" || a === "x64") return "x64";
return "unknown";
}
export async function detectOS(): Promise<DetectResult> {
if (typeof navigator === "undefined") {
return { os: "unknown", arch: "unknown", archConfident: false };
}
// Modern Chromium: userAgentData with high-entropy values gives
// both the platform name and CPU architecture unambiguously.
const uaData = (navigator as unknown as { userAgentData?: UserAgentDataLike })
.userAgentData;
if (uaData?.getHighEntropyValues) {
try {
const data = await uaData.getHighEntropyValues([
"platform",
"architecture",
]);
const os = normalizePlatform(data.platform);
const arch = normalizeArch(data.architecture);
return { os, arch, archConfident: arch !== "unknown" };
} catch {
// Some browsers expose the API but reject high-entropy requests.
}
}
// Fallback: UA + navigator.platform. Safari on Mac lands here and
// cannot distinguish Apple Silicon from Intel.
const ua = navigator.userAgent;
const platform = navigator.platform || "";
const os: OSName = /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X/i.test(ua)
? "mac"
: /Win/i.test(platform) || /Windows/i.test(ua)
? "windows"
: /Linux/i.test(platform) || /Linux/i.test(ua)
? "linux"
: "unknown";
let arch: Arch = "unknown";
if (os === "mac") {
// Best default. Real Intel Mac users will see the disclaimer.
arch = "arm64";
} else if (/arm|aarch/i.test(ua)) {
arch = "arm64";
} else if (os !== "unknown") {
arch = "x64";
}
return { os, arch, archConfident: false };
}

View File

@@ -1,94 +0,0 @@
/**
* Parses the GitHub Releases API asset array into a structured
* download asset map. Skips auxiliary files (blockmaps, update
* manifests, checksums) and the CLI tarballs — only desktop
* installer artifacts are relevant on the /download page.
*
* Desktop artifact naming (see apps/desktop/electron-builder.yml):
* multica-desktop-{version}-mac-{arch}.{dmg|zip}
* multica-desktop-{version}-windows-{arch}.exe
* multica-desktop-{version}-linux-{arch}.{AppImage|deb|rpm}
*
* Linux arch appears as amd64 / x86_64 / arm64 / aarch64 depending
* on the format; we normalize to amd64 and arm64.
*/
export interface GitHubAsset {
name: string;
browser_download_url: string;
}
export interface DownloadAssets {
macArm64Dmg?: string;
macArm64Zip?: string;
winX64Exe?: string;
winArm64Exe?: string;
linuxAmd64AppImage?: string;
linuxAmd64Deb?: string;
linuxAmd64Rpm?: string;
linuxArm64AppImage?: string;
linuxArm64Deb?: string;
linuxArm64Rpm?: string;
}
const DESKTOP_ARTIFACT_RE =
/^multica-desktop-[^-]+-(mac|windows|linux)-([a-z0-9_]+)\.(dmg|zip|exe|AppImage|deb|rpm)$/i;
function normalizeLinuxArch(arch: string): "amd64" | "arm64" | null {
const a = arch.toLowerCase();
if (a === "amd64" || a === "x86_64") return "amd64";
if (a === "arm64" || a === "aarch64") return "arm64";
return null;
}
export function parseReleaseAssets(raw: GitHubAsset[]): DownloadAssets {
const out: DownloadAssets = {};
for (const asset of raw) {
const name = asset.name;
// Skip auxiliary files that share the release (update manifests,
// blockmaps, checksums). CLI tarballs and other non-desktop
// artifacts are excluded automatically because they don't match
// DESKTOP_ARTIFACT_RE below.
if (name.endsWith(".blockmap") || name.endsWith(".yml")) continue;
if (name.startsWith("checksums")) continue;
const match = DESKTOP_ARTIFACT_RE.exec(name);
if (!match) continue;
const platform = match[1];
const arch = match[2];
const ext = match[3];
if (!platform || !arch || !ext) continue;
const archLower = arch.toLowerCase();
const extLower = ext.toLowerCase();
const url = asset.browser_download_url;
if (platform === "mac") {
if (archLower !== "arm64") continue; // we only ship arm64 today
if (extLower === "dmg") out.macArm64Dmg = url;
else if (extLower === "zip") out.macArm64Zip = url;
} else if (platform === "windows") {
if (extLower !== "exe") continue;
if (archLower === "x64") out.winX64Exe = url;
else if (archLower === "arm64") out.winArm64Exe = url;
} else if (platform === "linux") {
const normalized = normalizeLinuxArch(arch);
if (!normalized) continue;
const e = extLower;
if (normalized === "amd64") {
if (e === "appimage") out.linuxAmd64AppImage = url;
else if (e === "deb") out.linuxAmd64Deb = url;
else if (e === "rpm") out.linuxAmd64Rpm = url;
} else {
if (e === "appimage") out.linuxArm64AppImage = url;
else if (e === "deb") out.linuxArm64Deb = url;
else if (e === "rpm") out.linuxArm64Rpm = url;
}
}
}
return out;
}
/** Whether any desktop asset was parsed out. Used for UI degradation. */
export function hasAnyAsset(assets: DownloadAssets): boolean {
return Object.values(assets).some((v) => typeof v === "string");
}

View File

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

View File

@@ -1,19 +0,0 @@
# Development override: build the backend/web images from the current checkout
# instead of pulling the official GHCR images.
services:
backend:
image: multica-backend:dev
build:
context: .
dockerfile: Dockerfile
frontend:
image: multica-web:dev
build:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
NEXT_PUBLIC_APP_VERSION: dev

View File

@@ -29,7 +29,9 @@ services:
retries: 5
backend:
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
@@ -59,7 +61,13 @@ services:
restart: unless-stopped
frontend:
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
build:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
depends_on:
- backend
ports:

View File

@@ -74,13 +74,6 @@ handler → analytics.Client.Capture(Event) ← non-blocking, returns immediat
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
@@ -188,121 +181,6 @@ accepted and the member row is inserted in the same transaction.
`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 `![...](url)` 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
@@ -310,67 +188,6 @@ request payload.
mounts once under `WebProviders` and drives the acquisition funnel's
`/ → signup` step. posthog-js's automatic pageview capture is
disabled in `initAnalytics` so we own the event shape.
- `onboarding_runtime_path_selected` — fired from
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
user clicks one of the three Step 3 fork cards (before any server
call happens, so it's frontend-only). Properties: `path`
(`download_desktop` / `cli` / `cloud_waitlist`), `source` (`step3`;
literal today but reserved for future surfaces reusing this event),
`is_mac`. Also writes `platform_preference` (`web` / `desktop`) to
person properties so every subsequent event on the user can be
broken down by chosen platform. **Note**: semantic "download
intent" is now better served by `download_intent_expressed` below —
`path: "download_desktop"` signals Step 3 path choice specifically,
not actual download start.
- `download_intent_expressed` — fired whenever a user clicks a CTA
that points at the `/download` page. Surfaces five sources across
the funnel, letting the top-of-funnel entry be split cleanly.
Wrapper lives in `packages/core/analytics/download.ts`
(`captureDownloadIntent`). Properties:
- `source`: `landing_hero` / `landing_footer` / `login` / `welcome`
/ `step3`
Also writes `platform_preference: "desktop"` to person properties.
- `download_page_viewed` — fired once per `/download` mount after OS
detect resolves (`apps/web/app/(landing)/download/download-client.tsx`).
Properties:
- `detected_os`: `mac` / `windows` / `linux` / `unknown`
- `detected_arch`: `arm64` / `x64` / `unknown`
- `detect_confident`: `true` when detect used
`userAgentData.getHighEntropyValues` (Chromium); `false` when it
fell back to the UA string (Safari on Mac always lands here —
lets us isolate the arm64-default-for-Intel risk cohort).
- `version_available`: `false` when the GitHub API fetch failed
and the page is in the "Version unavailable" degraded state.
Also writes `first_detected_os` / `first_detected_arch` via
`$set_once` so every downstream event gains a platform dimension
without re-emitting.
- `download_initiated` — fired when the user clicks a specific
installer link on `/download`. Both the hero CTA and the All
Platforms matrix rows emit this; split by `primary_cta`.
Properties:
- `platform`: `mac` / `windows` / `linux`
- `arch`: `arm64` / `x64`
- `format`: `dmg` / `zip` / `exe` / `appimage` / `deb` / `rpm`
- `version`: release tag (e.g. `v0.2.13`) — correlates adoption
with release cadence.
- `primary_cta`: `true` for the hero-recommended installer, `false`
for a manual pick from the All Platforms matrix.
- `matched_detect`: `true` when the chosen platform+arch matches
what the page detected. `false` lets us quantify detect misses
from the single event (no cross-join needed).
- `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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,227 @@
# 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 `![](url)` 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 `![](url)`)
- 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 ![](url))
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` |

View File

@@ -0,0 +1,452 @@
# 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 `![alt](url)` — 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 |

View File

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

View File

@@ -0,0 +1,868 @@
# 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/WSapp 不需要写任何 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 行 |

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
# 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` 协议 — 只存 UUIDworkspace-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-723workspace 不在 URL | 核心目标 |
| MUL-727创建 workspace 闪 /issues | 删除 mutation 里的 switchWorkspace 副作用 |
| MUL-728删除 workspace 后留在 /settings | 删除成功后 navigate 到下一个 workspace |
| MUL-820sidebar Join 不切 workspace | Join 改成跳转到 `/invite/{id}` 走统一路径 |
不在本次范围内的Issue #951WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
---
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。

View File

@@ -6,7 +6,6 @@
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",

View File

@@ -1,113 +0,0 @@
/**
* Download funnel instrumentation.
*
* Complements the onboarding events added in PR #1489 by covering
* every surface that advertises the desktop app — landing hero,
* landing footer, login, Welcome (web branch), Step 3 — and the
* /download page itself. Without this layer we can see Step 3
* path selection but not the touchpoint that got the user there,
* nor the /download → installer conversion.
*
* Event names and property shapes are governed by docs/analytics.md;
* keep the two in sync when adding a new source or field.
*/
import posthog from "posthog-js";
import { captureEvent, setPersonProperties } from "./index";
/**
* Where the user clicked a CTA that points at `/download`. Typed union
* prevents drift across the five touchpoints and lets PostHog funnels
* split cleanly by top-of-funnel entry.
*/
export type DownloadIntentSource =
| "landing_hero"
| "landing_footer"
| "login"
| "welcome"
| "step3";
/**
* OS + arch detect result for the /download page. Mirrors the shape of
* `@/features/landing/utils/os-detect.ts` without importing it (that
* module lives in the web app; core packages can't depend on it). Keep
* these enums in lockstep.
*/
export interface DownloadDetectPayload {
detected_os: "mac" | "windows" | "linux" | "unknown";
detected_arch: "arm64" | "x64" | "unknown";
detect_confident: boolean;
version_available: boolean;
}
/**
* Specific installer the user chose on /download. Version is the GitHub
* tag name (e.g. "v0.2.13") so we can correlate adoption-by-release.
*/
export interface DownloadInitiatedPayload {
platform: "mac" | "windows" | "linux";
arch: "arm64" | "x64";
format: "dmg" | "zip" | "exe" | "appimage" | "deb" | "rpm";
version: string;
primary_cta: boolean;
matched_detect: boolean;
}
/**
* Fires when a user clicks any CTA that navigates to `/download`. We
* also write `platform_preference` to person properties so the backend
* can segment subsequent events — same convention the Step 3 handler
* already uses (see `step-platform-fork.tsx`).
*/
export function captureDownloadIntent(source: DownloadIntentSource): void {
captureEvent("download_intent_expressed", {
source,
});
setPersonProperties({ platform_preference: "desktop" });
}
/**
* Fires once on /download page mount, after OS detection resolves. The
* first detection for a given person is mirrored into person properties
* via `$set_once` so every downstream event gains a platform dimension
* without re-emitting.
*/
export function captureDownloadPageViewed(
payload: DownloadDetectPayload,
): void {
captureEvent("download_page_viewed", {
detected_os: payload.detected_os,
detected_arch: payload.detected_arch,
detect_confident: payload.detect_confident,
version_available: payload.version_available,
});
setPersonPropertiesOnce({
first_detected_os: payload.detected_os,
first_detected_arch: payload.detected_arch,
});
}
/**
* Fires when the user clicks a concrete installer link on `/download`.
* `primary_cta` marks the hero-level recommendation versus a manual
* pick from the All Platforms matrix; `matched_detect` captures
* whether the click matched what we detected (miss = detect got it
* wrong / user overrode).
*/
export function captureDownloadInitiated(
payload: DownloadInitiatedPayload,
): void {
captureEvent("download_initiated", { ...payload });
}
/**
* $set_once wire form. Mirrors the backend's `Event.SetOnce` path —
* first write wins, subsequent ones are no-ops on PostHog's side.
* Wrapping it here keeps call sites free of the no-op `$set_once`
* envelope quirk.
*/
function setPersonPropertiesOnce(props: Record<string, unknown>): void {
if (typeof window === "undefined") return;
posthog.capture("$set", { $set_once: props });
}

View File

@@ -1,34 +0,0 @@
/**
* 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 } : {}),
});
}

View File

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

View File

@@ -39,67 +39,10 @@ let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null
// 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";
}
/**
@@ -135,16 +78,6 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
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.
@@ -157,18 +90,6 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
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;
}
@@ -196,61 +117,8 @@ export function identify(userId: string, userProperties?: Record<string, unknown
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 });
}
/**

View File

@@ -68,7 +68,6 @@ import type {
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
@@ -289,13 +288,8 @@ export class ApiClient {
return this.fetch("/api/me");
}
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
async markOnboardingComplete(): Promise<User> {
return this.fetch("/api/me/onboarding/complete", { method: "POST" });
}
async joinCloudWaitlist(payload: {
@@ -336,12 +330,9 @@ export class ApiClient {
});
}
async dismissStarterContent(payload?: {
workspace_id?: string;
}): Promise<User> {
async dismissStarterContent(): Promise<User> {
return this.fetch("/api/me/starter-content/dismiss", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
}
@@ -395,17 +386,6 @@ export class ApiClient {
});
}
async createFeedback(data: {
message: string;
url?: string;
workspace_id?: string;
}): Promise<{ id: string; created_at: string }> {
return this.fetch("/api/feedback", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
return this.fetch(`/api/issues/${id}`, {
method: "PUT",
@@ -713,8 +693,6 @@ export class ApiClient {
// App Config
async getConfig(): Promise<{
cdn_domain: string;
allow_signup: boolean;
google_client_id?: string;
posthog_key?: string;
posthog_host?: string;
}> {

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export * from "./mutations";

View File

@@ -1,14 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { api } from "../api";
export interface CreateFeedbackInput {
message: string;
url?: string;
workspace_id?: string;
}
export function useCreateFeedback() {
return useMutation({
mutationFn: (input: CreateFeedbackInput) => api.createFeedback(input),
});
}

View File

@@ -2,7 +2,7 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | "create-project" | "feedback" | null;
type ModalType = "create-workspace" | "create-issue" | "create-project" | null;
interface ModalStore {
modal: ModalType;

View File

@@ -1,6 +1,5 @@
export type {
OnboardingStep,
OnboardingCompletionPath,
QuestionnaireAnswers,
TeamSize,
Role,

View File

@@ -1,7 +1,6 @@
import { api } from "../api";
import { useAuthStore } from "../auth";
import { setPersonProperties } from "../analytics";
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
import type { QuestionnaireAnswers } from "./types";
/**
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
@@ -18,34 +17,15 @@ export async function saveQuestionnaire(
): Promise<void> {
const user = await api.patchOnboarding({ questionnaire: answers });
useAuthStore.getState().setUser(user);
// Mirror the three cohort signals into person properties so every
// PostHog event on this user can be broken down by role / use_case /
// team_size without re-joining the DB. Matches the $set block the
// server writes alongside `onboarding_questionnaire_submitted`.
if (answers.team_size || answers.role || answers.use_case) {
setPersonProperties({
...(answers.team_size ? { team_size: answers.team_size } : {}),
...(answers.role ? { role: answers.role } : {}),
...(answers.use_case ? { use_case: answers.use_case } : {}),
});
}
}
/**
* Finalize onboarding. POST /complete marks `onboarded_at` atomically
* (COALESCE-guarded for idempotency). We then refresh the auth store
* so every gate sees the updated user.
*
* `completionPath` is the client's view of which Step-3 exit the user
* took; the server funnel-splits `onboarding_completed` on this value.
* Legacy callers that don't pass a path get recorded as `unknown`.
*/
export async function completeOnboarding(
completionPath?: OnboardingCompletionPath,
): Promise<void> {
await api.markOnboardingComplete(
completionPath ? { completion_path: completionPath } : undefined,
);
export async function completeOnboarding(): Promise<void> {
await api.markOnboardingComplete();
await useAuthStore.getState().refreshMe();
}

View File

@@ -6,18 +6,6 @@ export type OnboardingStep =
| "agent"
| "first_issue";
/**
* Exit path from the onboarding flow. Sent to
* POST /api/me/onboarding/complete and mirrored on the PostHog
* `onboarding_completed` event. Must stay in sync with the
* `OnboardingPath*` constants in `server/internal/analytics/events.go`.
*/
export type OnboardingCompletionPath =
| "full" // Reached Step 5 (first_issue) with a runtime connected
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
| "skip_existing"; // "I've done this before" from Welcome
export type TeamSize = "solo" | "team" | "other";
export type Role =

View File

@@ -52,8 +52,6 @@
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",

View File

@@ -31,9 +31,6 @@ export const RESERVED_SLUGS = new Set([
"multica", // brand name — prevent impersonation workspaces
"www", // hostname confusable; never a legitimate workspace slug
"new", // ambiguous verb-as-slug; reserved for future global create routes
"home", // likely-future marketing/landing entry
"homepage", // existing /homepage landing variant in apps/web
"dashboard", // standard SaaS entry; likely-future global route
"help",
"about",
"pricing",
@@ -51,14 +48,6 @@ export const RESERVED_SLUGS = new Set([
"press",
"download",
// Account / billing (likely-future global routes in the avatar menu).
"profile",
"account",
"billing",
"notifications",
"search",
"members",
// Dashboard / workspace route segments. Reserving the segment name
// prevents `/{slug}/{view}` from being visually ambiguous (e.g. a
// workspace named "issues" makes `/issues/abc` mean two things).
@@ -74,25 +63,6 @@ export const RESERVED_SLUGS = new Set([
"workspaces", // global `/workspaces/new` workspace creation page
"teams", // reserved for future team management routes
// API / integration prefixes. `api` above already covers /api/*; these
// guard against future top-level API alias routes (e.g. /v1, /graphql)
// and against accidental workspace slugs that read like API identifiers.
"v1",
"v2",
"graphql",
"webhooks",
"sdk",
"tokens",
"cli",
// Backend ops / observability. `/health` and `/ws` exist on the backend
// host; reserving them on the workspace slug space prevents naming
// confusion if/when these paths are ever proxied through the web origin.
"health",
"ws",
"metrics",
"ping",
// RFC 2142 — privileged email mailboxes. Allowing user workspaces with
// these slugs would let attackers spoof system messaging.
"postmaster",
@@ -112,12 +82,7 @@ export const RESERVED_SLUGS = new Set([
"files",
"uploads",
// Next.js / web standards. These entries contain characters (dots,
// underscores) that today's slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`
// already rejects at the format-validation step — so `isReservedSlug`
// never actually matches them. They are kept as defense-in-depth so
// that if the slug regex is ever relaxed (e.g. to support dotted
// corporate slugs like `acme.io`), these system paths stay protected.
// Next.js / web standards (framework-mandated)
"_next",
"favicon.ico",
"robots.txt",

View File

@@ -15,7 +15,6 @@ import { workspaceKeys } from "../workspace/queries";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import { setCurrentWorkspace } from "./workspace-storage";
import type { ClientIdentity } from "./types";
import type { StorageAdapter } from "../types/storage";
import type { User } from "../types";
@@ -27,14 +26,12 @@ export function AuthInitializer({
onLogout,
storage = defaultStorage,
cookieAuth,
identity,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
storage?: StorageAdapter;
cookieAuth?: boolean;
identity?: ClientIdentity;
}) {
const qc = useQueryClient();
@@ -50,16 +47,8 @@ export function AuthInitializer({
.getConfig()
.then((cfg) => {
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
configStore.getState().setAuthConfig({
allowSignup: cfg.allow_signup,
googleClientId: cfg.google_client_id,
});
if (cfg.posthog_key) {
initAnalytics({
key: cfg.posthog_key,
host: cfg.posthog_host || "",
appVersion: identity?.version,
});
initAnalytics({ key: cfg.posthog_key, host: cfg.posthog_host || "" });
}
})
.catch(() => {

View File

@@ -73,13 +73,7 @@ export function CoreProvider({
return (
<QueryProvider>
<AuthInitializer
onLogin={onLogin}
onLogout={onLogout}
storage={storage}
cookieAuth={cookieAuth}
identity={identity}
>
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage} cookieAuth={cookieAuth}>
<WSProvider
wsUrl={wsUrl}
authStore={authStore}

View File

@@ -27,9 +27,6 @@ export interface AgentTask {
id: string;
agent_id: string;
runtime_id: string;
// Empty string ("") when the task has no linked issue — either chat- or
// autopilot-spawned. Check chat_session_id / autopilot_run_id to tell
// which source produced it.
issue_id: string;
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
priority: number;
@@ -39,10 +36,6 @@ export interface AgentTask {
result: unknown;
error: string | null;
created_at: string;
/** Non-empty when the task was spawned from a chat session. */
chat_session_id?: string;
/** Non-empty when the task was spawned by an autopilot run. */
autopilot_run_id?: string;
}
export interface Agent {
@@ -82,9 +75,6 @@ export interface CreateAgentRequest {
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
model?: string;
/** Optional template slug used by the onboarding agent picker. Surfaced
* as the `template` property on the `agent_created` PostHog event. */
template?: string;
}
export interface UpdateAgentRequest {

View File

@@ -54,7 +54,6 @@ export type WSEventType =
| "project:deleted"
| "pin:created"
| "pin:deleted"
| "pin:reordered"
| "invitation:created"
| "invitation:accepted"
| "invitation:declined"

View File

@@ -1,11 +1,5 @@
export type PinnedItemType = "issue" | "project";
/**
* Pin metadata only. Title / status / identifier / icon are NOT here —
* consumers derive them from `issueDetailOptions` / `projectDetailOptions`
* so the sidebar reacts to `issue:updated` / `project:updated` events
* automatically, without needing a cross-entity invalidate on `pinKeys`.
*/
export interface PinnedItem {
id: string;
workspace_id: string;
@@ -14,6 +8,10 @@ export interface PinnedItem {
item_id: string;
position: number;
created_at: string;
title: string;
identifier?: string;
icon?: string;
status?: string;
}
export interface CreatePinRequest {

View File

@@ -1,17 +1,14 @@
import * as React from 'react'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { FileText, Download } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessFileCards } from './file-cards'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
import './markdown.css'
/**
* Render modes for markdown content:
@@ -79,7 +76,6 @@ const sanitizeSchema = {
code: [
...(defaultSchema.attributes?.code ?? []),
['className', /^language-/],
['className', /^math-/],
['className', /^hljs/],
],
img: [
@@ -404,8 +400,8 @@ export function Markdown({
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[remarkMath, [remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
urlTransform={urlTransform}
components={components}
>

View File

@@ -1,11 +0,0 @@
@import "katex/dist/katex.min.css";
.markdown-content .katex-display {
margin: 0.75rem 0;
overflow-x: auto;
overflow-y: hidden;
}
.markdown-content .katex-display > .katex {
white-space: nowrap;
}

View File

@@ -30,17 +30,14 @@
"emoji-mart": "^5.6.0",
"input-otp": "^1.4.2",
"linkify-it": "^5.0.0",
"katex": "catalog:",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"react-day-picker": "^9.14.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.5",
"recharts": "3.8.0",
"rehype-katex": "catalog:",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "catalog:",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "catalog:",

View File

@@ -202,56 +202,4 @@ describe("TasksTab", () => {
expect(label.closest("a")).toBeNull();
expect(mockGetIssue).not.toHaveBeenCalled();
});
it("labels chat-spawned tasks as 'Chat session'", async () => {
renderTasksTab(
[
{
id: "task-chat",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "",
chat_session_id: "chat-42",
status: "running",
priority: 1,
dispatched_at: "2026-04-16T00:30:00Z",
started_at: "2026-04-16T00:31:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[],
);
const label = await screen.findByText("Chat session");
expect(label.closest("a")).toBeNull();
});
it("labels autopilot-spawned tasks as 'Autopilot run'", async () => {
renderTasksTab(
[
{
id: "task-autopilot",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "",
autopilot_run_id: "run-7",
status: "completed",
priority: 1,
dispatched_at: null,
started_at: null,
completed_at: "2026-04-16T01:00:00Z",
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[],
);
const label = await screen.findByText("Autopilot run");
expect(label.closest("a")).toBeNull();
});
});

View File

@@ -106,18 +106,11 @@ export function TasksTab({ agent }: { agent: Agent }) {
{sortedTasks.map((task) => {
const config = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
const Icon = config.icon;
// Tasks without a linked issue carry issue_id = "" — skip the
// detail lookup and render them as non-link rows. The source
// label is picked from chat_session_id / autopilot_run_id,
// which the server populates for chat- and autopilot-spawned
// tasks respectively.
// Tasks without a linked issue (autopilot run_only, chat-spawned,
// etc.) carry issue_id = "" — skip the lookup and render them
// as non-link rows.
const hasIssue = task.issue_id !== "";
const issue = hasIssue ? issueMap.get(task.issue_id) : undefined;
const sourcelessLabel = task.chat_session_id
? "Chat session"
: task.autopilot_run_id
? "Autopilot run"
: "Task without linked issue";
const isActive = task.status === "running" || task.status === "dispatched";
const isRunning = task.status === "running";
const rowClassName = `flex items-center gap-3 rounded-lg border px-4 py-3 transition-shadow hover:shadow-sm ${
@@ -143,7 +136,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
</span>
)}
<span className={`text-sm truncate ${isActive ? "font-medium" : ""}`}>
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : sourcelessLabel)}
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : "Task without linked issue")}
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">

View File

@@ -55,11 +55,6 @@ interface LoginPageProps {
onTokenObtained?: () => void;
/** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */
onGoogleLogin?: () => void;
/** Slot rendered at the bottom of the sign-in card, below the
* Google button. The web shell uses it for a "Prefer the desktop
* app?" prompt; desktop omits it (a download prompt inside the app
* would be absurd). */
extra?: ReactNode;
}
// ---------------------------------------------------------------------------
@@ -103,7 +98,6 @@ export function LoginPage({
cliCallback,
onTokenObtained,
onGoogleLogin,
extra,
}: LoginPageProps) {
const qc = useQueryClient();
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
@@ -477,7 +471,6 @@ export function LoginPage({
</Button>
</>
)}
{extra && <div className="w-full pt-1 text-center">{extra}</div>}
</CardFooter>
</Card>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
@@ -11,6 +11,7 @@ import {
useCreateAutopilotTrigger,
useDeleteAutopilotTrigger,
} from "@multica/core/autopilots/mutations";
import { agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
@@ -27,15 +28,20 @@ import {
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import {
TriggerConfigSection,
getDefaultTriggerConfig,
toCronExpression,
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
import { ReadonlyContent } from "../../editor";
import { AutopilotDialog } from "./autopilot-dialog";
import type { AutopilotRun, AutopilotTrigger } from "@multica/core/types";
function formatDate(date: string): string {
return new Date(date).toLocaleString(undefined, {
@@ -132,6 +138,170 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
);
}
const PRIORITY_OPTIONS = [
{ value: "urgent", label: "Urgent" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
{ value: "none", label: "None" },
];
const EXECUTION_MODE_OPTIONS = [
{ value: "create_issue", label: "Create Issue" },
{ value: "run_only", label: "Run Only" },
];
function EditAutopilotDialog({
open,
onOpenChange,
autopilot,
agents,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
autopilot: { id: string; title: string; description?: string | null; assignee_id: string; priority: string; execution_mode: string; issue_title_template?: string | null };
agents: { id: string; name: string; archived_at?: string | null }[];
}) {
const updateAutopilot = useUpdateAutopilot();
const [title, setTitle] = useState(autopilot.title);
const [description, setDescription] = useState(autopilot.description ?? "");
const [assigneeId, setAssigneeId] = useState(autopilot.assignee_id);
const [priority, setPriority] = useState(autopilot.priority);
const [executionMode, setExecutionMode] = useState(autopilot.execution_mode);
const [submitting, setSubmitting] = useState(false);
const activeAgents = agents.filter((a) => !a.archived_at);
// Sync form when autopilot data changes (e.g. after optimistic update)
useEffect(() => {
setTitle(autopilot.title);
setDescription(autopilot.description ?? "");
setAssigneeId(autopilot.assignee_id);
setPriority(autopilot.priority);
setExecutionMode(autopilot.execution_mode);
}, [autopilot]);
const handleSubmit = async () => {
if (!title.trim() || !assigneeId || submitting) return;
setSubmitting(true);
try {
await updateAutopilot.mutateAsync({
id: autopilot.id,
title: title.trim(),
description: description.trim() || null,
assignee_id: assigneeId,
priority,
execution_mode: executionMode as "create_issue" | "run_only",
});
onOpenChange(false);
toast.success("Autopilot updated");
} catch {
toast.error("Failed to update autopilot");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogTitle>Edit Autopilot</DialogTitle>
<div className="space-y-4 pt-2">
{/* Name */}
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Daily code review"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
{/* Prompt */}
<div>
<label className="text-xs font-medium text-muted-foreground">Prompt</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Step-by-step instructions for the agent..."
rows={6}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring resize-y"
/>
</div>
{/* Agent + Priority */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Agent</label>
<Select value={assigneeId} onValueChange={(v) => v && setAssigneeId(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => {
if (!value) return "Select agent...";
const agent = activeAgents.find((a) => a.id === value);
return agent?.name ?? "Unknown Agent";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{activeAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Priority</label>
<Select value={priority} onValueChange={(v) => v && setPriority(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => PRIORITY_OPTIONS.find((o) => o.value === value)?.label ?? "Medium"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Execution Mode */}
<div>
<label className="text-xs font-medium text-muted-foreground">Execution Mode</label>
<Select value={executionMode} onValueChange={(v) => v && setExecutionMode(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => EXECUTION_MODE_OPTIONS.find((o) => o.value === value)?.label ?? "Create Issue"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{EXECUTION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || !assigneeId || submitting}>
{submitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function AddTriggerDialog({
open,
onOpenChange,
@@ -205,6 +375,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
const { data, isLoading } = useQuery(autopilotDetailOptions(wsId, autopilotId));
const { data: runs = [], isLoading: runsLoading } = useQuery(autopilotRunsOptions(wsId, autopilotId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const updateAutopilot = useUpdateAutopilot();
const deleteAutopilot = useDeleteAutopilot();
const triggerAutopilot = useTriggerAutopilot();
@@ -350,9 +521,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
{autopilot.description && (
<div className="col-span-2">
<label className="text-xs text-muted-foreground">Prompt</label>
<div className="mt-1">
<ReadonlyContent content={autopilot.description} />
</div>
<div className="mt-1 whitespace-pre-wrap text-sm">{autopilot.description}</div>
</div>
)}
</div>
@@ -418,22 +587,12 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
onOpenChange={setTriggerDialogOpen}
autopilotId={autopilotId}
/>
{editDialogOpen && (
<AutopilotDialog
mode="edit"
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
autopilotId={autopilot.id}
initial={{
title: autopilot.title,
description: autopilot.description ?? "",
assignee_id: autopilot.assignee_id,
priority: autopilot.priority,
execution_mode: autopilot.execution_mode as AutopilotExecutionMode,
}}
triggers={triggers}
/>
)}
<EditAutopilotDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
autopilot={autopilot}
agents={agents}
/>
</div>
);
}

View File

@@ -1,350 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Calendar, ChevronRight, Maximize2, Minimize2, Rocket, X as XIcon } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { useCurrentWorkspace } from "@multica/core/paths";
import {
useCreateAutopilot,
useCreateAutopilotTrigger,
useUpdateAutopilot,
useUpdateAutopilotTrigger,
} from "@multica/core/autopilots/mutations";
import type {
AutopilotExecutionMode,
AutopilotTrigger,
IssuePriority,
} from "@multica/core/types";
import { TitleEditor, ContentEditor } from "../../editor";
import { PillButton } from "../../common/pill-button";
import { PriorityPicker } from "../../issues/components/pickers";
import {
getDefaultTriggerConfig,
parseCronExpression,
summarizeTrigger,
toCronExpression,
type TriggerConfig,
} from "./trigger-config";
import { AgentPicker } from "./pickers/agent-picker";
import { ExecutionModePicker } from "./pickers/execution-mode-picker";
import { SchedulePicker } from "./pickers/schedule-picker";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AutopilotInitial {
title: string;
description: string;
assignee_id: string;
priority: string;
execution_mode: AutopilotExecutionMode;
}
export type AutopilotDialogProps =
| {
mode: "create";
open: boolean;
onOpenChange: (v: boolean) => void;
initial?: Partial<AutopilotInitial>;
initialTriggerConfig?: Partial<TriggerConfig>;
}
| {
mode: "edit";
open: boolean;
onOpenChange: (v: boolean) => void;
autopilotId: string;
initial: AutopilotInitial;
triggers: AutopilotTrigger[];
};
// ---------------------------------------------------------------------------
// AutopilotDialog — shared Create/Edit dialog for autopilots
// ---------------------------------------------------------------------------
export function AutopilotDialog(props: AutopilotDialogProps) {
const { open, onOpenChange } = props;
const workspaceName = useCurrentWorkspace()?.name;
const [isExpanded, setIsExpanded] = useState(false);
const isCreate = props.mode === "create";
const initial: Partial<AutopilotInitial> = isCreate ? props.initial ?? {} : props.initial;
const [title, setTitle] = useState(initial.title ?? "");
const [description, setDescription] = useState(initial.description ?? "");
const [assigneeId, setAssigneeId] = useState<string>(initial.assignee_id ?? "");
const [priority, setPriority] = useState<string>(initial.priority ?? "medium");
const [executionMode, setExecutionMode] = useState<AutopilotExecutionMode>(
initial.execution_mode ?? "create_issue",
);
const initialCfg: TriggerConfig = (() => {
if (isCreate) {
const tpl = props.initialTriggerConfig;
return tpl ? { ...getDefaultTriggerConfig(), ...tpl } : getDefaultTriggerConfig();
}
const first = props.triggers[0];
if (first?.cron_expression) {
return parseCronExpression(first.cron_expression, first.timezone ?? "UTC");
}
return getDefaultTriggerConfig();
})();
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(initialCfg);
// Snapshot initial cron payload at mount so `scheduleDirty` only flips when
// the payload actually changes (prevents phantom trigger creates when the
// user opens the popover, clicks the already-active option, then Saves).
const initialCronRef = useRef(toCronExpression(initialCfg));
const initialTimezoneRef = useRef(initialCfg.timezone);
const scheduleDirty =
toCronExpression(triggerConfig) !== initialCronRef.current ||
triggerConfig.timezone !== initialTimezoneRef.current;
// Snapshot the first-trigger id at mount. Parent `triggers` prop can update
// mid-dialog via WS refetch — we want Save to act on the trigger we showed.
const firstTriggerIdRef = useRef(
!isCreate && props.triggers[0] ? props.triggers[0].id : null,
);
const triggerCount = isCreate ? 0 : props.triggers.length;
const schedulePillDisabled = !isCreate && triggerCount >= 2;
const schedulePillLabel = (() => {
if (isCreate) return summarizeTrigger(triggerConfig);
if (triggerCount === 0) return "Add schedule";
if (triggerCount === 1) return summarizeTrigger(triggerConfig);
return `${triggerCount} schedules`;
})();
const createAutopilot = useCreateAutopilot();
const createTrigger = useCreateAutopilotTrigger();
const updateAutopilot = useUpdateAutopilot();
const updateTrigger = useUpdateAutopilotTrigger();
const [submitting, setSubmitting] = useState(false);
const canSubmit = title.trim().length > 0 && assigneeId.length > 0 && !submitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setSubmitting(true);
try {
if (isCreate) {
const autopilot = await createAutopilot.mutateAsync({
title: title.trim(),
description: description.trim() || undefined,
assignee_id: assigneeId,
priority,
execution_mode: executionMode,
});
let scheduleOk = true;
try {
await createTrigger.mutateAsync({
autopilotId: autopilot.id,
kind: "schedule",
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
} catch {
scheduleOk = false;
}
onOpenChange(false);
if (scheduleOk) toast.success("Autopilot created");
else toast.error("Autopilot created, but schedule failed to save");
} else {
await updateAutopilot.mutateAsync({
id: props.autopilotId,
title: title.trim(),
description: description.trim() || null,
assignee_id: assigneeId,
priority,
execution_mode: executionMode,
});
// Schedule: patch the trigger we snapshotted at mount, else create one.
let scheduleOk = true;
if (scheduleDirty && !schedulePillDisabled) {
const snapshottedTriggerId = firstTriggerIdRef.current;
try {
if (snapshottedTriggerId) {
await updateTrigger.mutateAsync({
autopilotId: props.autopilotId,
triggerId: snapshottedTriggerId,
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
} else {
await createTrigger.mutateAsync({
autopilotId: props.autopilotId,
kind: "schedule",
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
}
} catch {
scheduleOk = false;
}
}
onOpenChange(false);
if (scheduleOk) toast.success("Autopilot updated");
else toast.error("Autopilot updated, but schedule failed to save");
}
} catch {
toast.error(isCreate ? "Failed to create autopilot" : "Failed to update autopilot");
} finally {
setSubmitting(false);
}
};
const contentKey = isCreate ? "create" : props.autopilotId;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!transition-all !duration-300 !ease-out !-translate-y-1/2",
"!w-[calc(100vw-2rem)]",
isExpanded ? "!max-w-4xl !h-5/6" : "!max-w-2xl !h-96",
)}
>
<DialogTitle className="sr-only">
{isCreate ? "New Autopilot" : "Edit Autopilot"}
</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<Rocket className="size-3 text-muted-foreground" />
<span className="font-medium">
{isCreate ? "New autopilot" : "Edit autopilot"}
</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded((v) => !v)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => onOpenChange(false)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
{/* Body re-mounts when switching between autopilots (or create/edit) */}
<div key={contentKey} className="flex-1 flex flex-col min-h-0">
{/* Name */}
<div className="px-5 pb-2 shrink-0">
<TitleEditor
autoFocus={isCreate}
defaultValue={initial.title ?? ""}
placeholder="Autopilot name"
className="text-lg font-semibold"
onChange={setTitle}
onSubmit={handleSubmit}
/>
</div>
{/* Prompt — takes remaining space */}
<div className="relative flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
defaultValue={initial.description ?? ""}
placeholder="Step-by-step instructions for the agent..."
onUpdate={setDescription}
debounceMs={300}
showBubbleMenu={false}
/>
</div>
{/* Pill toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<AgentPicker
agentId={assigneeId || null}
onChange={setAssigneeId}
triggerRender={<PillButton />}
align="start"
/>
<PriorityPicker
priority={priority as IssuePriority}
onUpdate={(u) => { if (u.priority) setPriority(u.priority); }}
triggerRender={<PillButton />}
align="start"
/>
<ExecutionModePicker
mode={executionMode}
onChange={setExecutionMode}
triggerRender={<PillButton />}
align="start"
/>
{schedulePillDisabled ? (
<Tooltip>
<TooltipTrigger
render={
<PillButton
disabled
className="opacity-60 cursor-not-allowed"
>
<Calendar className="size-3" />
<span className="truncate">{schedulePillLabel}</span>
</PillButton>
}
/>
<TooltipContent side="top">Edit schedules in detail page</TooltipContent>
</Tooltip>
) : (
<SchedulePicker
config={triggerConfig}
onChange={setTriggerConfig}
triggerRender={
<PillButton>
<Calendar className="size-3" />
<span className="truncate">{schedulePillLabel}</span>
</PillButton>
}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t shrink-0">
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{submitting
? isCreate ? "Creating..." : "Saving..."
: isCreate ? "Create autopilot" : "Save"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,8 @@ import { useState } from "react";
import { Plus, Zap, Play, Pause, AlertCircle, Newspaper, GitPullRequest, Bug, BarChart3, Shield, FileSearch } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotListOptions } from "@multica/core/autopilots/queries";
import { useCreateAutopilot, useCreateAutopilotTrigger } from "@multica/core/autopilots/mutations";
import { agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
@@ -13,7 +15,25 @@ import { PageHeader } from "../../layout/page-header";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { AutopilotDialog } from "./autopilot-dialog";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import {
TriggerConfigSection,
getDefaultTriggerConfig,
toCronExpression,
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { Autopilot } from "@multica/core/types";
import type { TriggerFrequency } from "./trigger-config";
@@ -166,6 +186,155 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
);
}
function CreateAutopilotDialog({
open,
onOpenChange,
template,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
template?: AutopilotTemplate | null;
}) {
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const createAutopilot = useCreateAutopilot();
const createTrigger = useCreateAutopilotTrigger();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
const [submitting, setSubmitting] = useState(false);
// Apply template when it changes
const [appliedTemplate, setAppliedTemplate] = useState<AutopilotTemplate | null | undefined>(null);
if (template !== appliedTemplate && open) {
setAppliedTemplate(template);
if (template) {
setTitle(template.title);
setDescription(template.prompt);
setTriggerConfig({
...getDefaultTriggerConfig(),
frequency: template.frequency,
time: template.time,
});
}
}
const activeAgents = agents.filter((a) => !a.archived_at);
const handleSubmit = async () => {
if (!title.trim() || !assigneeId || submitting) return;
setSubmitting(true);
try {
const autopilot = await createAutopilot.mutateAsync({
title: title.trim(),
description: description.trim() || undefined,
assignee_id: assigneeId,
execution_mode: "create_issue",
});
// Attach schedule trigger
try {
await createTrigger.mutateAsync({
autopilotId: autopilot.id,
kind: "schedule",
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
} catch {
toast.error("Autopilot created, but trigger failed to save");
}
onOpenChange(false);
setTitle("");
setDescription("");
setAssigneeId("");
setTriggerConfig(getDefaultTriggerConfig());
toast.success("Autopilot created");
} catch {
toast.error("Failed to create autopilot");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogTitle>New Autopilot</DialogTitle>
<div className="space-y-5 pt-2">
{/* Name */}
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Daily code review"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
{/* Prompt */}
<div>
<label className="text-xs font-medium text-muted-foreground">Prompt</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Step-by-step instructions for the agent..."
rows={6}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring resize-y"
/>
</div>
{/* Agent */}
<div>
<label className="text-xs font-medium text-muted-foreground">Agent</label>
<Select value={assigneeId} onValueChange={(v) => v && setAssigneeId(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => {
if (!value) return "Select agent...";
const agent = activeAgents.find((a) => a.id === value);
return agent?.name ?? "Unknown Agent";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{activeAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Schedule */}
<div>
<label className="text-xs font-medium text-muted-foreground">Schedule</label>
<div className="mt-2">
<TriggerConfigSection config={triggerConfig} onChange={setTriggerConfig} />
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || !assigneeId || submitting}>
{submitting ? "Creating..." : "Create"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function AutopilotsPage() {
const wsId = useWorkspaceId();
const { data: autopilots = [], isLoading } = useQuery(autopilotListOptions(wsId));
@@ -261,23 +430,7 @@ export function AutopilotsPage() {
)}
</div>
{createOpen && (
<AutopilotDialog
mode="create"
open={createOpen}
onOpenChange={setCreateOpen}
initial={
selectedTemplate
? { title: selectedTemplate.title, description: selectedTemplate.prompt }
: undefined
}
initialTriggerConfig={
selectedTemplate
? { frequency: selectedTemplate.frequency, time: selectedTemplate.time }
: undefined
}
/>
)}
<CreateAutopilotDialog open={createOpen} onOpenChange={setCreateOpen} template={selectedTemplate} />
</div>
);
}

View File

@@ -1,87 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Bot } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { ActorAvatar } from "../../../common/actor-avatar";
import {
PropertyPicker,
PickerItem,
PickerEmpty,
} from "../../../issues/components/pickers/property-picker";
export function AgentPicker({
agentId,
onChange,
trigger: customTrigger,
triggerRender,
align = "start",
}: {
agentId: string | null;
onChange: (id: string) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
}) {
const wsId = useWorkspaceId();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const active = agents.filter((a) => !a.archived_at);
const selected = active.find((a) => a.id === agentId);
const query = filter.trim().toLowerCase();
const filteredAgents = query
? active.filter((a) => a.name.toLowerCase().includes(query))
: active;
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-56"
align={align}
searchable
searchPlaceholder="Filter agents..."
onSearchChange={setFilter}
triggerRender={triggerRender}
trigger={
customTrigger ?? (
<>
{selected ? (
<>
<ActorAvatar actorType="agent" actorId={selected.id} size={16} />
<span className="truncate">{selected.name}</span>
</>
) : (
<>
<Bot className="size-3" />
<span>Select agent</span>
</>
)}
</>
)
}
>
{filteredAgents.length === 0 ? (
<PickerEmpty />
) : (
filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={a.id === agentId}
onClick={() => {
onChange(a.id);
setOpen(false);
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span className="truncate">{a.name}</span>
</PickerItem>
))
)}
</PropertyPicker>
);
}

View File

@@ -1,76 +0,0 @@
"use client";
import { useState } from "react";
import { FilePlus2, Play } from "lucide-react";
import type { AutopilotExecutionMode } from "@multica/core/types";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
PropertyPicker,
PickerItem,
} from "../../../issues/components/pickers/property-picker";
const OPTIONS: { value: AutopilotExecutionMode; label: string; description: string; Icon: typeof FilePlus2 }[] = [
{ value: "create_issue", label: "Create Issue", description: "File an issue with the agent assigned", Icon: FilePlus2 },
{ value: "run_only", label: "Run Only", description: "Run the agent without creating an issue", Icon: Play },
];
export function ExecutionModePicker({
mode,
onChange,
trigger: customTrigger,
triggerRender,
align = "start",
}: {
mode: AutopilotExecutionMode;
onChange: (mode: AutopilotExecutionMode) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
}) {
const [open, setOpen] = useState(false);
const current = OPTIONS.find((o) => o.value === mode) ?? OPTIONS[0]!;
const CurrentIcon = current.Icon;
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-52"
align={align}
triggerRender={triggerRender}
trigger={
customTrigger ?? (
<>
<CurrentIcon className="size-3 shrink-0" />
<span className="truncate">{current.label}</span>
</>
)
}
>
{OPTIONS.map((o) => {
const Icon = o.Icon;
return (
<Tooltip key={o.value}>
<TooltipTrigger
render={
<PickerItem
selected={o.value === mode}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span>{o.label}</span>
</PickerItem>
}
/>
<TooltipContent side="right" sideOffset={8}>
{o.description}
</TooltipContent>
</Tooltip>
);
})}
</PropertyPicker>
);
}

View File

@@ -1,32 +0,0 @@
"use client";
import { useState } from "react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import {
TriggerConfigSection,
type TriggerConfig,
} from "../trigger-config";
export function SchedulePicker({
config,
onChange,
triggerRender,
}: {
config: TriggerConfig;
onChange: (cfg: TriggerConfig) => void;
triggerRender: React.ReactElement;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={triggerRender} />
<PopoverContent align="start" className="w-96 p-3">
<TriggerConfigSection config={config} onChange={onChange} />
</PopoverContent>
</Popover>
);
}

View File

@@ -1,68 +0,0 @@
import { describe, it, expect } from "vitest";
import {
parseCronExpression,
toCronExpression,
getDefaultTriggerConfig,
} from "./trigger-config";
describe("parseCronExpression", () => {
it("round-trips hourly", () => {
const cfg = { ...getDefaultTriggerConfig(), frequency: "hourly" as const, time: "00:15" };
const cron = toCronExpression(cfg);
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("hourly");
});
it("round-trips daily at 09:30", () => {
const cfg = { ...getDefaultTriggerConfig(), frequency: "daily" as const, time: "09:30" };
const cron = toCronExpression(cfg);
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("daily");
expect(parsed.time).toBe("09:30");
});
it("recognises weekdays pattern", () => {
const parsed = parseCronExpression("0 9 * * 1-5", "UTC");
expect(parsed.frequency).toBe("weekdays");
expect(parsed.time).toBe("09:00");
});
it("recognises weekly with multiple days", () => {
const parsed = parseCronExpression("0 9 * * 1,3,5", "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.daysOfWeek).toEqual([1, 3, 5]);
expect(parsed.time).toBe("09:00");
});
it("falls back to custom for non-matching pattern", () => {
const parsed = parseCronExpression("*/15 * * * *", "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe("*/15 * * * *");
});
it("falls back to custom for malformed input", () => {
const parsed = parseCronExpression("not a cron", "UTC");
expect(parsed.frequency).toBe("custom");
});
it("preserves provided timezone", () => {
const parsed = parseCronExpression("0 9 * * *", "Asia/Shanghai");
expect(parsed.timezone).toBe("Asia/Shanghai");
});
it("rejects out-of-range minute", () => {
expect(parseCronExpression("60 * * * *", "UTC").frequency).toBe("custom");
});
it("rejects out-of-range hour", () => {
expect(parseCronExpression("0 24 * * *", "UTC").frequency).toBe("custom");
});
it("round-trips weekly preserving daysOfWeek", () => {
const cfg = { ...getDefaultTriggerConfig(), frequency: "weekly" as const, time: "14:45", daysOfWeek: [0, 2, 6] };
const parsed = parseCronExpression(toCronExpression(cfg), "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.time).toBe("14:45");
expect(parsed.daysOfWeek).toEqual([0, 2, 6]);
});
});

View File

@@ -139,57 +139,6 @@ export function toCronExpression(cfg: TriggerConfig): string {
}
}
export function parseCronExpression(cron: string, timezone: string): TriggerConfig {
const base: TriggerConfig = {
...getDefaultTriggerConfig(),
timezone,
cronExpression: cron,
};
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return { ...base, frequency: "custom" };
const minStr = parts[0] ?? "";
const hourStr = parts[1] ?? "";
const dom = parts[2] ?? "";
const mon = parts[3] ?? "";
const dow = parts[4] ?? "";
if (dom !== "*" || mon !== "*") return { ...base, frequency: "custom" };
const min = parseInt(minStr, 10);
if (Number.isNaN(min) || min < 0 || min > 59) return { ...base, frequency: "custom" };
if (hourStr === "*" && dow === "*") {
const time = `00:${String(min).padStart(2, "0")}`;
return { ...base, frequency: "hourly", time };
}
const hour = parseInt(hourStr, 10);
if (Number.isNaN(hour) || hour < 0 || hour > 23) return { ...base, frequency: "custom" };
const time = `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
if (dow === "*") return { ...base, frequency: "daily", time };
if (dow === "1-5") return { ...base, frequency: "weekdays", time, daysOfWeek: [1, 2, 3, 4, 5] };
if (/^[0-6](,[0-6])*$/.test(dow)) {
const days = dow.split(",").map((n) => parseInt(n, 10));
return { ...base, frequency: "weekly", time, daysOfWeek: days };
}
return { ...base, frequency: "custom" };
}
export function summarizeTrigger(cfg: TriggerConfig): string {
switch (cfg.frequency) {
case "hourly": {
const min = cfg.time.split(":")[1] ?? "00";
return `Hourly · :${min}`;
}
case "daily":
return `Daily ${cfg.time}`;
case "weekdays":
return `Weekdays ${cfg.time}`;
case "weekly":
return `${formatDayList(cfg.daysOfWeek)} ${cfg.time}`;
case "custom":
return "Custom cron";
}
}
export function describeTrigger(cfg: TriggerConfig): string {
const offset = getTimezoneOffset(cfg.timezone);
switch (cfg.frequency) {

View File

@@ -18,11 +18,6 @@ interface ChatInputProps {
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
leftAdornment?: ReactNode;
/** Rendered just before the submit button — used for context-anchor action. */
rightAdornment?: ReactNode;
/** Rendered inside the rounded container, above the editor — attached
* context cards, drafts, etc. */
topSlot?: ReactNode;
}
export function ChatInput({
@@ -32,8 +27,6 @@ export function ChatInput({
disabled,
agentName,
leftAdornment,
rightAdornment,
topSlot,
}: ChatInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const activeSessionId = useChatStore((s) => s.activeSessionId);
@@ -82,7 +75,6 @@ export function ChatInput({
return (
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
// Remount the editor when the active session changes so its
@@ -109,8 +101,7 @@ export function ChatInput({
{leftAdornment}
</div>
)}
<div className="absolute bottom-1 right-1.5 flex items-center gap-2">
{rightAdornment}
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled}

View File

@@ -31,12 +31,6 @@ import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import {
ContextAnchorButton,
ContextAnchorCard,
buildAnchorMarkdown,
useRouteAnchorCandidate,
} from "./context-anchor";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
@@ -160,11 +154,6 @@ export function ChatWindow() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [isOpen, activeSessionId, currentHasUnread]);
// Focus-mode anchor: derived from route each render. Prepended to the
// outgoing message when focus is on; the anchor persists across sends
// (focus mode tracks the user's page, not a per-message attachment).
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) {
@@ -172,11 +161,6 @@ export function ChatWindow() {
return;
}
const focusOn = useChatStore.getState().focusMode;
const finalContent = focusOn && anchorCandidate
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
: content;
let sessionId = activeSessionId;
const isNewSession = !sessionId;
@@ -184,14 +168,13 @@ export function ChatWindow() {
sessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
hasAnchor: focusOn && !!anchorCandidate,
contentLength: content.length,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: finalContent.slice(0, 50),
title: content.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
@@ -202,7 +185,7 @@ export function ChatWindow() {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
content,
task_id: null,
created_at: new Date().toISOString(),
};
@@ -212,7 +195,7 @@ export function ChatWindow() {
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, finalContent);
const result = await api.sendChatMessage(sessionId, content);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
@@ -229,7 +212,6 @@ export function ChatWindow() {
[
activeSessionId,
activeAgent,
anchorCandidate,
createSession,
setActiveSession,
qc,
@@ -419,7 +401,6 @@ export function ChatWindow() {
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
@@ -428,7 +409,6 @@ export function ChatWindow() {
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
</div>
);

View File

@@ -1,34 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildAnchorMarkdown } from "./context-anchor";
describe("buildAnchorMarkdown", () => {
it("formats an issue anchor as a mention link with title subtitle", () => {
const md = buildAnchorMarkdown({
type: "issue",
id: "uuid-123",
label: "MUL-42",
subtitle: "Fix login redirect",
});
expect(md).toBe(
'Context: [MUL-42](mention://issue/uuid-123) — "Fix login redirect"',
);
});
it("omits the subtitle clause when none is provided", () => {
const md = buildAnchorMarkdown({
type: "issue",
id: "uuid-x",
label: "MUL-7",
});
expect(md).toBe("Context: [MUL-7](mention://issue/uuid-x)");
});
it("formats a project anchor as plain text (no mention type)", () => {
const md = buildAnchorMarkdown({
type: "project",
id: "proj-uuid",
label: "Authentication",
});
expect(md).toBe('Context: Project "Authentication"');
});
});

View File

@@ -1,215 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Focus } from "lucide-react";
import type { ContextAnchor } from "@multica/core/chat";
import { useChatStore } from "@multica/core/chat";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { inboxListOptions } from "@multica/core/inbox/queries";
import { Button } from "@multica/ui/components/ui/button";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { IssueChip } from "../../issues/components/issue-chip";
import { ProjectChip } from "../../projects/components/project-chip";
import { AppLink, useNavigation } from "../../navigation";
import { useWorkspacePaths } from "@multica/core/paths";
/**
* Format a derived ContextAnchor as the markdown prefix prepended to the
* outgoing chat message. Uses the same `mention://issue/<uuid>` scheme as
* the editor's mention extension, so the AI sees an identical token whether
* the user typed `@MUL-1` in-line or focus-mode attached it.
*/
export function buildAnchorMarkdown(anchor: ContextAnchor): string {
if (anchor.type === "issue") {
const base = `Context: [${anchor.label}](mention://issue/${anchor.id})`;
return anchor.subtitle ? `${base} — "${anchor.subtitle}"` : base;
}
return `Context: Project "${anchor.label}"`;
}
/**
* Resolve the current page into an anchorable candidate, or null if the user
* is somewhere without a natural focus object. Subscribes via react-query so
* the result updates the instant the relevant cache fills.
*
* `wsId` is passed in (per CLAUDE.md convention) so this hook works outside
* a WorkspaceIdProvider if ever reused elsewhere.
*/
export function useRouteAnchorCandidate(wsId: string): {
candidate: ContextAnchor | null;
isResolving: boolean;
} {
const { pathname, searchParams } = useNavigation();
const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/);
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
const isInbox = /^\/[^/]+\/inbox$/.test(pathname);
const routeIssueId = issueMatch ? decodeURIComponent(issueMatch[1]!) : null;
const routeProjectId = projectMatch
? decodeURIComponent(projectMatch[1]!)
: null;
// Inbox: the anchor is the issue behind the currently selected notification.
const { data: inboxItems = [] } = useQuery({
...inboxListOptions(wsId),
enabled: isInbox,
});
const inboxKey = isInbox ? searchParams.get("issue") : null;
const inboxSelectedIssueId =
isInbox && inboxKey
? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ??
null
: null;
// One issue fetch covers both /issues/:id and inbox-derived anchors.
const issueIdToFetch = routeIssueId ?? inboxSelectedIssueId;
const { data: issue, isLoading: issueLoading } = useQuery({
...issueDetailOptions(wsId, issueIdToFetch ?? ""),
enabled: !!issueIdToFetch,
});
const { data: project, isLoading: projectLoading } = useQuery({
...projectDetailOptions(wsId, routeProjectId ?? ""),
enabled: !!routeProjectId,
});
if (issueIdToFetch) {
if (!issue) return { candidate: null, isResolving: issueLoading };
return {
candidate: {
type: "issue",
id: issue.id,
label: issue.identifier,
subtitle: issue.title,
},
isResolving: false,
};
}
if (routeProjectId) {
if (!project) return { candidate: null, isResolving: projectLoading };
return {
candidate: {
type: "project",
id: project.id,
label: project.title,
},
isResolving: false,
};
}
return { candidate: null, isResolving: false };
}
/**
* Focus-mode toggle. Disabled whenever the current page has no anchor
* (nothing to share) — focusMode persists across such pages, so returning
* to an anchorable page restores the user's prior on/off choice.
*
* no candidate → disabled
* off + candidate → ghost + muted, clickable (→ turns on)
* on + candidate → secondary (bright), clickable (→ turns off)
*/
export function ContextAnchorButton() {
const wsId = useWorkspaceId();
const { candidate, isResolving } = useRouteAnchorCandidate(wsId);
const focusMode = useChatStore((s) => s.focusMode);
const setFocusMode = useChatStore((s) => s.setFocusMode);
const hasAnchor = !!candidate;
const isDisabled = !hasAnchor && !isResolving;
const isBright = focusMode && hasAnchor;
const tooltipText = isDisabled
? "Nothing to share with Multica on this page"
: focusMode && candidate
? candidate.type === "issue"
? `Multica knows you're viewing ${candidate.label} · Click to turn off`
: `Multica knows you're viewing project "${candidate.label}" · Click to turn off`
: "Let Multica know what you're viewing";
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={isBright ? "secondary" : "ghost"}
size="icon-sm"
className={isBright ? undefined : "text-muted-foreground"}
onClick={() => setFocusMode(!focusMode)}
disabled={isDisabled}
aria-label={
focusMode ? "Stop sharing current page" : "Share current page with Multica"
}
aria-pressed={focusMode}
/>
}
>
<Focus />
</TooltipTrigger>
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
);
}
/**
* Renders the derived focus target above the input. Shows only when focus
* mode is on *and* the current route resolves to an anchorable object.
* No dismiss affordance — use the button to leave focus mode.
*/
export function ContextAnchorCard() {
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
const { candidate } = useRouteAnchorCandidate(wsId);
const focusMode = useChatStore((s) => s.focusMode);
if (!focusMode || !candidate) return null;
const href =
candidate.type === "issue"
? paths.issueDetail(candidate.id)
: paths.projectDetail(candidate.id);
const tooltipText =
candidate.type === "issue"
? `Multica knows you're viewing ${candidate.label}${candidate.subtitle ? `${candidate.subtitle}` : ""}`
: `Multica knows you're viewing project "${candidate.label}"`;
// Same pattern as IssueMentionCard: wrap the pure chip in an AppLink and
// layer cursor + hover affordance onto the chip. Makes the anchor feel
// alive (text-cursor → pointer, hover background) and behave consistently
// with @mentions — clicking jumps to the entity.
return (
<div className="mx-2 mt-2 flex items-center">
<Tooltip>
<TooltipTrigger
render={
<AppLink href={href} className="inline-flex">
{candidate.type === "issue" ? (
<IssueChip
issueId={candidate.id}
fallbackLabel={candidate.label}
className="cursor-pointer hover:bg-accent transition-colors"
/>
) : (
<ProjectChip
projectId={candidate.id}
fallbackLabel={candidate.label}
className="cursor-pointer hover:bg-accent transition-colors"
/>
)}
</AppLink>
}
/>
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
</div>
);
}

View File

@@ -1,25 +0,0 @@
"use client";
import { cn } from "@multica/ui/lib/utils";
export function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
"data-popup-open:bg-accent data-popup-open:text-accent-foreground",
"disabled:cursor-not-allowed disabled:hover:bg-transparent",
className,
)}
{...props}
>
{children}
</button>
);
}

View File

@@ -1,5 +1,3 @@
@import "katex/dist/katex.min.css";
/*
* ContentEditor typography — ProseMirror styles using shadcn design tokens.
*
@@ -125,31 +123,6 @@
margin-top: 0.25rem;
}
.rich-text-editor .math-node {
display: inline-flex;
max-width: 100%;
vertical-align: middle;
}
.rich-text-editor .math-node.inline {
align-items: center;
}
.rich-text-editor .math-node.block {
display: block;
margin: 0.75rem 0;
overflow-x: auto;
overflow-y: hidden;
}
.rich-text-editor .math-node.block .katex-display {
margin: 0;
}
.rich-text-editor .math-node .katex {
max-width: 100%;
}
/* Nested lists — bullet style progression and tighter spacing */
.rich-text-editor ul ul {
list-style-type: circle;
@@ -532,3 +505,4 @@
white-space: nowrap;
}

View File

@@ -44,7 +44,6 @@ import { createBlurShortcutExtension } from "./blur-shortcut";
import { createFileUploadExtension } from "./file-upload";
import { FileCardExtension } from "./file-card";
import { ImageView } from "./image-view";
import { BlockMathExtension, InlineMathExtension } from "./math";
const lowlight = createLowlight(common);
@@ -117,10 +116,7 @@ export function createEditorExtensions(
TableRow,
TableHeader,
TableCell,
BlockMathExtension,
InlineMathExtension,
// 3-space indent so nested ordered lists survive CommonMark in ReadonlyContent.
Markdown.configure({ indentation: { style: "space", size: 3 } }),
Markdown,
FileCardExtension,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },

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