mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 06:59:19 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/db-res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e0b7481f |
20
.env.example
20
.env.example
@@ -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=
|
||||
|
||||
|
||||
267
.github/workflows/release.yml
vendored
267
.github/workflows/release.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -57,4 +57,3 @@ _features/
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
@@ -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
|
||||
50
CLAUDE.md
50
CLAUDE.md
@@ -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 1–3 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS)
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
59
Makefile
59
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
|
||||
.PHONY: 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"; \
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
|
||||
* `daemonAPI` (main-process daemon state) into the page so its empty
|
||||
* state can distinguish "no runtime registered" from "runtime is on its
|
||||
* way" — without the bundled daemon's status, the page shows a
|
||||
* misleading "Run multica daemon start" hint during the few seconds
|
||||
* between page load and the daemon's first registration.
|
||||
*
|
||||
* `bootstrapping` is true while the daemon is installing, starting, or
|
||||
* already running but hasn't surfaced as a server-side runtime yet.
|
||||
* RuntimeList only shows the spinner when the runtime list is also
|
||||
* empty, so once the daemon registers (and the list fills) the flag
|
||||
* has no visible effect.
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
status.state === "installing_cli" ||
|
||||
status.state === "starting" ||
|
||||
status.state === "running";
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,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}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "We’ll host the runtime for you. Not live yet — leave your email to be notified.",
|
||||
},
|
||||
footer: {
|
||||
releaseNotes: "What’s new in {version}",
|
||||
allReleases: "View all releases",
|
||||
currentVersion: "Current version: {version}",
|
||||
versionUnavailable: "Version unavailable — check GitHub",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 查看",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 `` 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
|
||||
|
||||
611
docs/onboarding-redesign-proposal.md
Normal file
611
docs/onboarding-redesign-proposal.md
Normal 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 试用转化 15–25%,开发者工具只有 8–15%,**68% 放弃原因是 setup 太复杂**
|
||||
- **问卷是杀手**:每多一个表单字段完成率下降 3–5%,某 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 1–2s 内 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`,但新增预期管理("通常 2–4 分钟")和 60s stuck-state fallback("Stuck? 常见问题")
|
||||
- **Cloud waitlist(soft exit)**:邮箱 capture → 标记为"临时完成"(`onboarded_at` 写当前时间,保留 `cloud_waitlist_email`)→ 进 workspace + 顶部 banner
|
||||
|
||||
### 3.4 三个问题的设计
|
||||
|
||||
**Q1:Who will use this workspace?**(单选)
|
||||
- ○ Just me
|
||||
- ○ My team (2–10 people)
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
注意:删掉了"Just exploring for now"——它本质是"态度"而不是"人数结构",和这题的题意不契合;评估型用户如果真的选项都不合适,可以通过 Other 写自由文本("just trying it out" 等)表达。
|
||||
|
||||
**Q2:What best describes you?**(单选)
|
||||
- ○ Software developer
|
||||
- ○ Product / project lead
|
||||
- ○ Writer or content creator
|
||||
- ○ Founder / solo operator
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
**Q3:What 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` | 任意 | Assistant(founder 什么都干,通用兜底) |
|
||||
| `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 |
|
||||
| 后端修改文件 | 1–2(router) |
|
||||
| 前端新文件 | ~6 |
|
||||
| 前端修改文件 | ~10 |
|
||||
| 测试新文件 | ~5(核心逻辑 + personalization 映射 + resume scenarios) |
|
||||
|
||||
---
|
||||
|
||||
## 六、成功指标(上线 30 天内评估)
|
||||
|
||||
参考调研结论设定:
|
||||
|
||||
| 指标 | 业界标杆 | Multica 目标 |
|
||||
|---|---|---|
|
||||
| Time-to-value | < 3 分钟 | Desktop 直达:≤ 3 min;Web→Desktop:≤ 5 min(含装机);Web→CLI:≤ 8 min |
|
||||
| Onboarding 完成率 | 60–80% | 目标 70% |
|
||||
| Day 7 留存 | 25–40% | 目标 30% |
|
||||
| Activation 率 | 40–60% | 目标 50% |
|
||||
| Web→Desktop 转化(Step 3 fork) | in-product 高于 42% 冷推上限 | 目标 50–70% |
|
||||
|
||||
**第一漏斗目标**: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 hatch;Q3 use_case_other 还会嵌入 Step 5 first issue prompt |
|
||||
| 问卷必填 | **全可选**(Other 选了必填文本) | 给评估型用户零摩擦通道;0 选时 Continue 变 Skip |
|
||||
| Welcome 步骤 | **保留独立 welcome**,但改造为"产品介绍屏"(不是打招呼);只在首次进入时看到,回访 resume 自动跳过 | 多一次点击换来的是首次用户真正理解 Multica 是什么;Multica 无心智对标物,没有前置介绍就进问卷 = 用户没有 frame of reference;Welcome 不入后端 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 1(welcome + 问卷拆两屏)**:新建 `step-welcome.tsx`(产品介绍,首次进入时展示)+ `step-questionnaire.tsx`(3 题);抽出 `<OptionCard>` / `<OtherOptionCard>` 复用组件
|
||||
3. **Step 2(workspace)**:基本保留,接入 `useOnboardingStore()`
|
||||
4. **Step 3(runtime)**:在 web 分支里新建 `step-platform-fork.tsx`;desktop 分支保留静默自动;CLI 分支加预期管理和 60s fallback
|
||||
5. **Step 4(agent)**:模板集从 3 扩成 4(加 Writing),按 Q2×Q3 预填 template + provider(provider 来自 daemon 探测),移除手填 name 的强制性
|
||||
6. **Step 5(first 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 + router(API 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/)
|
||||
1772
docs/plans/2026-04-07-tanstack-query-migration.md
Normal file
1772
docs/plans/2026-04-07-tanstack-query-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal 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)
|
||||
```
|
||||
227
docs/plans/2026-04-08-drag-upload-enhancement.md
Normal file
227
docs/plans/2026-04-08-drag-upload-enhancement.md
Normal 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 `` markdown. Non-images use `[name](url)` markdown, rendered as a styled card via Tiptap NodeView when URL matches our CDN. Backend unchanged.
|
||||
|
||||
**Tech Stack:** Tiptap ProseMirror, React, Tailwind CSS, shadcn tokens
|
||||
|
||||
---
|
||||
|
||||
## What We Keep (from previous work)
|
||||
|
||||
- **Drag overlay** — `content-editor.tsx` drag handlers + `content-editor.css` overlay styles
|
||||
- **Image upload flow** — blob preview → upload → replace with real URL (existing `file-upload.ts`)
|
||||
- **Non-image upload placeholder** — `⏳ Uploading filename...` → replaced with link (existing `file-upload.ts`)
|
||||
- **`MAX_FILE_SIZE`** — 100MB limit
|
||||
|
||||
## What We Remove (redundant)
|
||||
|
||||
| File | What to remove |
|
||||
|------|----------------|
|
||||
| `attachment-section.tsx` | **Delete entire file** |
|
||||
| `issue-detail.tsx` | attachment query, delete mutation, handleImageRemoved, AttachmentSection JSX, onImageRemoved prop, all `["attachments"]` cache invalidation, onUploadSuccess on CommentInput, `api` import (if unused after) |
|
||||
| `content-editor.tsx` | `onImageRemoved` prop, `onImageRemovedRef` |
|
||||
| `extensions/index.ts` | `onImageRemovedRef` option |
|
||||
| `extensions/file-upload.ts` | `collectImageSrcs`, `imageRemovalTracker` plugin, `isAllowedFileType` check + import, `toast` import |
|
||||
| `shared/constants/upload.ts` | Everything except `MAX_FILE_SIZE` — remove `ALLOWED_MIME_PATTERNS`, `FILE_INPUT_ACCEPT`, `EXTENSION_MIME_MAP`, `isAllowedFileType`, `matchesMimePattern` |
|
||||
| `shared/constants/__tests__/upload.test.ts` | All tests except MAX_FILE_SIZE |
|
||||
| `shared/hooks/use-file-upload.ts` | `isAllowedFileType` import + check |
|
||||
| `components/common/file-upload-button.tsx` | `FILE_INPUT_ACCEPT` import + `accept` attribute |
|
||||
| `comment-input.tsx` | `onUploadSuccess` prop |
|
||||
|
||||
## What We Add (new)
|
||||
|
||||
**File Card Node** — a Tiptap custom node that renders `[name](url)` as a styled card when the URL matches our CDN (`multica-static.copilothub.ai` or S3 bucket domain).
|
||||
|
||||
```
|
||||
Editor view: ┌──────────────────────────┐
|
||||
│ 📄 report.pdf ⬇ │
|
||||
└──────────────────────────┘
|
||||
|
||||
Markdown storage: [report.pdf](https://multica-static.copilothub.ai/xxx.pdf)
|
||||
```
|
||||
|
||||
- Only for non-image CDN URLs (images stay as ``)
|
||||
- Regular external links (github.com, etc.) stay as normal links
|
||||
- Card shows: file type icon + filename + download button
|
||||
- Readonly mode shows the same card
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove Redundant Code
|
||||
|
||||
**Files to modify:**
|
||||
- Delete: `apps/web/features/issues/components/attachment-section.tsx`
|
||||
- Modify: `apps/web/features/issues/components/issue-detail.tsx`
|
||||
- Modify: `apps/web/features/issues/components/comment-input.tsx`
|
||||
- Modify: `apps/web/features/editor/content-editor.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
- Modify: `apps/web/shared/constants/upload.ts`
|
||||
- Modify: `apps/web/shared/constants/__tests__/upload.test.ts`
|
||||
- Modify: `apps/web/shared/hooks/use-file-upload.ts`
|
||||
- Modify: `apps/web/components/common/file-upload-button.tsx`
|
||||
|
||||
**What to do:**
|
||||
1. Delete `attachment-section.tsx`
|
||||
2. `issue-detail.tsx`: Remove AttachmentSection import, attachment useQuery, deleteAttachment useMutation, handleImageRemoved, onImageRemoved prop, all `["attachments"]` invalidation in handleDescriptionUpload (revert to simple `uploadWithToast` call), remove onUploadSuccess from CommentInput
|
||||
3. `comment-input.tsx`: Remove `onUploadSuccess` prop
|
||||
4. `content-editor.tsx`: Remove `onImageRemoved` prop + ref + wiring
|
||||
5. `extensions/index.ts`: Remove `onImageRemovedRef` from interface + call
|
||||
6. `extensions/file-upload.ts`: Remove `collectImageSrcs`, `imageRemovalTracker` plugin, `onImageRemovedRef` param, `isAllowedFileType` import + check, `toast` import (keep `toast` if still used — check)
|
||||
7. `shared/constants/upload.ts`: Keep only `MAX_FILE_SIZE`. Delete everything else.
|
||||
8. `shared/constants/__tests__/upload.test.ts`: Keep only `MAX_FILE_SIZE` test
|
||||
9. `shared/hooks/use-file-upload.ts`: Remove `isAllowedFileType` import + check. Import `MAX_FILE_SIZE` stays.
|
||||
10. `file-upload-button.tsx`: Remove `FILE_INPUT_ACCEPT` import + `accept` attribute
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
**Commit:** `refactor(upload): remove attachment section and file type whitelist`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: File Card Tiptap Node
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/file-card.ts`
|
||||
- Create: `apps/web/features/editor/extensions/file-card-view.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/content-editor.css`
|
||||
|
||||
**Design:**
|
||||
|
||||
The node intercepts markdown links `[name](url)` where URL matches our CDN, and renders them as a card NodeView.
|
||||
|
||||
```typescript
|
||||
// Detection: URL starts with CDN domain or known S3 bucket pattern
|
||||
function isCdnFileUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname.endsWith('.copilothub.ai') || u.hostname.endsWith('.amazonaws.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only match non-image files (images stay as )
|
||||
function isFileCardLink(url: string): boolean {
|
||||
return isCdnFileUrl(url) && !isImageUrl(url);
|
||||
}
|
||||
```
|
||||
|
||||
**Node spec:**
|
||||
- Node name: `fileCard`
|
||||
- Attrs: `href`, `filename`
|
||||
- Markdown serialize: `[filename](href)`
|
||||
- Markdown parse: detect `[text](cdnUrl)` where cdnUrl is non-image CDN link
|
||||
- NodeView: React component with file icon + name + download button
|
||||
|
||||
**Card UI (React NodeView):**
|
||||
```tsx
|
||||
<div className="file-card">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{filename}</span>
|
||||
<a href={href} download={filename} className="...">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.file-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
margin: 0.25rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual:
|
||||
1. Upload a PDF → card appears in editor (not plain link)
|
||||
2. Upload a .go file → card appears
|
||||
3. Upload an image → still renders inline (not as card)
|
||||
4. Paste an external link → still renders as normal link (not card)
|
||||
5. Save and reload → card still displays correctly
|
||||
6. Switch to readonly mode → card still displays
|
||||
|
||||
**Commit:** `feat(editor): render CDN file links as styled cards`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Non-Image Upload to Use File Card
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
|
||||
Currently the non-image upload path inserts a markdown string `[name](url)`. After Task 2 adds the fileCard node, this should insert a `fileCard` node directly instead:
|
||||
|
||||
```typescript
|
||||
// Instead of:
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
replacePlaceholder(editor, placeholder, linkText);
|
||||
|
||||
// Insert fileCard node:
|
||||
replacePlaceholder(editor, placeholder, "");
|
||||
editor.chain().focus().insertContent({
|
||||
type: "fileCard",
|
||||
attrs: { href: result.link, filename: result.filename },
|
||||
}).run();
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual: Upload a PDF → placeholder appears → replaced with file card (not plain text link)
|
||||
|
||||
**Commit:** `feat(upload): insert file card node for non-image uploads`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual test all upload flows:
|
||||
1. Drag image → overlay → drop → inline image with pulse → real image
|
||||
2. Drag PDF → overlay → drop → placeholder → file card
|
||||
3. Drag .mp4 → uploads normally (no type restriction) → file card
|
||||
4. Paste image → inline image
|
||||
5. Click 📎 → file picker shows all types → upload works
|
||||
6. Readonly mode → cards and images display correctly
|
||||
7. Save → reload → everything persists
|
||||
|
||||
**Commit:** fix any issues found
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before (current) | After |
|
||||
|-------------------|-------|
|
||||
| File type whitelist blocks .mp4/.zip/etc | All files accepted (like Linear) |
|
||||
| Attachment Section below description | Gone — files live in markdown |
|
||||
| Non-image files as plain `[name](url)` text | Styled file card with icon + download |
|
||||
| Image removal tracker + attachment cache | Gone — simpler code |
|
||||
| ~300 lines of attachment UI code | Deleted |
|
||||
| ~100 lines of whitelist code | Replaced by 1 line: `MAX_FILE_SIZE` |
|
||||
452
docs/plans/2026-04-08-image-view-enhancement.md
Normal file
452
docs/plans/2026-04-08-image-view-enhancement.md
Normal 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 `` — 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 |
|
||||
489
docs/plans/2026-04-08-monorepo-extraction.md
Normal file
489
docs/plans/2026-04-08-monorepo-extraction.md
Normal 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 | 低 |
|
||||
1082
docs/plans/2026-04-09-desktop-app.md
Normal file
1082
docs/plans/2026-04-09-desktop-app.md
Normal file
File diff suppressed because it is too large
Load Diff
868
docs/plans/2026-04-09-monorepo-extraction.md
Normal file
868
docs/plans/2026-04-09-monorepo-extraction.md
Normal 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/WS,app 不需要写任何 platform 代码 |
|
||||
| Phase 2: Sidebar & Layout | 3-5 | 共享 AppSidebar + DashboardLayout,删除两端重复 |
|
||||
| Phase 3: Login | 6-7 | 共享 LoginPage + AuthInitializer |
|
||||
| Phase 4: Agents | 8-10 | 1,279 行 → 共享模块 |
|
||||
| Phase 5: Inbox | 11-13 | 468 行 → 共享模块 |
|
||||
| Phase 6: Settings | 14-16 | 1,277 行 → 共享模块 |
|
||||
| Phase 7: 清理 | 17-18 | 删除所有 platform 目录、placeholder、死代码 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core 自包含初始化
|
||||
|
||||
### 设计思路
|
||||
|
||||
现在每个 app 都要手动调用 `new ApiClient()`、`createAuthStore()`、`createWorkspaceStore()`、包 `<WSProvider>`。但这些逻辑在两个 app 里完全一样。
|
||||
|
||||
方案:`@multica/core` 导出一个 `<CoreProvider>` 包裹整个应用。它内部自动完成所有初始化。配置通过环境变量(`VITE_API_URL` / `NEXT_PUBLIC_API_URL`)或 prop 注入。SSR-safe 的 localStorage wrapper 内置到 core 里作为默认 storage(`typeof window` 守卫对 Electron 无害)。
|
||||
|
||||
```tsx
|
||||
// 任何 app 的根组件,只需要这样:
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? ""}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
onLogin={setLoggedInCookie} // 可选,Web 独有
|
||||
onLogout={clearLoggedInCookie} // 可选,Web 独有
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
Desktop 更简单(没有可选回调):
|
||||
```tsx
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
### Task 1: 在 `@multica/core` 里创建 CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/platform/storage.ts` — 内置 SSR-safe localStorage
|
||||
- Create: `packages/core/platform/core-provider.tsx` — CoreProvider 组件
|
||||
- Create: `packages/core/platform/auth-initializer.tsx` — 共享 AuthInitializer
|
||||
- Create: `packages/core/platform/types.ts` — CoreProviderProps
|
||||
- Create: `packages/core/platform/index.ts` — barrel export
|
||||
- Modify: `packages/core/package.json` — add `"./platform"` export
|
||||
|
||||
**Step 1: Create built-in SSR-safe storage**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/storage.ts
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
|
||||
export const defaultStorage: StorageAdapter = {
|
||||
getItem: (k) => (typeof window !== "undefined" ? localStorage.getItem(k) : null),
|
||||
setItem: (k, v) => { if (typeof window !== "undefined") localStorage.setItem(k, v); },
|
||||
removeItem: (k) => { if (typeof window !== "undefined") localStorage.removeItem(k); },
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Create types**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/types.ts
|
||||
export interface CoreProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** API base URL. Default: "" (same-origin). */
|
||||
apiBaseUrl?: string;
|
||||
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
|
||||
wsUrl?: string;
|
||||
/** Called after successful login (e.g. set cookie for Next.js middleware). */
|
||||
onLogin?: () => void;
|
||||
/** Called after logout (e.g. clear cookie). */
|
||||
onLogout?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create AuthInitializer**
|
||||
|
||||
Merge the identical logic from both apps. Uses `defaultStorage`, reads from existing singletons.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/auth-initializer.tsx
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
export function AuthInitializer({
|
||||
children,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getApi();
|
||||
api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Create CoreProvider**
|
||||
|
||||
This is the one component that wires everything together. Each app wraps its root with this.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/core-provider.tsx
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { AuthInitializer } from "./auth-initializer";
|
||||
import type { CoreProviderProps } from "./types";
|
||||
|
||||
// Module-level singletons — created once, shared across renders.
|
||||
let initialized = false;
|
||||
let authStore: ReturnType<typeof createAuthStore>;
|
||||
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
|
||||
|
||||
function initCore(apiBaseUrl: string) {
|
||||
if (initialized) return;
|
||||
|
||||
const api = new ApiClient(apiBaseUrl, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
},
|
||||
});
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate token from storage
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
|
||||
authStore = createAuthStore({ api, storage: defaultStorage });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, {
|
||||
storage: defaultStorage,
|
||||
});
|
||||
registerWorkspaceStore(workspaceStore);
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function CoreProvider({
|
||||
children,
|
||||
apiBaseUrl = "",
|
||||
wsUrl = "ws://localhost:8080/ws",
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render
|
||||
useMemo(() => initCore(apiBaseUrl), [apiBaseUrl]);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout}>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={defaultStorage}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
</AuthInitializer>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/index.ts
|
||||
export { CoreProvider } from "./core-provider";
|
||||
export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
```
|
||||
|
||||
Add to `packages/core/package.json` exports:
|
||||
```json
|
||||
"./platform": "./platform/index.ts"
|
||||
```
|
||||
|
||||
**Step 6: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/core/platform/ packages/core/package.json
|
||||
git commit -m "feat(core): add CoreProvider — single component for full app initialization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate both apps to CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/layout.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Modify: `apps/desktop/src/renderer/src/App.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Delete: `apps/web/platform/api.ts`
|
||||
- Delete: `apps/web/platform/auth.ts`
|
||||
- Delete: `apps/web/platform/workspace.ts`
|
||||
- Delete: `apps/web/platform/storage.ts`
|
||||
- Delete: `apps/web/platform/ws-provider.tsx`
|
||||
- Delete: `apps/web/features/auth/initializer.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/api.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/workspace.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/storage.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/ws-provider.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth-initializer.tsx`
|
||||
- Keep: `apps/web/platform/navigation.tsx` — NavigationAdapter (唯一不可共享)
|
||||
- Keep: `apps/desktop/src/renderer/src/platform/navigation.tsx` — NavigationAdapter
|
||||
- Keep: `apps/web/features/auth/auth-cookie.ts` — Web 独有
|
||||
|
||||
**Step 1: Update web root layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/layout.tsx
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
<WebNavigationProvider>
|
||||
{children}
|
||||
</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update desktop App.tsx**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/App.tsx
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { router } from "./router";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL}
|
||||
wsUrl={import.meta.env.VITE_WS_URL}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Fix all `@/platform/*` imports across both apps**
|
||||
|
||||
Search all files for:
|
||||
- `from "@/platform/api"` → `from "@multica/core/api"` (use singleton proxy `api`)
|
||||
- `from "@/platform/auth"` → `from "@multica/core/auth"` (use singleton `useAuthStore`)
|
||||
- `from "@/platform/workspace"` → `from "@multica/core/workspace"` (use singleton `useWorkspaceStore`)
|
||||
|
||||
These singletons already exist and are registered by CoreProvider on init. Every component can import them directly from core.
|
||||
|
||||
**Step 4: Delete all platform files except navigation**
|
||||
|
||||
Web — delete entire `apps/web/platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/web/platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
Desktop — delete entire `apps/desktop/.../platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/desktop/.../platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
**Step 5: Run typecheck + tests**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: migrate both apps to CoreProvider — delete all platform duplication"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Sidebar & Layout
|
||||
|
||||
### Task 3: Extract `AppSidebar` to `@multica/views/layout`
|
||||
|
||||
**Why:** Web and Desktop sidebars are 99% identical (239 vs 236 lines). Only difference: `Link`/`usePathname`/`useRouter` (web) vs `AppLink`/`useNavigation` (desktop). Since `useNavigation` + `AppLink` is the abstraction in views, the desktop version is already the correct shared version.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/app-sidebar.tsx` — copy from desktop version
|
||||
- Create: `packages/views/layout/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./layout"` export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` — import from views
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` — import from views
|
||||
- Delete: `apps/web/app/(dashboard)/_components/app-sidebar.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/components/app-sidebar.tsx`
|
||||
|
||||
**Step 1: Create shared AppSidebar**
|
||||
|
||||
Copy desktop `app-sidebar.tsx` into `packages/views/layout/app-sidebar.tsx`. Key changes:
|
||||
- `import { useAuthStore } from "@multica/core/auth"` (singleton)
|
||||
- `import { useWorkspaceStore } from "@multica/core/workspace"` (singleton)
|
||||
- `import { api } from "@multica/core/api"` (singleton proxy)
|
||||
- `import { useNavigation, AppLink } from "../navigation"` (relative within views)
|
||||
- `import { useModalStore } from "@multica/core/modals"`
|
||||
- All `@multica/ui` imports unchanged
|
||||
|
||||
**Step 2: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/index.ts
|
||||
export { AppSidebar } from "./app-sidebar";
|
||||
```
|
||||
|
||||
Add to `packages/views/package.json`:
|
||||
```json
|
||||
"./layout": "./layout/index.ts"
|
||||
```
|
||||
|
||||
**Step 3: Update both apps, delete old files**
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared AppSidebar to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract `DashboardLayout` to `@multica/views/layout`
|
||||
|
||||
**Why:** Both apps have identical dashboard shell: auth guard → loading → sidebar + workspace provider + content. Only differences: web has `SearchCommand`, desktop has `TitleBar`. These are slots.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/dashboard-layout.tsx`
|
||||
- Modify: `packages/views/layout/index.ts` (add export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` (~10 lines after)
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared DashboardLayout**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/dashboard-layout.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { ModalRegistry } from "../modals/registry";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
/** Above sidebar (e.g. desktop TitleBar) */
|
||||
header?: ReactNode;
|
||||
/** Sibling of SidebarInset (e.g. web SearchCommand) */
|
||||
extra?: ReactNode;
|
||||
/** Loading indicator */
|
||||
loadingIndicator?: ReactNode;
|
||||
/** Redirect path when not authenticated. Default: "/" */
|
||||
loginPath?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
children, header, extra, loadingIndicator, loginPath = "/",
|
||||
}: DashboardLayoutProps) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) push(loginPath);
|
||||
}, [user, isLoading, push, loginPath]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<SidebarProvider className="flex-1">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{workspace ? (
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</WorkspaceIdProvider>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
{extra}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Slim down web layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/layout.tsx
|
||||
"use client";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
extra={<SearchCommand />}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Slim down desktop shell**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/components/dashboard-shell.tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { TitleBar } from "./title-bar";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
export function DashboardShell() {
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<DashboardLayout
|
||||
header={<TitleBar />}
|
||||
loginPath="/login"
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardLayout>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared DashboardLayout to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Build + smoke test
|
||||
|
||||
Run: `pnpm build && make check`
|
||||
|
||||
Fix any issues, commit:
|
||||
```bash
|
||||
git commit -m "fix: fixups from layout extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Shared Login Page
|
||||
|
||||
### Task 6: Extract `LoginPage` to `@multica/views/auth`
|
||||
|
||||
**Why:** Desktop login (139 lines) is a simple email/code form. Web login (393 lines) has extra: CLI callback, Google OAuth, OTP component. Strategy: extract the core email/code form to views. Desktop uses it directly. Web keeps its own richer version (too different to merge).
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/auth/login-page.tsx`
|
||||
- Create: `packages/views/auth/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./auth"` export)
|
||||
- Modify: `apps/desktop/src/renderer/src/pages/login.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared LoginPage**
|
||||
|
||||
Props: `logo?: ReactNode`, `onSuccess: () => void`. Internally uses `useAuthStore`/`useWorkspaceStore`/`api` from core singletons.
|
||||
|
||||
**Step 2: Update desktop login**
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "../components/multica-icon";
|
||||
import { TitleBar } from "../components/title-bar";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<TitleBar />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => navigate("/issues", { replace: true })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Web login stays as-is (CLI callback + Google OAuth = web-only features).
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract shared LoginPage to @multica/views/auth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verify login flow in both apps
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Extract Agents Page (1,279 lines → shared module)
|
||||
|
||||
### Task 8: Create `@multica/views/agents`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/agents/config.ts` — statusConfig, taskStatusConfig
|
||||
- Create: `packages/views/agents/components/agents-page.tsx` — main page
|
||||
- Create: `packages/views/agents/components/create-agent-dialog.tsx`
|
||||
- Create: `packages/views/agents/components/agent-list-item.tsx`
|
||||
- Create: `packages/views/agents/components/agent-detail.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/instructions-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/skills-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/tasks-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/settings-tab.tsx`
|
||||
- Create: `packages/views/agents/components/index.ts`
|
||||
- Create: `packages/views/agents/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./agents"` export)
|
||||
|
||||
**Key migration:** All `@/platform/*` imports → `@multica/core/*` singletons. All `@multica/ui` and `@multica/core` imports stay as-is. `@multica/views` imports become relative.
|
||||
|
||||
**Step 1:** Extract config → components → barrel
|
||||
**Step 2:** Run `pnpm typecheck`
|
||||
**Step 3:** Commit
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract agents page to @multica/views/agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire web agents route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/agents/page.tsx — 1 line replaces 1,279
|
||||
export { AgentsPage as default } from "@multica/views/agents";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace agents page with @multica/views/agents import`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Wire desktop agents route
|
||||
|
||||
```typescript
|
||||
// router.tsx
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
{ path: "agents", element: <AgentsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire agents page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Extract Inbox Page (468 lines → shared module)
|
||||
|
||||
### Task 11: Create `@multica/views/inbox`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/inbox/components/inbox-page.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-list-item.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-detail-label.tsx`
|
||||
- Create: `packages/views/inbox/components/index.ts`
|
||||
- Create: `packages/views/inbox/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./inbox"` export)
|
||||
|
||||
**Key migration:**
|
||||
- `import { useSearchParams } from "next/navigation"` → `import { useNavigation } from "../navigation"` — use `searchParams` from adapter
|
||||
- `window.history.replaceState(null, "", url)` → `replace(url)` from `useNavigation()`
|
||||
- `@/platform/*` → `@multica/core/*` singletons
|
||||
|
||||
Commit: `feat(views): extract inbox page to @multica/views/inbox`
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Wire web inbox route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/inbox/page.tsx — 1 line replaces 468
|
||||
export { InboxPage as default } from "@multica/views/inbox";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace inbox page with @multica/views/inbox import`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Wire desktop inbox route
|
||||
|
||||
```typescript
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
{ path: "inbox", element: <InboxPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire inbox page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Extract Settings Page (1,277 lines → shared module)
|
||||
|
||||
### Task 14: Create `@multica/views/settings`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/settings/components/settings-page.tsx`
|
||||
- Create: `packages/views/settings/components/account-tab.tsx`
|
||||
- Create: `packages/views/settings/components/appearance-tab.tsx`
|
||||
- Create: `packages/views/settings/components/tokens-tab.tsx`
|
||||
- Create: `packages/views/settings/components/workspace-tab.tsx`
|
||||
- Create: `packages/views/settings/components/members-tab.tsx`
|
||||
- Create: `packages/views/settings/components/repositories-tab.tsx`
|
||||
- Create: `packages/views/settings/components/index.ts`
|
||||
- Create: `packages/views/settings/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./settings"` export)
|
||||
|
||||
**Key migration:** Same pattern — `@/platform/*` → `@multica/core/*` singletons.
|
||||
|
||||
Commit: `feat(views): extract settings page to @multica/views/settings`
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Wire web settings route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/settings/page.tsx — 1 line replaces 1,277 (page + 6 tabs)
|
||||
export { SettingsPage as default } from "@multica/views/settings";
|
||||
```
|
||||
|
||||
Delete `apps/web/app/(dashboard)/settings/_components/` (all 6 files).
|
||||
|
||||
Commit: `refactor(web): replace settings page with @multica/views/settings import`
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Wire desktop settings route
|
||||
|
||||
```typescript
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
{ path: "settings", element: <SettingsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire settings page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Cleanup
|
||||
|
||||
### Task 17: Delete dead code
|
||||
|
||||
- Delete `apps/desktop/src/renderer/src/pages/placeholder.tsx`
|
||||
- Delete `apps/web/platform/` directory entirely (only `navigation.tsx` remains — move to `apps/web/app/` or `apps/web/lib/`)
|
||||
- Delete `apps/desktop/src/renderer/src/platform/` directory (only `navigation.tsx` remains — move)
|
||||
- Remove unused imports across both apps
|
||||
- Clean up `apps/web/features/auth/` — only `auth-cookie.ts` should remain
|
||||
|
||||
Commit: `chore: delete dead platform code after monorepo extraction`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Full verification
|
||||
|
||||
Run: `make check`
|
||||
Expected: ALL PASS
|
||||
|
||||
---
|
||||
|
||||
## Final Architecture
|
||||
|
||||
### Each app after extraction
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/
|
||||
│ ├── layout.tsx # CoreProvider + WebNavigationProvider + ThemeProvider
|
||||
│ ├── (auth)/login/page.tsx # Web 独有:CLI callback, Google OAuth
|
||||
│ ├── (dashboard)/
|
||||
│ │ ├── layout.tsx # DashboardLayout + SearchCommand (10 行)
|
||||
│ │ ├── issues/page.tsx # 1 行 re-export
|
||||
│ │ ├── agents/page.tsx # 1 行 re-export
|
||||
│ │ ├── inbox/page.tsx # 1 行 re-export
|
||||
│ │ ├── settings/page.tsx # 1 行 re-export
|
||||
│ │ └── ... (all 1-line)
|
||||
│ └── (landing)/ # Web 独有
|
||||
├── lib/
|
||||
│ └── navigation.tsx # WebNavigationProvider(唯一平台代码)
|
||||
├── features/
|
||||
│ ├── auth/auth-cookie.ts # Web 独有
|
||||
│ ├── landing/ # Web 独有
|
||||
│ └── search/ # Web 独有
|
||||
└── components/ # theme, icon, loading (少量)
|
||||
|
||||
apps/desktop/
|
||||
├── src/main/ # Electron 主进程
|
||||
├── src/preload/ # preload bridge
|
||||
├── src/renderer/src/
|
||||
│ ├── App.tsx # CoreProvider + RouterProvider + ThemeProvider
|
||||
│ ├── router.tsx # 路由表(全部 @multica/views/*)
|
||||
│ ├── lib/
|
||||
│ │ └── navigation.tsx # DesktopNavigationProvider(唯一平台代码)
|
||||
│ ├── components/
|
||||
│ │ ├── dashboard-shell.tsx # DashboardLayout + TitleBar (10 行)
|
||||
│ │ ├── title-bar.tsx # Desktop 独有
|
||||
│ │ └── multica-icon.tsx # Desktop 独有
|
||||
│ └── pages/
|
||||
│ └── login.tsx # LoginPage + TitleBar (10 行)
|
||||
```
|
||||
|
||||
### 数字对比
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| Web platform 文件 | 6 个 | 1 个 (navigation.tsx) |
|
||||
| Desktop platform 文件 | 7 个 | 1 个 (navigation.tsx) |
|
||||
| Web agents/page.tsx | 1,279 行 | 1 行 |
|
||||
| Web inbox/page.tsx | 468 行 | 1 行 |
|
||||
| Web settings/ 总计 | 1,277 行 | 1 行 |
|
||||
| Web sidebar | 239 行 | 0 (共享) |
|
||||
| Desktop sidebar | 236 行 (重复) | 0 (共享) |
|
||||
| Desktop placeholders | 3 个 | 0 |
|
||||
| 共享 views 模块 | 7 个 | 12 个 |
|
||||
| 两端重复代码 | ~1,500 行 | 0 行 |
|
||||
319
docs/plans/2026-04-09-upload-attachment-fixes.md
Normal file
319
docs/plans/2026-04-09-upload-attachment-fixes.md
Normal 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.
|
||||
1819
docs/plans/2026-04-15-workspace-slug-url-refactor.md
Normal file
1819
docs/plans/2026-04-15-workspace-slug-url-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
1315
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
Normal file
1315
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
Normal file
File diff suppressed because it is too large
Load Diff
357
docs/plans/2026-04-16-unify-workspace-identity-resolver.md
Normal file
357
docs/plans/2026-04-16-unify-workspace-identity-resolver.md
Normal 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.
|
||||
109
docs/workspace-url-refactor-proposal.md
Normal file
109
docs/workspace-url-refactor-proposal.md
Normal 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` 协议 — 只存 UUID,workspace-agnostic
|
||||
- CLI 登录 URL — `/login` 也是 pre-workspace,不需要 slug
|
||||
- 后端 API 路径 — 保持 `/api/workspaces/{id}`,slug 仅用于前端 URL
|
||||
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
**核心原则**:URL 是 workspace 上下文的唯一 source of truth,其他状态都是派生态。
|
||||
|
||||
**URL 形状**:`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
|
||||
|
||||
**切换 workspace = 导航**:sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
|
||||
|
||||
**预估影响面**:~30-35 个文件,其中约 20 个是机械替换(hardcoded 路径 → path builder),真正需要思考的核心逻辑改动集中在 5-6 个文件。
|
||||
|
||||
**一个 PR 合并**:中间状态不可运行(URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
|
||||
|
||||
## 四、执行与测试计划
|
||||
|
||||
### 执行阶段
|
||||
|
||||
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
|
||||
2. **下一步**:在独立 worktree 上开发,AI 辅助写代码,过程中人工 review
|
||||
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E)
|
||||
|
||||
### 测试阶段
|
||||
|
||||
1. **本地自测**:
|
||||
- 已知功能路径(创建 / 浏览 / 搜索 issue,切换 workspace,接受邀请,分享链接)
|
||||
- 已知 bug 场景(MUL-43 / MUL-509 / MUL-727 / MUL-820)逐一验证已修复
|
||||
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
|
||||
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
|
||||
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程)
|
||||
- URL 使用感受(分享、收藏、刷新)
|
||||
3. **灰度 / 生产**:测试环境稳定后推生产
|
||||
|
||||
### 风险提示
|
||||
|
||||
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404(产品还没正式 ship、用户量可忽略,所以不做兼容性重定向)
|
||||
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
|
||||
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate(可能性极低,因 slug 限制较严)
|
||||
|
||||
## 五、附注
|
||||
|
||||
这次重构会**顺带修掉**以下已登记 issue,不需要单独开 PR:
|
||||
|
||||
| Issue | 修复方式 |
|
||||
|---|---|
|
||||
| MUL-43(切换 workspace 报错 / 分享链接失效) | URL 带 slug,根本解决 |
|
||||
| MUL-509(手机端无法切 workspace) | 切换变导航,手机能点链接就能切 |
|
||||
| MUL-723(workspace 不在 URL) | 核心目标 |
|
||||
| MUL-727(创建 workspace 闪 /issues) | 删除 mutation 里的 switchWorkspace 副作用 |
|
||||
| MUL-728(删除 workspace 后留在 /settings) | 删除成功后 navigate 到下一个 workspace |
|
||||
| MUL-820(sidebar Join 不切 workspace) | Join 改成跳转到 `/invite/{id}` 走统一路径 |
|
||||
|
||||
不在本次范围内的:Issue #951(WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
|
||||
|
||||
---
|
||||
|
||||
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。
|
||||
@@ -6,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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./mutations";
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type {
|
||||
OnboardingStep,
|
||||
OnboardingCompletionPath,
|
||||
QuestionnaireAnswers,
|
||||
TeamSize,
|
||||
Role,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -54,7 +54,6 @@ export type WSEventType =
|
||||
| "project:deleted"
|
||||
| "pin:created"
|
||||
| "pin:deleted"
|
||||
| "pin:reordered"
|
||||
| "invitation:created"
|
||||
| "invitation:accepted"
|
||||
| "invitation:declined"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user