mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 20:58:56 +02:00
Compare commits
5 Commits
v0.2.14
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2f89062d | ||
|
|
f6dd47c944 | ||
|
|
f98a67dd90 | ||
|
|
90ccd97469 | ||
|
|
180a534511 |
231
.github/workflows/release.yml
vendored
231
.github/workflows/release.yml
vendored
@@ -78,18 +78,38 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
docker-images:
|
||||
# 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
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-docker-images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
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: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- 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
|
||||
@@ -101,8 +121,55 @@ jobs:
|
||||
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_backend
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-backend
|
||||
@@ -112,28 +179,114 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
|
||||
type=raw,value=${{ needs.verify.outputs.tag_name }}
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Backend
|
||||
org.opencontainers.image.description=Multica self-hosted backend
|
||||
|
||||
- name: Build and push backend image
|
||||
- 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
|
||||
file: Dockerfile.web
|
||||
pull: true
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.meta_backend.outputs.labels }}
|
||||
tags: ${{ steps.meta_backend.outputs.tags }}
|
||||
cache-from: type=gha,scope=release-backend
|
||||
cache-to: type=gha,mode=max,scope=release-backend
|
||||
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: |
|
||||
VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
COMMIT=${{ github.sha }}
|
||||
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_web
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-web
|
||||
@@ -143,25 +296,25 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
|
||||
type=raw,value=${{ needs.verify.outputs.tag_name }}
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Web
|
||||
org.opencontainers.image.description=Multica self-hosted web frontend
|
||||
|
||||
- name: Build and push web image
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
pull: true
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.meta_web.outputs.labels }}
|
||||
tags: ${{ steps.meta_web.outputs.tags }}
|
||||
cache-from: type=gha,scope=release-web
|
||||
cache-to: type=gha,mode=max,scope=release-web
|
||||
build-args: |
|
||||
REMOTE_API_URL=http://backend:8080
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
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
|
||||
|
||||
71
.vercelignore
Normal file
71
.vercelignore
Normal file
@@ -0,0 +1,71 @@
|
||||
# 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
|
||||
|
||||
!.env.example
|
||||
|
||||
.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
|
||||
scripts
|
||||
skills-lock.json
|
||||
|
||||
.github
|
||||
docker
|
||||
docs
|
||||
e2e
|
||||
server
|
||||
apps/desktop
|
||||
|
||||
*.log
|
||||
*.pid
|
||||
*.tsbuildinfo
|
||||
|
||||
.cache
|
||||
.next
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.vercel
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
data
|
||||
|
||||
node_modules
|
||||
bin
|
||||
dist
|
||||
out
|
||||
build
|
||||
dist-electron
|
||||
|
||||
*.app
|
||||
*.dmg
|
||||
@@ -123,15 +123,21 @@ function AppContent() {
|
||||
// warning because `switchWorkspace` is a Zustand setState that the
|
||||
// TabBar is subscribed to. useLayoutEffect flushes both renders before
|
||||
// the user sees anything, so there's no visible flicker.
|
||||
//
|
||||
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
|
||||
// the first fetch, so without this guard we'd run validation against an
|
||||
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
|
||||
// back to `workspaces[0]` once the real list arrives — losing the user's
|
||||
// last-opened workspace on every app start.
|
||||
useLayoutEffect(() => {
|
||||
if (!workspaces) return;
|
||||
if (!workspaceListFetched) return;
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
const tabStore = useTabStore.getState();
|
||||
tabStore.validateWorkspaceSlugs(validSlugs);
|
||||
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
|
||||
tabStore.switchWorkspace(workspaces[0].slug);
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
|
||||
if (!activeWorkspaceSlug && workspaces.length > 0) {
|
||||
switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [workspaces]);
|
||||
}, [workspaces, workspaceListFetched]);
|
||||
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
# Desktop 下载体系 — 文案定位(Step 1 产出)
|
||||
|
||||
**目的**:为 `/download` 页面、onboarding、login、landing 所有 Desktop/CLI/Cloud 相关触点提供**唯一文案真相源**。后续 Step 2/3/4 实现时,UI 层只从这里拿文案,不临时发明。
|
||||
|
||||
**双语策略**:遵循当前项目 i18n 现状——
|
||||
- **Landing / `/download` / Web Login**:i18n 双语(en + zh),接入 `apps/web/features/landing/i18n/`
|
||||
- **Onboarding(共享 views 包)**:保持英文单语(当前现状,i18n 基建本次不引入)
|
||||
|
||||
---
|
||||
|
||||
## 一、三个 surface 的核心定位句
|
||||
|
||||
写 UI 时所有文案都派生自这三句。每一句都**以场景开头**,不以能力比较开头。
|
||||
|
||||
| Surface | EN | ZH |
|
||||
|---|---|---|
|
||||
| **Desktop** | Install the app. Agents run on your machine. | 下载桌面应用,agent 在你的电脑上运行。 |
|
||||
| **CLI** | For servers, remote dev boxes, and automation. | 适合服务器、远程开发机、自动化场景。 |
|
||||
| **Cloud** | We host the runtime. No local install. | 我们为你托管 runtime,无需本地安装。 |
|
||||
|
||||
### 一句话决策树(用户视角)
|
||||
|
||||
- "我就是想在自己电脑上用" → Desktop
|
||||
- "我想让 agent 跑在我的服务器 / 远程机器上" → CLI
|
||||
- "我一点都不想装东西" → Cloud(目前是 waitlist)
|
||||
|
||||
---
|
||||
|
||||
## 二、文案设计原则
|
||||
|
||||
| 原则 | 理由 | 例子 |
|
||||
|---|---|---|
|
||||
| 场景先于能力 | Desktop 和 CLI 运行后能力等价,差异在 setup moment 和使用场景 | ✅ "For servers and remote boxes" / ❌ "Lighter-weight Desktop" |
|
||||
| 避免"easy / simple / just" | 这些是 claim,用户不信;且会和现实冲突(CLI 的 `multica setup` 实际 10-30s) | ✅ "Terminal setup" / ❌ "Just one command" |
|
||||
| 诚实时间估计 | Welcome 当前 "Takes about 3 minutes" 对 web 用户是谎 | ✅ 差异化文案或去掉时间 |
|
||||
| 第二人称 + 直接语气 | 和 Linear / Cursor 一致 | ✅ "Agents run on your Mac." / ❌ "Our runtime operates locally." |
|
||||
| 不夸"强大"/"智能" | 现代用户免疫 marketing 形容词 | ✅ "Agents pick up tasks." / ❌ "Powerful AI agents tackle your work." |
|
||||
|
||||
---
|
||||
|
||||
## 三、触点文案对照表
|
||||
|
||||
### 3.1 Landing Hero(web only)
|
||||
|
||||
**位置**:`apps/web/features/landing/components/landing-hero.tsx:44-65` + `landing/i18n/en.ts:19` + `zh.ts:19`
|
||||
|
||||
**当前**:
|
||||
- EN: `"Download Desktop"` (ghost 按钮)
|
||||
- ZH: `"下载桌面端"` (ghost 按钮)
|
||||
- href: `https://github.com/multica-ai/multica/releases/latest`
|
||||
|
||||
**新**:
|
||||
- EN: `"Download Desktop"` ← 文案不变,**视觉升级为 primary/solid** + **href 改为 `/download`**
|
||||
- ZH: `"下载桌面端"` ← 同上
|
||||
- i18n key:复用现有 `hero.downloadDesktop`,**不新增 key**
|
||||
|
||||
**理由**:
|
||||
- 文案已经合适
|
||||
- 改动只在视觉权重(ghost → solid)和链接目标(GitHub releases → `/download`)
|
||||
- href 变更落在 `landing-hero.tsx:45` 的 hardcoded URL——改成相对路径 `/download`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Landing Nav / Footer(web only)
|
||||
|
||||
**位置**:`landing/i18n/en.ts:230` + `zh.ts:230`
|
||||
|
||||
**当前**:
|
||||
```ts
|
||||
{ label: "Desktop" / "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" }
|
||||
```
|
||||
|
||||
**新**:
|
||||
```ts
|
||||
{ label: "Download" / "下载", href: "/download" }
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 把"Desktop"改成"Download"——`/download` 页面本身就是三个选项的聚合(Desktop/CLI/Cloud),不只是 desktop
|
||||
- href 统一到 `/download`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Web Login Page — 新增 Desktop CTA
|
||||
|
||||
**位置**:`apps/web/app/(auth)/login/page.tsx` 调用 `LoginPage`(`packages/views/auth/login-page.tsx`)时注入新 prop `extra`
|
||||
|
||||
**当前**:无此 UI
|
||||
|
||||
**新**(Google 按钮下方低调一行):
|
||||
- EN: `"Prefer the desktop app? Download →"`
|
||||
- ZH: `"想用桌面应用?下载 →"`
|
||||
|
||||
**i18n**:新增 key
|
||||
```ts
|
||||
auth: {
|
||||
login: {
|
||||
extraDownloadPrompt: "Prefer the desktop app?" / "想用桌面应用?",
|
||||
extraDownloadCta: "Download" / "下载",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 登录页是**最轻投入时刻**,推 Desktop 最便宜
|
||||
- 不强推,低调一行,不影响 Google OAuth 主流
|
||||
- Desktop app 的 Login Page(`apps/desktop/src/renderer/src/pages/login.tsx`)**不传** `extra` → 不显示。这条 CTA 只在 web 出现。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Welcome 屏 — web 分支新增 Desktop 引导
|
||||
|
||||
**位置**:`packages/views/onboarding/steps/step-welcome.tsx`(单语英文)
|
||||
|
||||
**当前**(所有平台):
|
||||
```
|
||||
Takes about 3 minutes. You'll end with a real agent
|
||||
replying to a real issue.
|
||||
```
|
||||
按钮:`Start exploring` + (optional) `I've done this before`
|
||||
|
||||
**新**(web 分支,`isWeb=true`):
|
||||
- 上方文字保留 `"Your AI teammates, in one workspace."` 标题
|
||||
- 副文案改为:`"About 3 minutes on desktop. A bit more on web — you'll need a local runtime."`
|
||||
- 按钮区追加第三个按钮(视觉权重:primary > secondary ghost):
|
||||
- Primary: `"Start exploring"` (保留,引导继续 web 流程)
|
||||
- **新增 secondary**: `"Download Desktop — faster setup"`(指向 `/download`,新窗口打开)
|
||||
- Ghost: `"I've done this before"` (保留条件)
|
||||
|
||||
**新**(desktop 分支,`isWeb=false`):
|
||||
- 文案完全保持:`"Takes about 3 minutes. You'll end with a real agent replying to a real issue."`
|
||||
- 按钮不变
|
||||
|
||||
**理由**:
|
||||
- 首次向 web 用户承认"desktop 更顺"——早于任何投入
|
||||
- Primary CTA 仍是"Start exploring"——不强推 desktop,只是让它可见
|
||||
- 3 minutes 文案按平台差异化——对 desktop 用户诚实,对 web 用户不再骗
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Step 3 Platform Fork — Desktop 卡
|
||||
|
||||
**位置**:`packages/views/onboarding/steps/step-platform-fork.tsx:232-299`(`ForkPrimary` 组件)
|
||||
|
||||
**当前(Mac detect 命中)**:
|
||||
- 标题:`"Download the desktop app"`
|
||||
- 副文案:`"macOS · runtime bundled — detects your tools automatically, nothing to install."`
|
||||
|
||||
**当前(非 Mac)**:
|
||||
- 标题:`"Desktop app — macOS only for now"` (disabled card)
|
||||
- 副文案:`"Windows and Linux builds are on the way. In the meantime, install the CLI below — it takes about two minutes."`
|
||||
|
||||
**新(所有平台,按 detect 结果适配)**:
|
||||
- 标题:`"Download the desktop app"`
|
||||
- 副文案(按 detect 分支):
|
||||
- macOS arm64: `"macOS (Apple Silicon) · bundled daemon, zero setup."`
|
||||
- macOS Intel/unknown: `"macOS · bundled daemon, zero setup."` + 小字 `"Apple Silicon only — on Intel? Use CLI."`
|
||||
- Windows: `"Windows · bundled daemon, zero setup."`
|
||||
- Linux: `"Linux · bundled daemon, zero setup."`
|
||||
- 非 Mac 检测不到: `"Bundled daemon, zero setup."`
|
||||
- 按钮 pill:`"Download"`(不变)
|
||||
|
||||
**理由**:
|
||||
- 拆掉 `isMac` 门——Windows/Linux 包已经齐
|
||||
- 副文案主打**"zero setup"**——这才是和 CLI 的真差异
|
||||
- Intel Mac 诚实提示,不骗他们点 arm64 包
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Step 3 Platform Fork — CLI 卡
|
||||
|
||||
**位置**:同上,`ForkAlt` 调用
|
||||
|
||||
**当前**:
|
||||
- 标题:`"Install the CLI"`
|
||||
- 副文案:`"Run the Multica daemon yourself — a couple of terminal commands."`
|
||||
- 按钮:`"Show steps"`
|
||||
|
||||
**新**:
|
||||
- 标题:`"Install the CLI"` (不变)
|
||||
- 副文案:`"For servers, remote dev boxes, and headless setups. Terminal required."`
|
||||
- 按钮:`"Show steps"` (不变)
|
||||
|
||||
**理由**:
|
||||
- 副文案从"自己跑 daemon"改为"服务器 / 远程 / headless"——让 CLI 归位到它真正的场景
|
||||
- 加 "Terminal required" 给用户明确预期,不伪装成轻量路径
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Step 3 Platform Fork — Cloud 卡
|
||||
|
||||
**位置**:同上,`ForkAlt` 调用
|
||||
|
||||
**当前**:
|
||||
- 标题:`"Cloud runtime"`
|
||||
- 副文案:`"We host it for you. Not live yet — leave your email and we'll let you know."`
|
||||
- 按钮:`"Join waitlist"` / `"On the list"`
|
||||
|
||||
**新**:
|
||||
- 标题:`"Cloud runtime"` (不变)
|
||||
- 副文案:`"We host the runtime. Not live yet — join the waitlist."`
|
||||
- 按钮不变
|
||||
|
||||
**理由**:
|
||||
- 微调,对齐定位句
|
||||
- 不再把 "we'll let you know" 说得像客户支持
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Step 3 Footer Hint
|
||||
|
||||
**位置**:`step-platform-fork.tsx:101-112`
|
||||
|
||||
**当前 non-Mac**:`"Install the CLI to connect a runtime, or skip for now."`
|
||||
|
||||
**新(去掉 non-Mac 分支,因为 Desktop 对所有平台 active)**:
|
||||
```ts
|
||||
if (waitlistSubmitted) return "You're on the waitlist — pick Skip to keep exploring.";
|
||||
if (downloaded) return "Downloading… finish setup in the desktop app, or pick another path.";
|
||||
return "Pick a path above — or skip and configure a runtime later.";
|
||||
```
|
||||
|
||||
**理由**:删掉 non-Mac 专属分支,现在所有平台 Desktop 都可用。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 CLI Install Dialog — Title + Description
|
||||
|
||||
**位置**:`step-platform-fork.tsx:378-384`
|
||||
|
||||
**当前**:
|
||||
```
|
||||
Title: "Install the CLI"
|
||||
Description: "Runs the same daemon the desktop app bundles — you install it yourself."
|
||||
```
|
||||
|
||||
**新**:
|
||||
```
|
||||
Title: "Install the CLI"
|
||||
Description: "Same daemon, installed on your terminal. Use it when Desktop doesn't fit — servers, remote dev boxes, or headless setups."
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 明确 CLI 和 Desktop 是**同一个 daemon**——消除"CLI 是否弱化版 Desktop"的误解
|
||||
- 直接说 CLI 的正当场景——当 Desktop 不适合时
|
||||
|
||||
---
|
||||
|
||||
### 3.10 CliInstallInstructions — 头部提示
|
||||
|
||||
**位置**:`packages/views/onboarding/steps/cli-install-instructions.tsx:65-68`
|
||||
|
||||
**当前**:
|
||||
```
|
||||
You'll need a local AI coding tool (Claude Code, Codex,
|
||||
Cursor, …) installed for the runtime to do real work.
|
||||
```
|
||||
|
||||
**新**:
|
||||
```
|
||||
You'll need an AI coding tool on this machine (Claude Code,
|
||||
Codex, Cursor, …) for the daemon to do real work. Also works
|
||||
on servers and remote dev boxes.
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 最后一句点出 CLI 的远程场景——和 Step 3 CLI 卡的副文案呼应
|
||||
|
||||
---
|
||||
|
||||
### 3.11 CLI Dialog Waiting — "Stalled" 文案
|
||||
|
||||
**位置**:`step-platform-fork.tsx:552-561`
|
||||
|
||||
**当前**:
|
||||
```
|
||||
Nothing coming through yet. Close this dialog and try another
|
||||
path on the previous screen — Skip for now (in the footer)
|
||||
enters your workspace in read-only mode, or the Cloud runtime
|
||||
card lets you join the waitlist.
|
||||
```
|
||||
|
||||
**新**:
|
||||
```
|
||||
Nothing coming through yet. If you're not comfortable with the
|
||||
terminal, Desktop is the smoother path — it bundles the daemon.
|
||||
Close this dialog and pick Desktop, or hit Skip to continue.
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 在 stall 发生时主动把 Desktop 作为退路——这是用户最需要听到的
|
||||
- 原文案把 Cloud waitlist 作为退路不合理(那是 soft exit,不解决问题)
|
||||
|
||||
---
|
||||
|
||||
### 3.12 `/download` 页面(全新,i18n 双语)
|
||||
|
||||
**位置**:`apps/web/app/(landing)/download/page.tsx`(新建)
|
||||
|
||||
**页面结构 + 文案**:
|
||||
|
||||
#### Hero 区(顶部主 CTA,按 detect 结果拼出)
|
||||
|
||||
**检测到 macOS arm64**:
|
||||
- EN:
|
||||
- H1: `"Multica for macOS"`
|
||||
- Sub: `"Apple Silicon · bundled daemon, zero setup"`
|
||||
- Primary button: `"Download (.dmg)"` → macArm64Dmg
|
||||
- Alt link: `"or download .zip"` → macArm64Zip
|
||||
- ZH:
|
||||
- H1: `"Multica for macOS"`
|
||||
- Sub: `"Apple Silicon · 内置 daemon,无需额外配置"`
|
||||
- Primary: `"下载 (.dmg)"`
|
||||
- Alt: `"或下载 .zip"`
|
||||
|
||||
**检测到 macOS Intel(Chromium)**:
|
||||
- EN:
|
||||
- H1: `"Multica for macOS"`
|
||||
- Sub: `"Apple Silicon required — Intel Macs not yet supported."`
|
||||
- Primary button 样式: **muted + disabled**,文案 `"Apple Silicon required"`
|
||||
- 次要段落:`"On an Intel Mac? Use the CLI below — it runs the same daemon."`
|
||||
- ZH:对应翻译
|
||||
|
||||
**检测到 Windows x64**:
|
||||
- EN:
|
||||
- H1: `"Multica for Windows"`
|
||||
- Sub: `"Bundled daemon, zero setup"`
|
||||
- Primary: `"Download (.exe)"` → winX64Exe
|
||||
- ZH: `"Multica for Windows"` / `"内置 daemon,无需额外配置"` / `"下载 (.exe)"`
|
||||
|
||||
**检测到 Linux**:
|
||||
- EN:
|
||||
- H1: `"Multica for Linux"`
|
||||
- Primary: `"Download AppImage"` → linuxAmd64AppImage
|
||||
- Alt links: `"or .deb / .rpm"`
|
||||
- ZH: 对应翻译
|
||||
|
||||
**未检测 / SSR 初始状态**:
|
||||
- 默认渲染 macOS arm64 作为 H1(占位);JS hydration 后按 detect 替换
|
||||
|
||||
#### All Platforms 区(永远可见,在 Hero 下方)
|
||||
|
||||
**标题**:
|
||||
- EN: `"All platforms"`
|
||||
- ZH: `"所有平台"`
|
||||
|
||||
**内容**:表格或卡片,每行一个包:
|
||||
```
|
||||
macOS · Apple Silicon (.dmg / .zip)
|
||||
Windows · x64 (.exe) · ARM64 (.exe)
|
||||
Linux · x64 (.AppImage / .deb / .rpm) · ARM64 (.AppImage / .deb / .rpm)
|
||||
```
|
||||
|
||||
**Intel Mac 说明**:
|
||||
- EN: `"Apple Silicon only — Intel Macs not supported in this release."`
|
||||
- ZH: `"仅支持 Apple Silicon——Intel Mac 目前暂不支持。"`
|
||||
|
||||
#### CLI 区(二级标题,独立 section)
|
||||
|
||||
**标题**:
|
||||
- EN: `"Prefer the CLI?"`
|
||||
- ZH: `"想用 CLI?"`
|
||||
|
||||
**副文案**:
|
||||
- EN: `"For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal."`
|
||||
- ZH: `"适合服务器、远程开发机、无图形界面环境。底层 daemon 和 Desktop 相同,通过终端安装。"`
|
||||
|
||||
**命令块**(复用 `CliInstallInstructions` 的样式):
|
||||
```bash
|
||||
# Install
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
|
||||
# Start daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
**底部说明**:
|
||||
- EN: `"Already on a server? Same commands work over SSH."`
|
||||
- ZH: `"已经在服务器上?通过 SSH 执行同样的命令即可。"`
|
||||
|
||||
#### Cloud 区(最小、置底)
|
||||
|
||||
**标题**:
|
||||
- EN: `"Cloud runtime (waitlist)"`
|
||||
- ZH: `"Cloud runtime(等待名单)"`
|
||||
|
||||
**副文案**:
|
||||
- EN: `"We'll host the runtime for you. Not live yet — leave your email to be notified."`
|
||||
- ZH: `"我们将为你托管 runtime,目前尚未上线——留下邮箱,上线后通知你。"`
|
||||
|
||||
**表单**:复用 `CloudWaitlistExpand`(`packages/views/onboarding/components/cloud-waitlist-expand.tsx`)
|
||||
|
||||
#### Footer 区
|
||||
|
||||
- Release notes 链接:`"What's new in {version}"` → GitHub release tag URL
|
||||
- All releases:`"View all releases →"` → `https://github.com/multica-ai/multica/releases`
|
||||
- 版本号小字:`"Current version: v0.2.13"`(来自 `/api/latest-version`)
|
||||
|
||||
---
|
||||
|
||||
## 四、i18n Keys 规划
|
||||
|
||||
### 4.1 现有 key 复用
|
||||
|
||||
- `hero.downloadDesktop` — 保持,landing hero 按钮文案
|
||||
- `nav.desktop` → **重命名为 `nav.download`**(需同步改 landing-nav 组件读的 key)
|
||||
|
||||
### 4.2 新增 key 命名空间
|
||||
|
||||
```ts
|
||||
// apps/web/features/landing/i18n/en.ts + zh.ts
|
||||
{
|
||||
// ... 现有 key ...
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
allPlatforms: {
|
||||
title: "All platforms",
|
||||
macLabel: "macOS · Apple Silicon",
|
||||
winX64Label: "Windows · x64",
|
||||
winArm64Label: "Windows · ARM64",
|
||||
linuxX64Label: "Linux · x64",
|
||||
linuxArm64Label: "Linux · ARM64",
|
||||
intelNote: "Apple Silicon only — Intel Macs not supported in this release.",
|
||||
},
|
||||
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.",
|
||||
},
|
||||
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}",
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
login: {
|
||||
extraDownloadPrompt: "Prefer the desktop app?",
|
||||
extraDownloadCta: "Download",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
中文翻译按 ZH 对照表同步填入 `zh.ts`。
|
||||
|
||||
### 4.3 Onboarding 触点(单语英文)
|
||||
|
||||
所有 `step-welcome.tsx` / `step-platform-fork.tsx` / `cli-install-instructions.tsx` 的新文案**直接硬编码到 TSX**,不进 i18n——保持和当前 onboarding 代码风格一致。
|
||||
|
||||
---
|
||||
|
||||
## 五、文案审校清单
|
||||
|
||||
Step 2/3/4 实施时,逐条检查:
|
||||
|
||||
- [ ] 每个触点的文案都在本文档有定义(不临时发明)
|
||||
- [ ] Landing / `/download` / Login extra 走 i18n,双语齐备
|
||||
- [ ] Onboarding 触点英文硬编码,与现有代码风格一致
|
||||
- [ ] "3 minutes" 时间声明仅出现在 desktop 分支
|
||||
- [ ] 没有 "easy / simple / just" 出现在 Desktop 或 CLI 文案里
|
||||
- [ ] 所有 Download CTA 指向 `/download`,不再有直接指向 GitHub releases 的链接(landing nav、landing hero、step 3 Desktop 卡点击、登录页 extra)
|
||||
- [ ] CLI 文案强调 server/remote/headless 场景,不再暗示"Desktop 的轻量版"
|
||||
- [ ] Intel Mac 处处诚实标注,不欺骗
|
||||
|
||||
---
|
||||
|
||||
## 六、开放事项
|
||||
|
||||
- `/download` 页面的视觉风格是否跟 landing 一致(serif 标题 / 背景色)?→ **建议跟 landing 一致**,但本文档不锁死,Step 2 UI 实现时决定
|
||||
- 是否加"系统最低要求"区块?→ **不做**(Cursor 有,但我们产品期不引入这种 clutter)
|
||||
- 是否在 `/download` 置顶放一个 `<video>` 或产品截图?→ **不做**(保持克制;landing 已承担营销角色)
|
||||
@@ -1,356 +0,0 @@
|
||||
# Desktop 下载体系重设计 — 执行计划
|
||||
|
||||
**日期**:2026-04-22
|
||||
**作者**:Naiyuan
|
||||
**状态**:方案定稿,分步执行中
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
### 1.1 现状的核心矛盾
|
||||
|
||||
Multica 本质是"**本地 runtime + 云端协作**"的产品。Desktop app 内置 bundled daemon,登录即用;web 是预览/入口,不是等价平台。但**当前代码和文案把 Desktop 和 Web 当作等价路径**,结果:
|
||||
|
||||
1. **登录到 Step 3 之间,零 Desktop 推广**。用户建完 workspace 才被告知"其实你得装 app",此时沉没成本已高
|
||||
2. **Step 3 分流屏三张卡(Desktop / CLI / Cloud)视觉和文案伪对称**,用户感知不到 Desktop 是正解
|
||||
3. **`isMac` 过时门**:Windows/Linux 桌面包已齐(v0.2.13),代码却把非 Mac 用户推去 CLI
|
||||
4. **所有 Download 入口(landing / Step 3 / footer)都指向 GitHub releases 页面**——30+ assets 的列表,对非技术用户是灾难
|
||||
5. **Welcome 屏 "Takes about 3 minutes" 对 web 用户是谎**——不含下载/安装/换端时间
|
||||
|
||||
### 1.2 Cursor 对标带来的确认
|
||||
|
||||
Cursor 的 `/download` 页面(<https://cursor.com/cn/download>)模式:
|
||||
- **Client-side auto-detect**:用 `navigator.userAgentData.getHighEntropyValues(['architecture'])`,精确到 arch;fallback 到 UA 字符串
|
||||
- **SSR 发全量 HTML**(所有 OS 内容都在),hydration 后 JS 挑对应平台作主 CTA
|
||||
- **三个并列 surface**:Desktop / Terminal / Web——不是"三选一",是**三个场景**
|
||||
- 有 `useDownloadTracking` hook,埋点下载事件
|
||||
|
||||
这验证了"桌面主推 + 其他平台可见 + CLI 作为独立场景"的正确性。
|
||||
|
||||
---
|
||||
|
||||
## 二、核心洞察(从代码扒出来的)
|
||||
|
||||
### 2.1 两端用户是两种心态,不是两种路径
|
||||
|
||||
| 维度 | Desktop 用户 | Web 用户 |
|
||||
|---|---|---|
|
||||
| 入口 | 主动下 .app,越过安装门槛 | 浏览器打开,零投入 |
|
||||
| 心态 | **投入者**:"我认真对待 Multica" | **探索者**:"先试试" |
|
||||
| 对本地安装的态度 | 已接受 | **主动拒绝过**(选 web 就是为了不装) |
|
||||
| Step 3 的本质 | 确认屏(daemon 已在跑) | 决策屏(产品真相首次披露) |
|
||||
|
||||
这解释了为什么 Step 3 在 web 上是漏斗流失点——**那是"你以为是 web 产品 / 实际是本地产品"的期望违约时刻**。
|
||||
|
||||
### 2.2 CLI 不是 Desktop 的低配版,是另一个场景
|
||||
|
||||
Desktop 和 CLI 跑的是**同一个 Go 二进制**(`daemon-manager.ts` spawn 的 bundled CLI)。区别仅在 **setup moment**:
|
||||
|
||||
| 维度 | Desktop | CLI |
|
||||
|---|---|---|
|
||||
| 安装 | 双击 .dmg | `curl \| bash` |
|
||||
| 启动 | `daemonAPI.autoStart()` 登录后自动 | `multica setup` 手动 |
|
||||
| 运行后能力 | 完全等价 | 完全等价 |
|
||||
| **真正适合的场景** | 个人机器、交互使用 | **服务器、远程 dev box、on-prem、自动化、CI** |
|
||||
|
||||
CLI 的合法性不来自"Desktop 不可得时的替代",来自它**真的有 Desktop 永远覆盖不了的场景**:
|
||||
|
||||
- Self-host / on-prem:daemon 跑在自有服务器
|
||||
- 远程 dev box:SSH 到 Linux 机器,在那里跑 daemon
|
||||
- CI/CD:headless 环境里调度 agent
|
||||
- 多机部署:一个人,多台 runtime
|
||||
|
||||
**文案必须攻击 setup moment 不对称,而不是比较能力**——因为能力是一样的。
|
||||
|
||||
### 2.3 当前代码和资产状态
|
||||
|
||||
**已有的完整跨平台包(v0.2.13)**:
|
||||
|
||||
| 平台 | 包 | 大小 |
|
||||
|---|---|---|
|
||||
| macOS arm64 | `.dmg` / `.zip` | 184MB |
|
||||
| macOS Intel (x64) | ❌ 无(`electron-builder.yml` 只 target arm64) | — |
|
||||
| Windows x64 | `.exe` (NSIS) | 140MB |
|
||||
| Windows arm64 | `.exe` | 430MB |
|
||||
| Linux | `.AppImage` / `.deb` / `.rpm`(amd64 + arm64) | 113–189MB |
|
||||
|
||||
**代码当前状态**:
|
||||
|
||||
- Desktop 端 Step 3 里根本没有 CLI 这张卡(`StepRuntimeConnect.EmptyView` 只有 Skip + Cloud waitlist)——产品团队自己的立场是"bundled daemon 是 Multica 的本分"
|
||||
- Web 端 Step 3(`StepPlatformFork`)把 CLI 作为一等平级卡,`isMac` 门 disabled 非 Mac 的 Desktop CTA
|
||||
- 所有 Download 链指向 `https://github.com/multica-ai/multica/releases/latest`
|
||||
|
||||
---
|
||||
|
||||
## 三、定位策略(文案骨架)
|
||||
|
||||
每个 surface 用同一套"场景导向"句子,不比较能力:
|
||||
|
||||
| 形态 | 一句话定位 | 适合谁 |
|
||||
|---|---|---|
|
||||
| **Desktop** | "Your personal machine. Double-click, agents run locally." | 95% 新用户 |
|
||||
| **CLI** | "Servers, remote boxes, on-prem, automation. No GUI required." | 开发者 / 运维 / 自建 |
|
||||
| **Cloud**(waitlist) | "No local install — we host the runtime for you." | 评估 / 不想本地跑 |
|
||||
|
||||
**Welcome 屏在 web 分支追加一条引导**:诚实告诉用户"Better on desktop",给一个 Download 按钮 + "Continue on web" 次选。
|
||||
|
||||
---
|
||||
|
||||
## 四、已做的决定
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 语言 | **中英双语**,跟 landing 的 `en.ts` / `zh.ts` 一致 | 保持全站 i18n 体系 |
|
||||
| Intel Mac | **暂不支持**,`/download` 页诚实标注 | 2026 年 Intel Mac 已是 4+ 年前老机器;包体积翻倍影响所有人;CLI 对 Intel 用户是合理路径 |
|
||||
| 版本号获取 | **Next.js API route 代理 GitHub API**(`/api/latest-version`),Vercel ISR 5 分钟 cache | `latest.yml` 无 CORS;build-time 注入需每次重部署;GitHub API 未认证 60/hr 对 5min cache 绰绰有余 |
|
||||
| 部署 | Vercel(已确认) | ISR 原生支持,`export const revalidate = 300` 一行解决 |
|
||||
| Desktop 链接 URL | 客户端按 detect 结果拼 GitHub asset 直链 | 无需后端端点;带版本号的 URL 从 `/api/latest-version` 返回的 assets 里取 |
|
||||
| Auto-detect 方式 | `navigator.userAgentData.getHighEntropyValues(['platform','architecture'])` + UA 字符串 fallback | 抄 Cursor 模式;Safari 无此 API,macOS 下无法区分 Intel/arm——默认推 arm64 + 诚实文案 |
|
||||
| CLI 在 onboarding 的定位 | 保留 Step 3 第二张卡,但**文案重写为服务器/远程场景**,不再假装是 Desktop 的轻量版 | CLI 场景真实存在,但大多数 onboarding 用户不在这个场景里 |
|
||||
| 开发顺序 | **Step 1 文案 → Step 2 `/download` → Step 3 Onboarding → Step 4 Login + Landing** | Step 1 确立真相源,后续 UI 改动有唯一文案来源,可并行 |
|
||||
|
||||
---
|
||||
|
||||
## 五、执行步骤
|
||||
|
||||
### Step 1 · 文案与定位对齐(不写代码)
|
||||
|
||||
**做什么**:
|
||||
|
||||
- 建 `docs/download-positioning.md`(本文档的姊妹文档,专门放文案)
|
||||
- 三个 surface 的定位句(中英)
|
||||
- 盘点所有触点的**当前文案 vs 新文案**:
|
||||
- Landing hero(`landing-hero.tsx`)
|
||||
- Landing nav + footer 链接(`landing/i18n/en.ts`、`zh.ts`)
|
||||
- Login page(`packages/views/auth/login-page.tsx`)——新增 Desktop CTA
|
||||
- Welcome step(`step-welcome.tsx`)——web 分支新增 Desktop CTA
|
||||
- Step 3 三张卡(`step-platform-fork.tsx`)
|
||||
- `/download` 页面(全新)
|
||||
- `CliInstallInstructions`(`cli-install-instructions.tsx`)
|
||||
- 每条文案带中英双语对照
|
||||
|
||||
**目标**:后续所有 UI 改动有唯一文案真相源,不临时发明
|
||||
|
||||
**产出**:1 个 markdown doc
|
||||
**工期**:0.5 天
|
||||
**产物文件**:`docs/download-positioning.md`
|
||||
|
||||
### Step 2 · `/download` 页面
|
||||
|
||||
**做什么**:
|
||||
|
||||
- **路由**:`apps/web/app/(landing)/download/page.tsx`(放 landing group 共享 layout)
|
||||
- **SSR**:全量渲染 Desktop / CLI / Cloud 三块,所有平台包都在 HTML 里
|
||||
- **API route**:`apps/web/app/api/latest-version/route.ts`
|
||||
- 调 `https://api.github.com/repos/multica-ai/multica/releases/latest`
|
||||
- 解析 assets,按文件名模式抽出每个平台的 URL
|
||||
- 返回 `{ version, assets: { macArm64, winX64, winArm64, linuxDeb, linuxRpm, linuxAppImage, ... } }`
|
||||
- Vercel ISR:`export const revalidate = 300`
|
||||
- **Client detect**:`packages/views/utils/os-detect.ts`(新建)
|
||||
- 优先用 `navigator.userAgentData.getHighEntropyValues(['platform','architecture'])`
|
||||
- Fallback 到 `navigator.userAgent` + `navigator.platform`
|
||||
- 返回 `{ os: 'mac'|'windows'|'linux'|'unknown', arch: 'arm64'|'x64'|'unknown' }`
|
||||
- **UI 行为**:
|
||||
- 顶部大 CTA:按 detect 结果拼好的 Desktop 下载按钮(macOS arm64 / Windows x64 / Linux AppImage 作主推)
|
||||
- 检测到 Intel Mac(Chromium)→ 主 CTA 变成"Apple Silicon required — use CLI",CLI 区块置顶
|
||||
- 检测到 Safari on macOS → 默认推 arm64 + 小字提示"On Intel Mac? Use CLI"
|
||||
- 全平台直链列表(arch 清晰标注)
|
||||
- CLI 区块:`curl | bash` + 场景说明
|
||||
- Cloud 区块:复用 `CloudWaitlistExpand`
|
||||
- **i18n**:`apps/web/features/landing/i18n/en.ts` / `zh.ts` 新增 `download` 命名空间
|
||||
|
||||
**目标**:全站下载总入口,版本自动更新,用户下到对的包
|
||||
|
||||
**工期**:1-2 天
|
||||
**产物文件**:
|
||||
- `apps/web/app/(landing)/download/page.tsx`
|
||||
- `apps/web/app/api/latest-version/route.ts`
|
||||
- `packages/views/utils/os-detect.ts`(或 `packages/core/platform/os-detect.ts`)
|
||||
- `apps/web/features/landing/i18n/en.ts` + `zh.ts` 新增 keys
|
||||
- `apps/web/features/landing/components/download-page.tsx`(主组件,landing 风格)
|
||||
|
||||
### Step 3 · Onboarding 修缮
|
||||
|
||||
**做什么**:
|
||||
|
||||
- **Welcome 屏 web 分支**:
|
||||
- `OnboardingFlow` 里派生 `isWeb = !!runtimeInstructions`,传给 `StepWelcome`
|
||||
- `StepWelcome` 在 `isWeb` 时,CTA 区域追加一行 "Better on desktop — bundled daemon, zero setup" + **Download 按钮**(指 `/download`)+ "Continue on web" 次选
|
||||
- "Takes about 3 minutes" 文案按平台差异化
|
||||
- **Step 3 分流屏**:
|
||||
- 拆掉 `isMac` 门(`step-platform-fork.tsx:244-268`)
|
||||
- Desktop 卡对所有平台 active,按 detect 显示对应平台文案
|
||||
- Non-Mac 兜底卡改成 Cloud waitlist 强化,不再假装推 CLI
|
||||
- 三张卡的文案按 Step 1 确定的定位句重写
|
||||
- **CLI dialog**:
|
||||
- `CliInstallInstructions` 加一行场景说明:"Also great for servers and remote dev boxes."
|
||||
- `multica setup` 命令旁边保留现状
|
||||
- **"Downloading"后态**:
|
||||
- Desktop 卡点击后的 downloaded 态文案改得更明确("Check your Downloads folder. Open the .dmg to install.")
|
||||
|
||||
**目标**:Welcome 不再骗 web 用户;Step 3 三张卡场景清晰;Windows/Linux 用户不再被推 CLI
|
||||
|
||||
**工期**:0.5 天
|
||||
**产物文件**:
|
||||
- `packages/views/onboarding/onboarding-flow.tsx`
|
||||
- `packages/views/onboarding/steps/step-welcome.tsx`
|
||||
- `packages/views/onboarding/steps/step-platform-fork.tsx`
|
||||
- `packages/views/onboarding/steps/cli-install-instructions.tsx`
|
||||
|
||||
### Step 4 · 上游漏斗(Login + Landing)
|
||||
|
||||
**做什么**:
|
||||
|
||||
- **Login page**:
|
||||
- `packages/views/auth/login-page.tsx` 的 `LoginPageProps` 加 `extra?: ReactNode` prop
|
||||
- Google 按钮下方低调一行 "Prefer the desktop app? **Download →**"
|
||||
- Desktop 调用方(`apps/desktop/src/renderer/src/pages/login.tsx`)**不传** extra → 不显示
|
||||
- Web 调用方(`apps/web/app/(auth)/login/page.tsx`)**传** extra → 显示
|
||||
- **Landing hero**:
|
||||
- `landing-hero.tsx:44-65` 的 Download 按钮从 `heroButtonClassName("ghost")` 升级为 `heroButtonClassName("solid")`(或至少主次分明的 outline)
|
||||
- href 从 `https://github.com/multica-ai/multica/releases/latest` 改为 `/download`
|
||||
- **Landing nav + footer**:
|
||||
- `landing/i18n/en.ts:230` / `zh.ts:230` 的 Desktop 链接统一改为 `/download`
|
||||
|
||||
**目标**:用户最轻投入时刻就看到 Desktop;Step 3 之前已有两次 Desktop touch
|
||||
|
||||
**工期**:2 小时
|
||||
**产物文件**:
|
||||
- `packages/views/auth/login-page.tsx`
|
||||
- `apps/web/app/(auth)/login/page.tsx`
|
||||
- `apps/desktop/src/renderer/src/pages/login.tsx`(确认不传 extra 即可)
|
||||
- `apps/web/features/landing/components/landing-hero.tsx`
|
||||
- `apps/web/features/landing/i18n/en.ts` + `zh.ts`
|
||||
|
||||
---
|
||||
|
||||
## 六、不做的事(明确范围)
|
||||
|
||||
- **后端 `/api/download?os=X&arch=Y` 302 端点**:方案 A 已够用,后端不动
|
||||
- **下载埋点/数据分析**:本次不做,Cursor 有但我们暂缓
|
||||
- **下载后 "waiting on desktop" 屏**:让 handoff 更丝滑的想法,留到数据出现再决定
|
||||
- **Intel Mac universal build**:暂不补,`/download` 诚实标注"暂不支持"
|
||||
- **CLI 文档页 / 自托管文档**:Step 3 CLI 卡副文案引向 docs,docs 本身不在本次范围
|
||||
- **/download 页的 "system requirements" 区块**:不做详细 minimum specs,保持简洁
|
||||
|
||||
---
|
||||
|
||||
## 七、技术细节速查
|
||||
|
||||
### 7.1 OS + Arch Detection
|
||||
|
||||
```typescript
|
||||
// 推荐实现骨架
|
||||
export async function detectOS(): Promise<{ os: OSName; arch: Arch }> {
|
||||
// 优先用 userAgentData(Chromium)
|
||||
if (navigator.userAgentData?.getHighEntropyValues) {
|
||||
try {
|
||||
const data = await navigator.userAgentData.getHighEntropyValues([
|
||||
"platform",
|
||||
"architecture",
|
||||
]);
|
||||
// data.platform: "macOS" | "Windows" | "Linux"
|
||||
// data.architecture: "x86" | "arm"
|
||||
return normalizePlatform(data);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
// Fallback: UA 字符串 + navigator.platform
|
||||
const ua = navigator.userAgent;
|
||||
const platform = navigator.platform || "";
|
||||
// ... 按 "Mac" / "Windows" / "Linux" 分支
|
||||
}
|
||||
```
|
||||
|
||||
**已知限制**:Safari on macOS 无法区分 Intel/arm64(Apple 故意不暴露)。默认推 arm64 + 诚实文案。
|
||||
|
||||
### 7.2 `/api/latest-version` Response Shape
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: "v0.2.13",
|
||||
publishedAt: "2026-04-21T13:13:52Z",
|
||||
assets: {
|
||||
macArm64Dmg: "https://github.com/.../multica-desktop-0.2.13-mac-arm64.dmg",
|
||||
macArm64Zip: "https://github.com/.../multica-desktop-0.2.13-mac-arm64.zip",
|
||||
winX64Exe: "https://github.com/.../multica-desktop-0.2.13-windows-x64.exe",
|
||||
winArm64Exe: "https://github.com/.../multica-desktop-0.2.13-windows-arm64.exe",
|
||||
linuxAmd64AppImage: "https://github.com/.../multica-desktop-0.2.13-linux-x86_64.AppImage",
|
||||
linuxAmd64Deb: "https://github.com/.../multica-desktop-0.2.13-linux-amd64.deb",
|
||||
linuxAmd64Rpm: "https://github.com/.../multica-desktop-0.2.13-linux-x86_64.rpm",
|
||||
linuxArm64AppImage: "...",
|
||||
linuxArm64Deb: "...",
|
||||
linuxArm64Rpm: "...",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Asset 文件名模式由 `electron-builder.yml` 定义:`multica-desktop-${version}-${platform}-${arch}.${ext}`。解析靠正则匹配。
|
||||
|
||||
### 7.3 Vercel ISR 配置
|
||||
|
||||
```typescript
|
||||
// apps/web/app/api/latest-version/route.ts
|
||||
export const revalidate = 300; // 5 min
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/multica-ai/multica/releases/latest",
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: "upstream" }, { status: 502 });
|
||||
}
|
||||
const data = await res.json();
|
||||
return Response.json({
|
||||
version: data.tag_name,
|
||||
publishedAt: data.published_at,
|
||||
assets: parseAssets(data.assets),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Welcome 屏 `isWeb` 派生
|
||||
|
||||
```typescript
|
||||
// onboarding-flow.tsx
|
||||
const isWeb = !!runtimeInstructions;
|
||||
|
||||
// 传给 StepWelcome
|
||||
<StepWelcome
|
||||
onNext={handleWelcomeNext}
|
||||
onSkip={canSkipWelcome ? handleWelcomeSkip : undefined}
|
||||
isWeb={isWeb}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、执行追踪
|
||||
|
||||
- [x] Step 1 · 文案 doc → `docs/download-positioning.md`
|
||||
- [x] Step 2 · `/download` 页面(分支 `NevilleQingNY/download-redesign`)
|
||||
- [ ] Step 3 · Onboarding 修缮
|
||||
- [ ] Step 4 · 上游漏斗
|
||||
|
||||
### Step 2 产出
|
||||
|
||||
- 新文件:`apps/web/app/(landing)/download/page.tsx` + `download-client.tsx`
|
||||
- 新组件:`apps/web/features/landing/components/download/{hero,all-platforms,cli-section,cloud-section,os-icons}.tsx`
|
||||
- 新 utils:`apps/web/features/landing/utils/{os-detect,parse-release-assets,github-release}.ts`
|
||||
- 扩展 i18n:`types.ts` 加 `download` + `auth.login.extra*`;`en.ts` + `zh.ts` 填双语
|
||||
- Nav 更新:landing footer 的 "Desktop" / "桌面端" 链接 → "Download" / "下载"(指 `/download`)
|
||||
- `@multica/views/onboarding` 新 export:`CloudWaitlistExpand`(`/download` 的 Cloud 区块复用)
|
||||
|
||||
### 本地开发注意
|
||||
|
||||
GitHub Releases API 未认证限流是 **60 req/hr per IP**。Vercel 生产环境的 fetch cache 跨所有 region 共享,每 5 分钟(`revalidate: 300`)全局最多 1 次调用,远低于限流。但**本地开发** + 共享办公室 IP 容易打爆限流,命中后页面降级到"Version unavailable"。
|
||||
|
||||
本地跑 `/download` 如遇到版本信息缺失:
|
||||
1. 设置 `GITHUB_TOKEN` 环境变量(Personal Access Token,公共仓库不需要 scope)
|
||||
2. `fetchLatestRelease` 会自动带 `Authorization: Bearer <token>` header,限流提到 5000 req/hr
|
||||
3. Token 只在 server-side 用,不会泄漏到客户端
|
||||
|
||||
每完成一步,勾掉 checkbox 并在对应 section 底部补一行实际 commit hash。
|
||||
@@ -1,611 +0,0 @@
|
||||
# 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/)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,511 +0,0 @@
|
||||
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
|
||||
|
||||
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
|
||||
|
||||
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
|
||||
|
||||
---
|
||||
|
||||
## Current State (files to modify)
|
||||
|
||||
| File | Current Role | Change |
|
||||
|------|-------------|--------|
|
||||
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
|
||||
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
|
||||
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
|
||||
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
|
||||
|
||||
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
|
||||
|
||||
This is the core task. The entire DnD orchestration logic changes.
|
||||
|
||||
### Data Model
|
||||
|
||||
```typescript
|
||||
// Local state: maps status → ordered array of issue IDs
|
||||
// This is the ONLY source of truth for card positions during drag
|
||||
type Columns = Record<IssueStatus, string[]>;
|
||||
```
|
||||
|
||||
### Step 1: Replace pendingMove with local columns state
|
||||
|
||||
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
|
||||
|
||||
```typescript
|
||||
// Build columns from TQ issues + view sort settings
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
visibleStatuses: IssueStatus[],
|
||||
sortBy: SortField,
|
||||
sortDirection: SortDirection,
|
||||
): Columns {
|
||||
const cols: Columns = {} as Columns;
|
||||
for (const status of visibleStatuses) {
|
||||
const sorted = sortIssues(
|
||||
issues.filter((i) => i.status === status),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
);
|
||||
cols[status] = sorted.map((i) => i.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
```
|
||||
|
||||
In the component:
|
||||
|
||||
```typescript
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
// Local columns state — follows TQ between drags, local during drag
|
||||
const [columns, setColumns] = useState<Columns>(() =>
|
||||
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
|
||||
);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// Sync from TQ when NOT dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) {
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
}
|
||||
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||
```
|
||||
|
||||
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
|
||||
|
||||
```typescript
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
```
|
||||
|
||||
### Step 2: Implement findColumn helper
|
||||
|
||||
```typescript
|
||||
/** Find which column (status) contains a given ID (issue or column). */
|
||||
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
|
||||
// Is it a column ID itself?
|
||||
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||
// Search columns for the item
|
||||
for (const [status, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return status as IssueStatus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement onDragStart
|
||||
|
||||
```typescript
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
isDragging.current = true;
|
||||
const issue = issueMap.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
}, [issueMap]);
|
||||
```
|
||||
|
||||
### Step 4: Implement onDragOver — the key missing piece
|
||||
|
||||
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
|
||||
|
||||
```typescript
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol || activeCol === overCol) return;
|
||||
|
||||
// Cross-column move: remove from old column, insert into new column
|
||||
setColumns((prev) => {
|
||||
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||
const newIds = [...prev[overCol]!];
|
||||
|
||||
// Insert position: if over a card, insert at that index; if over column, append
|
||||
const overIndex = newIds.indexOf(overId);
|
||||
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||
newIds.splice(insertIndex, 0, activeId);
|
||||
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
}, [columns, visibleStatuses]);
|
||||
```
|
||||
|
||||
### Step 5: Implement onDragEnd — persist to server
|
||||
|
||||
```typescript
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
isDragging.current = false;
|
||||
setActiveIssue(null);
|
||||
|
||||
if (!over) {
|
||||
// Cancelled — reset to TQ state
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol) return;
|
||||
|
||||
// Same column reorder
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute final position from the local column order
|
||||
const finalCol = findColumn(columns, activeId, visibleStatuses);
|
||||
if (!finalCol) return;
|
||||
|
||||
// After potential same-col reorder, re-read columns
|
||||
// (for same-col we just did setColumns above, but it's async;
|
||||
// however we can compute from the intended final order)
|
||||
let finalIds: string[];
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
|
||||
} else {
|
||||
finalIds = columns[finalCol]!;
|
||||
}
|
||||
|
||||
const newPosition = computePosition(finalIds, activeId, issues);
|
||||
const currentIssue = issueMap.get(activeId);
|
||||
|
||||
// Skip if nothing changed
|
||||
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
|
||||
|
||||
onMoveIssue(activeId, finalCol, newPosition);
|
||||
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
|
||||
```
|
||||
|
||||
### Step 6: Update computePosition to work with ID arrays
|
||||
|
||||
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
|
||||
|
||||
```typescript
|
||||
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
|
||||
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
|
||||
|
||||
if (ids.length === 1) return 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Update DragOverlay styling
|
||||
|
||||
```typescript
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
```
|
||||
|
||||
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
|
||||
|
||||
### Step 8: Wire it all together
|
||||
|
||||
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
|
||||
|
||||
```tsx
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
### Step 9: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
|
||||
|
||||
### Step 10: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-view.tsx
|
||||
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
|
||||
|
||||
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
|
||||
|
||||
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
|
||||
|
||||
```typescript
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
|
||||
// Resolve IDs to Issue objects (IDs are already sorted by parent)
|
||||
const resolvedIssues = useMemo(
|
||||
() => issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
|
||||
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issueIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right: add + menu — keep as-is */}
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
|
||||
<EyeOff className="size-3.5" />
|
||||
Hide column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue", { status })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Add issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- No more `useViewStore` for sort — parent handles sorting
|
||||
- No more internal `sortIssues` call
|
||||
- Uses `issueIds` for SortableContext (already in correct order)
|
||||
- Count shows `issueIds.length` instead of `issues.length`
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS (or errors in issues-page.tsx — Task 4)
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-column.tsx
|
||||
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/board-card.tsx`
|
||||
|
||||
### Step 1: Add custom animateLayoutChanges
|
||||
|
||||
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
|
||||
|
||||
```typescript
|
||||
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||
const { isSorting, wasDragging } = args;
|
||||
if (isSorting || wasDragging) return false;
|
||||
return defaultAnimateLayoutChanges(args);
|
||||
};
|
||||
```
|
||||
|
||||
Update useSortable call:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
animateLayoutChanges,
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-card.tsx
|
||||
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/issues-page.tsx`
|
||||
|
||||
### Step 1: Update handleMoveIssue
|
||||
|
||||
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
|
||||
|
||||
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
|
||||
|
||||
```tsx
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
```
|
||||
|
||||
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
|
||||
|
||||
### Step 2: Run full typecheck + test
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/issues-page.tsx
|
||||
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual QA Checklist
|
||||
|
||||
After all code changes, verify these scenarios in the browser:
|
||||
|
||||
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
|
||||
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
|
||||
3. **Drop on empty column**: Drag card to an empty column → card lands there
|
||||
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
|
||||
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
|
||||
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
|
||||
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
|
||||
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
|
||||
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
|
||||
|
||||
---
|
||||
|
||||
## Summary of Architecture Change
|
||||
|
||||
```
|
||||
BEFORE (broken):
|
||||
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
|
||||
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
|
||||
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
|
||||
|
||||
AFTER (correct):
|
||||
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
|
||||
onDragStart → isDragging=true, freeze local state
|
||||
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
|
||||
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
|
||||
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
|
||||
```
|
||||
@@ -1,227 +0,0 @@
|
||||
# Drag & Drop Upload Enhancement — Revised Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Clean drag-and-drop upload with visual feedback. Images render inline, non-images show as file cards. No file type restrictions (match Linear). No separate attachment section (URLs live in markdown).
|
||||
|
||||
**Architecture:** Frontend-only. Images use existing `` markdown. Non-images use `[name](url)` markdown, rendered as a styled card via Tiptap NodeView when URL matches our CDN. Backend unchanged.
|
||||
|
||||
**Tech Stack:** Tiptap ProseMirror, React, Tailwind CSS, shadcn tokens
|
||||
|
||||
---
|
||||
|
||||
## What We Keep (from previous work)
|
||||
|
||||
- **Drag overlay** — `content-editor.tsx` drag handlers + `content-editor.css` overlay styles
|
||||
- **Image upload flow** — blob preview → upload → replace with real URL (existing `file-upload.ts`)
|
||||
- **Non-image upload placeholder** — `⏳ Uploading filename...` → replaced with link (existing `file-upload.ts`)
|
||||
- **`MAX_FILE_SIZE`** — 100MB limit
|
||||
|
||||
## What We Remove (redundant)
|
||||
|
||||
| File | What to remove |
|
||||
|------|----------------|
|
||||
| `attachment-section.tsx` | **Delete entire file** |
|
||||
| `issue-detail.tsx` | attachment query, delete mutation, handleImageRemoved, AttachmentSection JSX, onImageRemoved prop, all `["attachments"]` cache invalidation, onUploadSuccess on CommentInput, `api` import (if unused after) |
|
||||
| `content-editor.tsx` | `onImageRemoved` prop, `onImageRemovedRef` |
|
||||
| `extensions/index.ts` | `onImageRemovedRef` option |
|
||||
| `extensions/file-upload.ts` | `collectImageSrcs`, `imageRemovalTracker` plugin, `isAllowedFileType` check + import, `toast` import |
|
||||
| `shared/constants/upload.ts` | Everything except `MAX_FILE_SIZE` — remove `ALLOWED_MIME_PATTERNS`, `FILE_INPUT_ACCEPT`, `EXTENSION_MIME_MAP`, `isAllowedFileType`, `matchesMimePattern` |
|
||||
| `shared/constants/__tests__/upload.test.ts` | All tests except MAX_FILE_SIZE |
|
||||
| `shared/hooks/use-file-upload.ts` | `isAllowedFileType` import + check |
|
||||
| `components/common/file-upload-button.tsx` | `FILE_INPUT_ACCEPT` import + `accept` attribute |
|
||||
| `comment-input.tsx` | `onUploadSuccess` prop |
|
||||
|
||||
## What We Add (new)
|
||||
|
||||
**File Card Node** — a Tiptap custom node that renders `[name](url)` as a styled card when the URL matches our CDN (`multica-static.copilothub.ai` or S3 bucket domain).
|
||||
|
||||
```
|
||||
Editor view: ┌──────────────────────────┐
|
||||
│ 📄 report.pdf ⬇ │
|
||||
└──────────────────────────┘
|
||||
|
||||
Markdown storage: [report.pdf](https://multica-static.copilothub.ai/xxx.pdf)
|
||||
```
|
||||
|
||||
- Only for non-image CDN URLs (images stay as ``)
|
||||
- Regular external links (github.com, etc.) stay as normal links
|
||||
- Card shows: file type icon + filename + download button
|
||||
- Readonly mode shows the same card
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove Redundant Code
|
||||
|
||||
**Files to modify:**
|
||||
- Delete: `apps/web/features/issues/components/attachment-section.tsx`
|
||||
- Modify: `apps/web/features/issues/components/issue-detail.tsx`
|
||||
- Modify: `apps/web/features/issues/components/comment-input.tsx`
|
||||
- Modify: `apps/web/features/editor/content-editor.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
- Modify: `apps/web/shared/constants/upload.ts`
|
||||
- Modify: `apps/web/shared/constants/__tests__/upload.test.ts`
|
||||
- Modify: `apps/web/shared/hooks/use-file-upload.ts`
|
||||
- Modify: `apps/web/components/common/file-upload-button.tsx`
|
||||
|
||||
**What to do:**
|
||||
1. Delete `attachment-section.tsx`
|
||||
2. `issue-detail.tsx`: Remove AttachmentSection import, attachment useQuery, deleteAttachment useMutation, handleImageRemoved, onImageRemoved prop, all `["attachments"]` invalidation in handleDescriptionUpload (revert to simple `uploadWithToast` call), remove onUploadSuccess from CommentInput
|
||||
3. `comment-input.tsx`: Remove `onUploadSuccess` prop
|
||||
4. `content-editor.tsx`: Remove `onImageRemoved` prop + ref + wiring
|
||||
5. `extensions/index.ts`: Remove `onImageRemovedRef` from interface + call
|
||||
6. `extensions/file-upload.ts`: Remove `collectImageSrcs`, `imageRemovalTracker` plugin, `onImageRemovedRef` param, `isAllowedFileType` import + check, `toast` import (keep `toast` if still used — check)
|
||||
7. `shared/constants/upload.ts`: Keep only `MAX_FILE_SIZE`. Delete everything else.
|
||||
8. `shared/constants/__tests__/upload.test.ts`: Keep only `MAX_FILE_SIZE` test
|
||||
9. `shared/hooks/use-file-upload.ts`: Remove `isAllowedFileType` import + check. Import `MAX_FILE_SIZE` stays.
|
||||
10. `file-upload-button.tsx`: Remove `FILE_INPUT_ACCEPT` import + `accept` attribute
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
**Commit:** `refactor(upload): remove attachment section and file type whitelist`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: File Card Tiptap Node
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/file-card.ts`
|
||||
- Create: `apps/web/features/editor/extensions/file-card-view.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/content-editor.css`
|
||||
|
||||
**Design:**
|
||||
|
||||
The node intercepts markdown links `[name](url)` where URL matches our CDN, and renders them as a card NodeView.
|
||||
|
||||
```typescript
|
||||
// Detection: URL starts with CDN domain or known S3 bucket pattern
|
||||
function isCdnFileUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname.endsWith('.copilothub.ai') || u.hostname.endsWith('.amazonaws.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only match non-image files (images stay as )
|
||||
function isFileCardLink(url: string): boolean {
|
||||
return isCdnFileUrl(url) && !isImageUrl(url);
|
||||
}
|
||||
```
|
||||
|
||||
**Node spec:**
|
||||
- Node name: `fileCard`
|
||||
- Attrs: `href`, `filename`
|
||||
- Markdown serialize: `[filename](href)`
|
||||
- Markdown parse: detect `[text](cdnUrl)` where cdnUrl is non-image CDN link
|
||||
- NodeView: React component with file icon + name + download button
|
||||
|
||||
**Card UI (React NodeView):**
|
||||
```tsx
|
||||
<div className="file-card">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{filename}</span>
|
||||
<a href={href} download={filename} className="...">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.file-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
margin: 0.25rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual:
|
||||
1. Upload a PDF → card appears in editor (not plain link)
|
||||
2. Upload a .go file → card appears
|
||||
3. Upload an image → still renders inline (not as card)
|
||||
4. Paste an external link → still renders as normal link (not card)
|
||||
5. Save and reload → card still displays correctly
|
||||
6. Switch to readonly mode → card still displays
|
||||
|
||||
**Commit:** `feat(editor): render CDN file links as styled cards`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Non-Image Upload to Use File Card
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
|
||||
Currently the non-image upload path inserts a markdown string `[name](url)`. After Task 2 adds the fileCard node, this should insert a `fileCard` node directly instead:
|
||||
|
||||
```typescript
|
||||
// Instead of:
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
replacePlaceholder(editor, placeholder, linkText);
|
||||
|
||||
// Insert fileCard node:
|
||||
replacePlaceholder(editor, placeholder, "");
|
||||
editor.chain().focus().insertContent({
|
||||
type: "fileCard",
|
||||
attrs: { href: result.link, filename: result.filename },
|
||||
}).run();
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual: Upload a PDF → placeholder appears → replaced with file card (not plain text link)
|
||||
|
||||
**Commit:** `feat(upload): insert file card node for non-image uploads`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual test all upload flows:
|
||||
1. Drag image → overlay → drop → inline image with pulse → real image
|
||||
2. Drag PDF → overlay → drop → placeholder → file card
|
||||
3. Drag .mp4 → uploads normally (no type restriction) → file card
|
||||
4. Paste image → inline image
|
||||
5. Click 📎 → file picker shows all types → upload works
|
||||
6. Readonly mode → cards and images display correctly
|
||||
7. Save → reload → everything persists
|
||||
|
||||
**Commit:** fix any issues found
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before (current) | After |
|
||||
|-------------------|-------|
|
||||
| File type whitelist blocks .mp4/.zip/etc | All files accepted (like Linear) |
|
||||
| Attachment Section below description | Gone — files live in markdown |
|
||||
| Non-image files as plain `[name](url)` text | Styled file card with icon + download |
|
||||
| Image removal tracker + attachment cache | Gone — simpler code |
|
||||
| ~300 lines of attachment UI code | Deleted |
|
||||
| ~100 lines of whitelist code | Replaced by 1 line: `MAX_FILE_SIZE` |
|
||||
@@ -1,452 +0,0 @@
|
||||
# Image View Enhancement Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add image hover toolbar (view/download/copy image/copy link/delete), lightbox preview, and smart sizing (centered, max-width capped) — matching Linear's image UX.
|
||||
|
||||
**Architecture:** Convert the Image extension from default `<img>` rendering to a React NodeView (`image-view.tsx`). The NodeView wraps `<img>` in a `<figure>` with a hover toolbar and lightbox portal. CSS handles centering and size cap. No new npm dependencies.
|
||||
|
||||
**Tech Stack:** Tiptap `ReactNodeViewRenderer`, lucide-react, sonner (toast), CSS, `createPortal` for lightbox
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Image NodeView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/image-view.tsx`
|
||||
|
||||
**Step 1: Create the ImageView component**
|
||||
|
||||
```tsx
|
||||
// apps/web/features/editor/extensions/image-view.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import {
|
||||
Maximize2,
|
||||
Download,
|
||||
Copy,
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightbox — full-screen image preview (ESC or click backdrop to close)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image NodeView — renders <img> with hover toolbar + lightbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
|
||||
const src = node.attrs.src as string;
|
||||
const alt = (node.attrs.alt as string) || "";
|
||||
const title = node.attrs.title as string | undefined;
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const handleView = () => setLightbox(true);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = alt || "image";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
window.open(src, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyImage = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob }),
|
||||
]);
|
||||
toast.success("Image copied");
|
||||
} catch {
|
||||
// Fallback: copy link (Safari doesn't support async clipboard image)
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success("Link copied");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success("Link copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-node">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<figure
|
||||
className={cn(
|
||||
"image-figure",
|
||||
selected && isEditable && "image-selected",
|
||||
)}
|
||||
contentEditable={false}
|
||||
onClick={!isEditable && !uploading ? handleView : undefined}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title || undefined}
|
||||
className={cn("image-content", uploading && "image-uploading")}
|
||||
draggable={false}
|
||||
/>
|
||||
{!uploading && (
|
||||
<div
|
||||
className="image-toolbar"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={handleView} title="View image">
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={handleDownload} title="Download">
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyImage}
|
||||
title="Copy image"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
title="Copy link"
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</button>
|
||||
{isEditable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNode()}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</figure>
|
||||
{lightbox && (
|
||||
<ImageLightbox
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClose={() => setLightbox(false)}
|
||||
/>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { ImageView };
|
||||
```
|
||||
|
||||
**Step 2: Verify file created**
|
||||
|
||||
Run: `ls apps/web/features/editor/extensions/image-view.tsx`
|
||||
Expected: file exists
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire Up NodeView in Image Extension
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts:59-75`
|
||||
|
||||
**Step 1: Add import**
|
||||
|
||||
At the top of `index.ts`, after the existing imports, add:
|
||||
|
||||
```typescript
|
||||
import { ImageView } from "./image-view";
|
||||
```
|
||||
|
||||
**Step 2: Update ImageExtension — add NodeView, remove inline style**
|
||||
|
||||
Replace the `ImageExtension` definition (lines 59-75) with:
|
||||
|
||||
```typescript
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploading: {
|
||||
default: false,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.uploading ? { "data-uploading": "" } : {},
|
||||
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageView);
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
});
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Added `addNodeView()` — images now render via React component
|
||||
- Removed `HTMLAttributes: { style: "max-width: 100%; height: auto;" }` — sizing is now in CSS
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/features/editor/extensions/image-view.tsx apps/web/features/editor/extensions/index.ts
|
||||
git commit -m "feat(editor): add Image NodeView with toolbar and lightbox
|
||||
|
||||
- React NodeView renders images with hover toolbar (view/download/copy/link/delete)
|
||||
- Lightbox portal for full-screen preview (ESC or click to close)
|
||||
- Copy image with clipboard API (fallback to copy link on Safari)
|
||||
- Delete button in edit mode only
|
||||
- Readonly: click image opens lightbox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Image CSS — Centering, sizing, toolbar, lightbox
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/content-editor.css:379-395`
|
||||
|
||||
**Step 1: Replace image CSS rules**
|
||||
|
||||
Replace lines 379-395 (from `/* Images — shared styling */` through the `@keyframes` block) with:
|
||||
|
||||
```css
|
||||
/* Images — generic fallback (non-NodeView contexts) */
|
||||
.rich-text-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Image NodeView — centered block with max-width cap */
|
||||
.rich-text-editor .image-node {
|
||||
display: block !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-figure {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: min(100%, 640px);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-figure.image-selected .image-content {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rich-text-editor .image-uploading {
|
||||
opacity: 0.5;
|
||||
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-upload-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Readonly — zoom cursor on clickable images */
|
||||
.rich-text-editor.readonly .image-figure {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Image toolbar — dark pill, top-right corner, appears on hover */
|
||||
.image-toolbar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0.25rem;
|
||||
background: color-mix(in srgb, black 75%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image-figure:hover .image-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: white;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.image-toolbar button:hover {
|
||||
background: color-mix(in srgb, white 15%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/features/editor/content-editor.css
|
||||
git commit -m "style(editor): add image centering, sizing cap, and toolbar styles
|
||||
|
||||
- Images centered with max-width 640px cap (smart sizing)
|
||||
- Dark hover toolbar with blur backdrop
|
||||
- Selection outline for edit mode
|
||||
- Zoom cursor for readonly mode
|
||||
- Upload pulse animation preserved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
**Step 1: Run all checks**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: all pass
|
||||
|
||||
**Step 2: Manual verification checklist**
|
||||
|
||||
Test in browser:
|
||||
|
||||
| # | Test | Expected |
|
||||
|---|------|----------|
|
||||
| 1 | Upload large screenshot | Centered, max 640px wide |
|
||||
| 2 | Upload small image (< 300px) | Natural size, centered |
|
||||
| 3 | Drag image into editor | Blob preview with pulse → real image |
|
||||
| 4 | Hover image | Dark toolbar appears top-right (5 buttons edit, 4 readonly) |
|
||||
| 5 | Toolbar → View image | Full-screen lightbox opens |
|
||||
| 6 | Lightbox → ESC | Closes |
|
||||
| 7 | Lightbox → click backdrop | Closes |
|
||||
| 8 | Toolbar → Download | Browser downloads the image |
|
||||
| 9 | Toolbar → Copy image | Toast "Image copied", image in clipboard |
|
||||
| 10 | Toolbar → Copy link | Toast "Link copied", URL in clipboard |
|
||||
| 11 | Toolbar → Delete | Image removed from editor |
|
||||
| 12 | Click image (edit mode) | Blue selection outline appears |
|
||||
| 13 | Select image → Backspace | Image deleted |
|
||||
| 14 | Click image (readonly mode) | Opens lightbox directly |
|
||||
| 15 | Readonly toolbar | No Delete button, other 4 buttons work |
|
||||
| 16 | Save → reload | Images persist with correct styling |
|
||||
|
||||
**Step 3: Fix any issues, re-run checks**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
**Step 4: Commit fixes (if any)**
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why NodeView instead of CSS-only?
|
||||
|
||||
The toolbar buttons (view/download/copy/delete) require interactive React components overlaid on the image. CSS-only can handle sizing/centering but cannot add click handlers. A NodeView is the standard Tiptap pattern for this — same as `CodeBlockView` (copy button) and `FileCardView` (download button) already in the codebase.
|
||||
|
||||
### Upload flow compatibility
|
||||
|
||||
The existing upload flow in `file-upload.ts` uses `tr.setNodeMarkup()` to update image attributes after upload. This works with NodeView because ProseMirror attribute changes trigger React re-renders via `ReactNodeViewRenderer`. Same mechanism used by `FileCardView`'s `finalizeFileCard()`.
|
||||
|
||||
### Markdown serialization
|
||||
|
||||
No changes needed. Images serialize as `` — standard markdown. The NodeView only affects editor rendering, not serialization. No width/height stored in markdown (sizing is purely CSS).
|
||||
|
||||
### Lightbox implementation
|
||||
|
||||
Uses `createPortal` to render outside the editor DOM tree, with a keyboard listener for ESC. Intentionally NOT using shadcn Dialog to keep it minimal — no focus trapping or complex accessibility needed for a simple image preview overlay.
|
||||
|
||||
### Browser compatibility: Copy image
|
||||
|
||||
`navigator.clipboard.write()` with `ClipboardItem` works in Chrome/Edge. Safari requires the clipboard write to be in the same user gesture (no async fetch before write), so it falls back to copying the link URL with a toast notification.
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| Images stretch to 100% width, left-aligned | Centered, capped at 640px |
|
||||
| No hover actions on images | 5-button toolbar: View, Download, Copy, Link, Delete |
|
||||
| No image preview | Click-to-zoom lightbox (ESC/click to close) |
|
||||
| Readonly images are static | Click to zoom, hover for toolbar (minus Delete) |
|
||||
| Delete image: select + backspace only | Toolbar Delete button + keyboard |
|
||||
| No visual selection feedback | Blue outline on selected image |
|
||||
@@ -1,489 +0,0 @@
|
||||
# Monorepo Extraction Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extract shared code into monorepo packages (`packages/core/`, `packages/ui/`, `packages/views/`), set up Turborepo, ensure `apps/web/` runs identically.
|
||||
|
||||
**Architecture:** Three packages, single-direction dependencies: `views/ → core/ + ui/`. Core is headless (zero react-dom). UI is atomic (zero business logic). Views is shared pages/components.
|
||||
|
||||
**Tech Stack:** pnpm workspaces + catalog, Turborepo, TypeScript internal packages (export TS source, no build), Tailwind CSS v4, shadcn/ui.
|
||||
|
||||
**Scope:** Monorepo extraction only. Desktop app is a separate future plan.
|
||||
|
||||
**Branch:** `feat/monorepo-extraction` (from latest `main` at f57cf44e)
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
| Category | Files | Nature |
|
||||
|---|---|---|
|
||||
| Pure file moves | ~170 | Copy + fix relative imports |
|
||||
| Code changes needed | ~17 | ApiClient callback, store factories, props refactor, nav adapter |
|
||||
| Bulk import updates | ~140 consumer files | Mechanical find-and-replace |
|
||||
| New files to create | ~15 | package.json, tsconfig, turbo.json, platform layer, nav adapter |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure (Tasks 1-3)
|
||||
|
||||
### Task 1: Turborepo + workspace
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-workspace.yaml` — add `"packages/*"` to packages list, add `@tanstack/react-query` to catalog
|
||||
- Create: `turbo.json`
|
||||
- Modify: `package.json` (root) — add turbo devDep, update scripts to use turbo
|
||||
- Modify: `.gitignore` — add `.turbo`
|
||||
|
||||
**turbo.json:**
|
||||
```json
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"dev": { "cache": false, "persistent": true },
|
||||
"typecheck": { "dependsOn": ["^typecheck"] },
|
||||
"test": { "dependsOn": ["^typecheck"] },
|
||||
"lint": { "dependsOn": ["^typecheck"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `pnpm typecheck` passes through turbo.
|
||||
|
||||
**Commit:** `chore: add Turborepo and configure workspace for packages/*`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Shared TypeScript config
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/tsconfig/package.json`
|
||||
- Create: `packages/tsconfig/base.json`
|
||||
- Create: `packages/tsconfig/react-library.json`
|
||||
|
||||
**base.json** — strict, ESNext, bundler resolution, declaration maps.
|
||||
**react-library.json** — extends base, adds jsx: react-jsx and DOM lib.
|
||||
|
||||
All other packages will `"extends": "@multica/tsconfig/react-library.json"`.
|
||||
|
||||
**Commit:** `chore: add shared TypeScript config package`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Clean up empty package dirs
|
||||
|
||||
**Action:** `rm -rf packages/sdk packages/types packages/utils packages/ui`
|
||||
|
||||
These are leftover empty dirs (only contain node_modules).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: packages/core/ (Tasks 4-10)
|
||||
|
||||
### Task 4: Scaffold + move types/utils/logger
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/package.json` (name: @multica/core, deps: react, zustand, @tanstack/react-query, sonner)
|
||||
- Create: `packages/core/tsconfig.json` (extends @multica/tsconfig/react-library.json)
|
||||
- Move: `apps/web/shared/types/` → `packages/core/types/` (11 files, no changes needed)
|
||||
- Move: `apps/web/shared/logger.ts` → `packages/core/logger.ts` (no changes)
|
||||
- Move: `apps/web/shared/utils.ts` → `packages/core/utils.ts` (no changes)
|
||||
|
||||
**Verify:** `cd packages/core && npx tsc --noEmit`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Move API client (with onUnauthorized abstraction)
|
||||
|
||||
**Files:**
|
||||
- Move: `apps/web/shared/api/ws-client.ts` → `packages/core/api/ws-client.ts` (no changes)
|
||||
- Move: `apps/web/shared/api/client.ts` → `packages/core/api/client.ts` (**3 changes**)
|
||||
- Create: `packages/core/api/index.ts`
|
||||
|
||||
**Code changes in client.ts:**
|
||||
1. `import type { ... } from "@/shared/types"` → `from "../types"`
|
||||
2. `import { ... } from "@/shared/logger"` → `from "../logger"`
|
||||
3. Add `onUnauthorized?: () => void` to options, replace `handleUnauthorized()` body:
|
||||
```typescript
|
||||
// Before: localStorage.removeItem + window.location.href = "/"
|
||||
// After: this.token = null; this.workspaceId = null; this.options.onUnauthorized?.();
|
||||
```
|
||||
|
||||
**NOT moved:** `apps/web/shared/api/index.ts` (the singleton) — replaced by `apps/web/platform/api.ts` in Task 9.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Move stores
|
||||
|
||||
**Pure moves (fix imports only):**
|
||||
- `features/issues/store.ts` → `packages/core/issues/store.ts`
|
||||
- `features/issues/config/*.ts` → `packages/core/issues/config/` — fix `@/shared/types` → `../../types`
|
||||
- `features/issues/stores/view-store.ts` → `packages/core/issues/stores/view-store.ts` — fix imports
|
||||
- `features/issues/stores/view-store-context.tsx` → `packages/core/issues/stores/view-store-context.tsx`
|
||||
- `features/issues/stores/draft-store.ts` → `packages/core/issues/stores/draft-store.ts`
|
||||
- `features/issues/stores/issues-scope-store.ts` → `packages/core/issues/stores/issues-scope-store.ts`
|
||||
- `features/issues/stores/selection-store.ts` → `packages/core/issues/stores/selection-store.ts`
|
||||
- `features/navigation/store.ts` → `packages/core/navigation/store.ts` (no changes)
|
||||
- `features/modals/store.ts` → `packages/core/modals/store.ts` (no changes)
|
||||
|
||||
**Factory refactor (code changes):**
|
||||
- `features/auth/store.ts` → `packages/core/auth/store.ts` — change to `createAuthStore({ api, onLogin?, onLogout? })` factory
|
||||
- `features/workspace/store.ts` → `packages/core/workspace/store.ts` — change to `createWorkspaceStore(api)` factory
|
||||
|
||||
**Also move:**
|
||||
- `features/workspace/hooks.ts` → `packages/core/workspace/hooks.ts` — fix imports to relative
|
||||
|
||||
**view-store.ts special handling:** The dynamic `import("@/features/workspace")` for workspace sync — change to accept workspace store instance via `registerViewStoreForWorkspaceSync(viewStore, workspaceStore)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Move TanStack Query modules
|
||||
|
||||
**Pure moves (fix import paths only):**
|
||||
- `apps/web/core/issues/{queries,mutations,ws-updaters}.ts` → `packages/core/issues/`
|
||||
- `apps/web/core/inbox/{queries,mutations,ws-updaters}.ts` → `packages/core/inbox/`
|
||||
- `apps/web/core/workspace/{queries,mutations}.ts` → `packages/core/workspace/`
|
||||
- `apps/web/core/runtimes/queries.ts` → `packages/core/runtimes/`
|
||||
- `apps/web/core/query-client.ts` → `packages/core/query-client.ts`
|
||||
- `apps/web/core/provider.tsx` → `packages/core/provider.tsx`
|
||||
|
||||
All changes: `@/shared/api` → `../api`, `@/shared/types` → `../types`, `@core/xxx` → `./xxx` or `../xxx`
|
||||
|
||||
**Code change:**
|
||||
- `apps/web/core/hooks.ts` → `packages/core/hooks.ts` — refactor `useWorkspaceId()` to use React Context instead of importing workspace store directly:
|
||||
```typescript
|
||||
const WorkspaceIdContext = createContext<string | null>(null);
|
||||
export function WorkspaceIdProvider({ wsId, children }) { ... }
|
||||
export function useWorkspaceId() { return useContext(WorkspaceIdContext); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Move realtime + shared hooks
|
||||
|
||||
**Pure moves (fix imports):**
|
||||
- `features/realtime/hooks.ts` → `packages/core/realtime/hooks.ts`
|
||||
- `features/realtime/use-realtime-sync.ts` → `packages/core/realtime/use-realtime-sync.ts`
|
||||
- `shared/hooks/use-file-upload.ts` → `packages/core/hooks/use-file-upload.ts`
|
||||
|
||||
**Code change:**
|
||||
- `features/realtime/provider.tsx` → `packages/core/realtime/provider.tsx` — accept `wsUrl` prop instead of reading `process.env.NEXT_PUBLIC_WS_URL`
|
||||
|
||||
**Note:** `use-realtime-sync.ts` needs auth/workspace store access. Since these are now factories, the realtime provider should receive the store instances. Simplest: WSProvider accepts `authStore` and `workspaceStore` props, passes them to `useRealtimeSync`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Create platform bridge in apps/web/
|
||||
|
||||
**New files (all new code):**
|
||||
- `apps/web/platform/api.ts` — creates api singleton with `NEXT_PUBLIC_API_URL`, `onUnauthorized` with `window.location.href`
|
||||
- `apps/web/platform/auth.ts` — `export const useAuthStore = createAuthStore({ api, onLogin: setLoggedInCookie, onLogout: clearLoggedInCookie })`
|
||||
- `apps/web/platform/workspace.ts` — `export const useWorkspaceStore = createWorkspaceStore(api)`
|
||||
- `apps/web/platform/index.ts` — re-exports
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Update imports in apps/web/ + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~94 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/shared/types` | `@multica/core/types` |
|
||||
| `@/shared/api"` (singleton usage) | `@/platform/api"` |
|
||||
| `@/shared/logger` | `@multica/core/logger` |
|
||||
| `@/shared/utils` | `@multica/core/utils` |
|
||||
| `@/shared/hooks/` | `@multica/core/hooks/` |
|
||||
| `@core/` | `@multica/core/` |
|
||||
| `@/features/auth"` (useAuthStore) | `@/platform/auth"` |
|
||||
| `@/features/workspace"` (useWorkspaceStore) | `@/platform/workspace"` |
|
||||
| `@/features/workspace"` (useActorName) | `@multica/core/workspace/hooks"` |
|
||||
| `@/features/realtime` | `@multica/core/realtime` |
|
||||
| `@/features/navigation` | `@multica/core/navigation` |
|
||||
| `@/features/modals"` (store) | `@multica/core/modals"` |
|
||||
| `@/features/issues/store` | `@multica/core/issues` |
|
||||
| `@/features/issues/stores/` | `@multica/core/issues/stores/` |
|
||||
| `@/features/issues/config` | `@multica/core/issues/config` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/core": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `transpilePackages: ["@multica/core"]` to `next.config.ts`
|
||||
- Remove `"@core/*"` alias from `apps/web/tsconfig.json`
|
||||
|
||||
**Delete old files:**
|
||||
```
|
||||
apps/web/shared/types/, apps/web/shared/api/, apps/web/shared/logger.ts,
|
||||
apps/web/shared/utils.ts, apps/web/shared/hooks/, apps/web/core/,
|
||||
features/auth/store.ts, features/workspace/store.ts, features/workspace/hooks.ts,
|
||||
features/realtime/, features/navigation/store.ts, features/modals/store.ts,
|
||||
features/issues/store.ts, features/issues/stores/, features/issues/config/
|
||||
```
|
||||
|
||||
**Keep:** `features/auth/auth-cookie.ts`, `features/auth/initializer.tsx`, `features/landing/`
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(core): extract packages/core — headless business logic layer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: packages/ui/ (Tasks 11-16)
|
||||
|
||||
### Task 11: Scaffold packages/ui/
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/ui/package.json` (name: @multica/ui, deps: all @radix-ui/*, clsx, tailwind-merge, lucide-react, emoji-mart, react-markdown, shiki, etc.)
|
||||
- Create: `packages/ui/tsconfig.json` (extends shared config, with `@/lib/utils`, `@/hooks/*`, `@/components/ui/*` path aliases for internal shadcn imports)
|
||||
- Create: `packages/ui/components.json` (shadcn config for this package)
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Move shadcn + lib + hooks
|
||||
|
||||
**Pure moves (no code changes):**
|
||||
- `apps/web/components/ui/*.tsx` (56 files) → `packages/ui/components/ui/`
|
||||
- `apps/web/lib/utils.ts` → `packages/ui/lib/utils.ts`
|
||||
- `apps/web/hooks/{use-auto-scroll,use-mobile,use-scroll-fade}.ts` → `packages/ui/hooks/`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Extract CSS tokens
|
||||
|
||||
- Copy `@theme inline { ... }` + `:root` + `.dark` blocks from `globals.css` → `packages/ui/styles/tokens.css`
|
||||
- Update `globals.css`: replace inline tokens with `@import "@multica/ui/styles/tokens.css"` + add `@source` directives for packages
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Refactor + move common components
|
||||
|
||||
**Code changes (3 files):**
|
||||
- `actor-avatar.tsx` — remove `useActorName()`, accept `name/initials/avatarUrl/isAgent` props
|
||||
- `mention-hover-card.tsx` — remove `useQuery`, accept resolved data props
|
||||
- `reaction-bar.tsx` — remove `useActorName()`, add `getActorName` prop
|
||||
|
||||
**Pure moves (3 files):**
|
||||
- `file-upload-button.tsx`, `emoji-picker.tsx`, `quick-emoji-picker.tsx` → direct copy
|
||||
|
||||
All go to `packages/ui/components/common/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Move markdown components
|
||||
|
||||
**Code change (1 file):**
|
||||
- `Markdown.tsx` — add `renderMention?: (props: { type: string; id: string }) => ReactNode` prop, remove hardcoded `IssueMentionCard` import
|
||||
|
||||
**Pure moves (5 files):**
|
||||
- `CodeBlock.tsx`, `StreamingMarkdown.tsx`, `linkify.ts`, `mentions.ts`, `index.ts`
|
||||
|
||||
All go to `packages/ui/markdown/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Update imports + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~118 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/components/ui/` | `@multica/ui/components/ui/` |
|
||||
| `@/components/common/` | `@multica/ui/components/common/` |
|
||||
| `@/components/markdown` | `@multica/ui/markdown` |
|
||||
| `@/lib/utils` | `@multica/ui/lib/utils` |
|
||||
| `@/hooks/use-mobile` | `@multica/ui/hooks/use-mobile` |
|
||||
| `@/hooks/use-auto-scroll` | `@multica/ui/hooks/use-auto-scroll` |
|
||||
| `@/hooks/use-scroll-fade` | `@multica/ui/hooks/use-scroll-fade` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/ui": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `"@multica/ui"` to `transpilePackages` in `next.config.ts`
|
||||
- Update `apps/web/components.json` aliases to point to `@multica/ui`
|
||||
|
||||
**Delete:** `components/ui/`, `components/common/`, `components/markdown/`, `hooks/`, `lib/utils.ts`
|
||||
|
||||
**Keep:** `components/{theme-provider,theme-toggle,multica-icon,loading-indicator,spinner,locale-sync}.tsx`
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(ui): extract packages/ui — shared atomic UI layer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: packages/views/ + navigation (Tasks 17-22)
|
||||
|
||||
### Task 17: Create navigation adapter
|
||||
|
||||
**New files (all new code, ~60 lines total):**
|
||||
- `packages/views/package.json` (deps: @multica/core, @multica/ui, @dnd-kit/*, @tiptap/*, sonner, recharts)
|
||||
- `packages/views/tsconfig.json`
|
||||
- `packages/views/navigation/types.ts` — `NavigationAdapter` interface (push, replace, back, pathname, searchParams)
|
||||
- `packages/views/navigation/context.tsx` — `NavigationProvider` + `useNavigation()` hook
|
||||
- `packages/views/navigation/app-link.tsx` — `<AppLink>` component (replaces `next/link`)
|
||||
- `packages/views/navigation/index.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Create WebNavigationProvider
|
||||
|
||||
**New file:**
|
||||
- `apps/web/platform/navigation.tsx` — wraps `useRouter`/`usePathname`/`useSearchParams` into `NavigationAdapter`
|
||||
|
||||
Wire into dashboard layout.
|
||||
|
||||
---
|
||||
|
||||
### Task 19: Move feature UI components
|
||||
|
||||
**Next.js decouple (7 files, ~2 lines each):**
|
||||
|
||||
| File | Import change | JSX change |
|
||||
|---|---|---|
|
||||
| `issue-mention-card.tsx` | `next/link` → `../navigation` | `<Link` → `<AppLink` |
|
||||
| `board-card.tsx` | same | same |
|
||||
| `list-row.tsx` | same | same |
|
||||
| `issue-detail.tsx` | `next/link` + `next/navigation` → `../navigation` | `<Link` → `<AppLink`, `router.push` → `nav.push` |
|
||||
| `create-issue.tsx` | `next/navigation` → `../navigation` | `router.push` → `nav.push` |
|
||||
| `create-workspace.tsx` | same | same |
|
||||
|
||||
**Pure moves (~85 files, fix import paths only):**
|
||||
- `features/issues/components/` (24 files) → `packages/views/issues/components/`
|
||||
- `features/issues/hooks/` (3 files) → `packages/views/issues/hooks/`
|
||||
- `features/issues/utils/` (5 files) → `packages/views/issues/utils/`
|
||||
- `features/editor/` (16 files incl CSS) → `packages/views/editor/`
|
||||
- `features/modals/{create-issue,create-workspace,registry}.tsx` → `packages/views/modals/`
|
||||
- `features/my-issues/` (4 files) → `packages/views/my-issues/`
|
||||
- `features/skills/` (5 files) → `packages/views/skills/`
|
||||
- `features/runtimes/` (16 files) → `packages/views/runtimes/`
|
||||
- `features/workspace/components/workspace-avatar.tsx` → `packages/views/workspace/`
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Extract fat pages
|
||||
|
||||
Move logic from page.tsx files into packages/views/:
|
||||
|
||||
| Page | Lines | Target |
|
||||
|---|---|---|
|
||||
| `(dashboard)/agents/page.tsx` | 1,280 | `packages/views/agents/agents-page.tsx` |
|
||||
| `(dashboard)/inbox/page.tsx` | 468 | `packages/views/inbox/inbox-page.tsx` |
|
||||
| `(auth)/login/page.tsx` | 389 | `packages/views/auth/login-page.tsx` |
|
||||
|
||||
Each original page.tsx becomes a 3-line thin shell:
|
||||
```typescript
|
||||
"use client";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
export default function Page() { return <AgentsPage />; }
|
||||
```
|
||||
|
||||
Login page: pass `googleClientId` as prop instead of reading env var.
|
||||
|
||||
---
|
||||
|
||||
### Task 21: Update imports + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~18 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/features/issues/components` | `@multica/views/issues/components` |
|
||||
| `@/features/issues/hooks/` | `@multica/views/issues/hooks/` |
|
||||
| `@/features/editor` | `@multica/views/editor` |
|
||||
| `@/features/modals/` (components) | `@multica/views/modals/` |
|
||||
| `@/features/my-issues` | `@multica/views/my-issues` |
|
||||
| `@/features/skills` | `@multica/views/skills` |
|
||||
| `@/features/runtimes` | `@multica/views/runtimes` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/views": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `"@multica/views"` to `transpilePackages`
|
||||
- Add `@source "../../packages/views/**/*.tsx"` to `globals.css`
|
||||
|
||||
**Delete old feature files.**
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(views): extract packages/views — shared business UI + navigation adapter`
|
||||
|
||||
---
|
||||
|
||||
### Task 22: Final verification
|
||||
|
||||
```bash
|
||||
make check # typecheck + unit tests + Go tests + E2E
|
||||
cd apps/web && npx shadcn@latest add --dry-run badge # shadcn CLI works
|
||||
|
||||
# Package constraints
|
||||
grep -r "@multica/core" packages/ui/ || echo "PASS: ui/ has zero core imports"
|
||||
grep -r "react-dom" packages/core/ || echo "PASS: core/ has zero react-dom"
|
||||
grep -r "from \"next/" packages/views/ || echo "PASS: views/ has zero next/* imports"
|
||||
```
|
||||
|
||||
**Commit:** `chore: monorepo extraction complete — all checks pass`
|
||||
|
||||
---
|
||||
|
||||
## Final Directory Structure
|
||||
|
||||
```
|
||||
multica/
|
||||
├── packages/
|
||||
│ ├── tsconfig/ # Shared TS config
|
||||
│ ├── core/ # @multica/core — 三端共用 (零 react-dom)
|
||||
│ │ ├── api/ # ApiClient class + WSClient
|
||||
│ │ ├── types/ # 所有领域类型
|
||||
│ │ ├── auth/ # createAuthStore factory
|
||||
│ │ ├── workspace/ # createWorkspaceStore factory + useActorName
|
||||
│ │ ├── issues/ # stores, config, queries, mutations, ws-updaters
|
||||
│ │ ├── inbox/ # queries, mutations, ws-updaters
|
||||
│ │ ├── runtimes/ # queries
|
||||
│ │ ├── realtime/ # WSProvider, hooks, sync
|
||||
│ │ ├── navigation/ # useNavigationStore
|
||||
│ │ ├── modals/ # useModalStore
|
||||
│ │ └── hooks.ts # useWorkspaceId (Context-based)
|
||||
│ ├── ui/ # @multica/ui — Web+Desktop 共用 (零业务逻辑)
|
||||
│ │ ├── components/ui/ # 56 shadcn 组件
|
||||
│ │ ├── components/common/ # actor-avatar, emoji-picker... (纯 props)
|
||||
│ │ ├── markdown/ # Markdown, StreamingMarkdown (renderMention slot)
|
||||
│ │ ├── hooks/ # use-auto-scroll, use-mobile, use-scroll-fade
|
||||
│ │ ├── lib/utils.ts # cn()
|
||||
│ │ └── styles/tokens.css
|
||||
│ └── views/ # @multica/views — Web+Desktop 共用页面
|
||||
│ ├── navigation/ # NavigationAdapter + AppLink
|
||||
│ ├── issues/ # IssuesPage, IssueDetail, BoardView...
|
||||
│ ├── editor/ # ContentEditor, TitleEditor
|
||||
│ ├── modals/ # CreateIssue, CreateWorkspace
|
||||
│ ├── agents/ # AgentsPage (从 1280 行 page.tsx 提取)
|
||||
│ ├── inbox/ # InboxPage (从 468 行 page.tsx 提取)
|
||||
│ ├── auth/ # LoginPage (从 389 行 page.tsx 提取)
|
||||
│ ├── my-issues/ # MyIssuesPage
|
||||
│ ├── skills/ # SkillsPage
|
||||
│ └── runtimes/ # RuntimesPage
|
||||
├── apps/
|
||||
│ └── web/
|
||||
│ ├── app/ # Next.js 路由薄壳 (每个 page < 15 行)
|
||||
│ ├── platform/ # Web 平台适配 (api 单例, auth store, nav provider)
|
||||
│ ├── features/
|
||||
│ │ ├── auth/ # auth-cookie.ts (Web 独有) + initializer.tsx
|
||||
│ │ └── landing/ # Landing 页面 (Web 独有, 用 next/image)
|
||||
│ └── components/ # theme-provider, multica-icon 等 app 级组件
|
||||
├── turbo.json
|
||||
└── pnpm-workspace.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Order & Commits
|
||||
|
||||
| # | Commit | 影响范围 | 风险 |
|
||||
|---|---|---|---|
|
||||
| 1 | `chore: Turborepo + workspace` | 配置文件 | 低 |
|
||||
| 2 | `chore: shared TypeScript config` | 新文件 | 低 |
|
||||
| 3 | `feat(core): extract packages/core` | 94 文件 import 变更 | 中 — 最大批量替换 |
|
||||
| 4 | `feat(ui): extract packages/ui` | 118 文件 import 变更 | 中 — 最多文件 |
|
||||
| 5 | `feat(views): extract packages/views` | 18 文件 + 3 胖壳 | 中 |
|
||||
| 6 | `chore: final verification` | 0 | 低 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,868 +0,0 @@
|
||||
# Monorepo Full Extraction Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 让每个 app 只剩路由定义 + NavigationAdapter + 真正独有的功能(landing page、title bar、cookie)。所有业务逻辑、UI、状态管理、API、WS 全部在共享包里,零重复。
|
||||
|
||||
**核心洞察:** Electron renderer 就是浏览器。localStorage、fetch、WebSocket 和 Next.js 客户端页面完全一样。URL 是环境配置不是 app 差异。所以除了 NavigationAdapter(路由框架不同),没有任何东西需要在每个 app 里单独写。
|
||||
|
||||
**Architecture:** `@multica/core` 自带完整初始化(API、stores、WS),不需要每个 app 调用 factory。`@multica/views` 包含所有页面和 layout。每个 app 只提供路由壳子。
|
||||
|
||||
**Tech Stack:** React 19, TanStack Query, Zustand, Tailwind CSS v4, shadcn/ui, TypeScript strict mode.
|
||||
|
||||
**Branch:** `feat/monorepo-extraction` (from latest `feat/desktop-app`)
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
| Phase | Tasks | What it achieves |
|
||||
|---|---|---|
|
||||
| Phase 1: Core 自包含初始化 | 1-2 | core 自己初始化 API/stores/WS,app 不需要写任何 platform 代码 |
|
||||
| Phase 2: Sidebar & Layout | 3-5 | 共享 AppSidebar + DashboardLayout,删除两端重复 |
|
||||
| Phase 3: Login | 6-7 | 共享 LoginPage + AuthInitializer |
|
||||
| Phase 4: Agents | 8-10 | 1,279 行 → 共享模块 |
|
||||
| Phase 5: Inbox | 11-13 | 468 行 → 共享模块 |
|
||||
| Phase 6: Settings | 14-16 | 1,277 行 → 共享模块 |
|
||||
| Phase 7: 清理 | 17-18 | 删除所有 platform 目录、placeholder、死代码 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core 自包含初始化
|
||||
|
||||
### 设计思路
|
||||
|
||||
现在每个 app 都要手动调用 `new ApiClient()`、`createAuthStore()`、`createWorkspaceStore()`、包 `<WSProvider>`。但这些逻辑在两个 app 里完全一样。
|
||||
|
||||
方案:`@multica/core` 导出一个 `<CoreProvider>` 包裹整个应用。它内部自动完成所有初始化。配置通过环境变量(`VITE_API_URL` / `NEXT_PUBLIC_API_URL`)或 prop 注入。SSR-safe 的 localStorage wrapper 内置到 core 里作为默认 storage(`typeof window` 守卫对 Electron 无害)。
|
||||
|
||||
```tsx
|
||||
// 任何 app 的根组件,只需要这样:
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? ""}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
onLogin={setLoggedInCookie} // 可选,Web 独有
|
||||
onLogout={clearLoggedInCookie} // 可选,Web 独有
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
Desktop 更简单(没有可选回调):
|
||||
```tsx
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
### Task 1: 在 `@multica/core` 里创建 CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/platform/storage.ts` — 内置 SSR-safe localStorage
|
||||
- Create: `packages/core/platform/core-provider.tsx` — CoreProvider 组件
|
||||
- Create: `packages/core/platform/auth-initializer.tsx` — 共享 AuthInitializer
|
||||
- Create: `packages/core/platform/types.ts` — CoreProviderProps
|
||||
- Create: `packages/core/platform/index.ts` — barrel export
|
||||
- Modify: `packages/core/package.json` — add `"./platform"` export
|
||||
|
||||
**Step 1: Create built-in SSR-safe storage**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/storage.ts
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
|
||||
export const defaultStorage: StorageAdapter = {
|
||||
getItem: (k) => (typeof window !== "undefined" ? localStorage.getItem(k) : null),
|
||||
setItem: (k, v) => { if (typeof window !== "undefined") localStorage.setItem(k, v); },
|
||||
removeItem: (k) => { if (typeof window !== "undefined") localStorage.removeItem(k); },
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Create types**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/types.ts
|
||||
export interface CoreProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** API base URL. Default: "" (same-origin). */
|
||||
apiBaseUrl?: string;
|
||||
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
|
||||
wsUrl?: string;
|
||||
/** Called after successful login (e.g. set cookie for Next.js middleware). */
|
||||
onLogin?: () => void;
|
||||
/** Called after logout (e.g. clear cookie). */
|
||||
onLogout?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create AuthInitializer**
|
||||
|
||||
Merge the identical logic from both apps. Uses `defaultStorage`, reads from existing singletons.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/auth-initializer.tsx
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
export function AuthInitializer({
|
||||
children,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getApi();
|
||||
api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Create CoreProvider**
|
||||
|
||||
This is the one component that wires everything together. Each app wraps its root with this.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/core-provider.tsx
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { AuthInitializer } from "./auth-initializer";
|
||||
import type { CoreProviderProps } from "./types";
|
||||
|
||||
// Module-level singletons — created once, shared across renders.
|
||||
let initialized = false;
|
||||
let authStore: ReturnType<typeof createAuthStore>;
|
||||
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
|
||||
|
||||
function initCore(apiBaseUrl: string) {
|
||||
if (initialized) return;
|
||||
|
||||
const api = new ApiClient(apiBaseUrl, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
},
|
||||
});
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate token from storage
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
|
||||
authStore = createAuthStore({ api, storage: defaultStorage });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, {
|
||||
storage: defaultStorage,
|
||||
});
|
||||
registerWorkspaceStore(workspaceStore);
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function CoreProvider({
|
||||
children,
|
||||
apiBaseUrl = "",
|
||||
wsUrl = "ws://localhost:8080/ws",
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render
|
||||
useMemo(() => initCore(apiBaseUrl), [apiBaseUrl]);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout}>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={defaultStorage}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
</AuthInitializer>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/index.ts
|
||||
export { CoreProvider } from "./core-provider";
|
||||
export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
```
|
||||
|
||||
Add to `packages/core/package.json` exports:
|
||||
```json
|
||||
"./platform": "./platform/index.ts"
|
||||
```
|
||||
|
||||
**Step 6: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/core/platform/ packages/core/package.json
|
||||
git commit -m "feat(core): add CoreProvider — single component for full app initialization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate both apps to CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/layout.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Modify: `apps/desktop/src/renderer/src/App.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Delete: `apps/web/platform/api.ts`
|
||||
- Delete: `apps/web/platform/auth.ts`
|
||||
- Delete: `apps/web/platform/workspace.ts`
|
||||
- Delete: `apps/web/platform/storage.ts`
|
||||
- Delete: `apps/web/platform/ws-provider.tsx`
|
||||
- Delete: `apps/web/features/auth/initializer.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/api.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/workspace.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/storage.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/ws-provider.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth-initializer.tsx`
|
||||
- Keep: `apps/web/platform/navigation.tsx` — NavigationAdapter (唯一不可共享)
|
||||
- Keep: `apps/desktop/src/renderer/src/platform/navigation.tsx` — NavigationAdapter
|
||||
- Keep: `apps/web/features/auth/auth-cookie.ts` — Web 独有
|
||||
|
||||
**Step 1: Update web root layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/layout.tsx
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
<WebNavigationProvider>
|
||||
{children}
|
||||
</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update desktop App.tsx**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/App.tsx
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { router } from "./router";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL}
|
||||
wsUrl={import.meta.env.VITE_WS_URL}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Fix all `@/platform/*` imports across both apps**
|
||||
|
||||
Search all files for:
|
||||
- `from "@/platform/api"` → `from "@multica/core/api"` (use singleton proxy `api`)
|
||||
- `from "@/platform/auth"` → `from "@multica/core/auth"` (use singleton `useAuthStore`)
|
||||
- `from "@/platform/workspace"` → `from "@multica/core/workspace"` (use singleton `useWorkspaceStore`)
|
||||
|
||||
These singletons already exist and are registered by CoreProvider on init. Every component can import them directly from core.
|
||||
|
||||
**Step 4: Delete all platform files except navigation**
|
||||
|
||||
Web — delete entire `apps/web/platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/web/platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
Desktop — delete entire `apps/desktop/.../platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/desktop/.../platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
**Step 5: Run typecheck + tests**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: migrate both apps to CoreProvider — delete all platform duplication"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Sidebar & Layout
|
||||
|
||||
### Task 3: Extract `AppSidebar` to `@multica/views/layout`
|
||||
|
||||
**Why:** Web and Desktop sidebars are 99% identical (239 vs 236 lines). Only difference: `Link`/`usePathname`/`useRouter` (web) vs `AppLink`/`useNavigation` (desktop). Since `useNavigation` + `AppLink` is the abstraction in views, the desktop version is already the correct shared version.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/app-sidebar.tsx` — copy from desktop version
|
||||
- Create: `packages/views/layout/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./layout"` export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` — import from views
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` — import from views
|
||||
- Delete: `apps/web/app/(dashboard)/_components/app-sidebar.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/components/app-sidebar.tsx`
|
||||
|
||||
**Step 1: Create shared AppSidebar**
|
||||
|
||||
Copy desktop `app-sidebar.tsx` into `packages/views/layout/app-sidebar.tsx`. Key changes:
|
||||
- `import { useAuthStore } from "@multica/core/auth"` (singleton)
|
||||
- `import { useWorkspaceStore } from "@multica/core/workspace"` (singleton)
|
||||
- `import { api } from "@multica/core/api"` (singleton proxy)
|
||||
- `import { useNavigation, AppLink } from "../navigation"` (relative within views)
|
||||
- `import { useModalStore } from "@multica/core/modals"`
|
||||
- All `@multica/ui` imports unchanged
|
||||
|
||||
**Step 2: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/index.ts
|
||||
export { AppSidebar } from "./app-sidebar";
|
||||
```
|
||||
|
||||
Add to `packages/views/package.json`:
|
||||
```json
|
||||
"./layout": "./layout/index.ts"
|
||||
```
|
||||
|
||||
**Step 3: Update both apps, delete old files**
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared AppSidebar to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract `DashboardLayout` to `@multica/views/layout`
|
||||
|
||||
**Why:** Both apps have identical dashboard shell: auth guard → loading → sidebar + workspace provider + content. Only differences: web has `SearchCommand`, desktop has `TitleBar`. These are slots.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/dashboard-layout.tsx`
|
||||
- Modify: `packages/views/layout/index.ts` (add export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` (~10 lines after)
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared DashboardLayout**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/dashboard-layout.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { ModalRegistry } from "../modals/registry";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
/** Above sidebar (e.g. desktop TitleBar) */
|
||||
header?: ReactNode;
|
||||
/** Sibling of SidebarInset (e.g. web SearchCommand) */
|
||||
extra?: ReactNode;
|
||||
/** Loading indicator */
|
||||
loadingIndicator?: ReactNode;
|
||||
/** Redirect path when not authenticated. Default: "/" */
|
||||
loginPath?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
children, header, extra, loadingIndicator, loginPath = "/",
|
||||
}: DashboardLayoutProps) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) push(loginPath);
|
||||
}, [user, isLoading, push, loginPath]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<SidebarProvider className="flex-1">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{workspace ? (
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</WorkspaceIdProvider>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
{extra}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Slim down web layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/layout.tsx
|
||||
"use client";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
extra={<SearchCommand />}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Slim down desktop shell**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/components/dashboard-shell.tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { TitleBar } from "./title-bar";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
export function DashboardShell() {
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<DashboardLayout
|
||||
header={<TitleBar />}
|
||||
loginPath="/login"
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardLayout>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared DashboardLayout to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Build + smoke test
|
||||
|
||||
Run: `pnpm build && make check`
|
||||
|
||||
Fix any issues, commit:
|
||||
```bash
|
||||
git commit -m "fix: fixups from layout extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Shared Login Page
|
||||
|
||||
### Task 6: Extract `LoginPage` to `@multica/views/auth`
|
||||
|
||||
**Why:** Desktop login (139 lines) is a simple email/code form. Web login (393 lines) has extra: CLI callback, Google OAuth, OTP component. Strategy: extract the core email/code form to views. Desktop uses it directly. Web keeps its own richer version (too different to merge).
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/auth/login-page.tsx`
|
||||
- Create: `packages/views/auth/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./auth"` export)
|
||||
- Modify: `apps/desktop/src/renderer/src/pages/login.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared LoginPage**
|
||||
|
||||
Props: `logo?: ReactNode`, `onSuccess: () => void`. Internally uses `useAuthStore`/`useWorkspaceStore`/`api` from core singletons.
|
||||
|
||||
**Step 2: Update desktop login**
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "../components/multica-icon";
|
||||
import { TitleBar } from "../components/title-bar";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<TitleBar />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => navigate("/issues", { replace: true })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Web login stays as-is (CLI callback + Google OAuth = web-only features).
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract shared LoginPage to @multica/views/auth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verify login flow in both apps
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Extract Agents Page (1,279 lines → shared module)
|
||||
|
||||
### Task 8: Create `@multica/views/agents`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/agents/config.ts` — statusConfig, taskStatusConfig
|
||||
- Create: `packages/views/agents/components/agents-page.tsx` — main page
|
||||
- Create: `packages/views/agents/components/create-agent-dialog.tsx`
|
||||
- Create: `packages/views/agents/components/agent-list-item.tsx`
|
||||
- Create: `packages/views/agents/components/agent-detail.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/instructions-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/skills-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/tasks-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/settings-tab.tsx`
|
||||
- Create: `packages/views/agents/components/index.ts`
|
||||
- Create: `packages/views/agents/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./agents"` export)
|
||||
|
||||
**Key migration:** All `@/platform/*` imports → `@multica/core/*` singletons. All `@multica/ui` and `@multica/core` imports stay as-is. `@multica/views` imports become relative.
|
||||
|
||||
**Step 1:** Extract config → components → barrel
|
||||
**Step 2:** Run `pnpm typecheck`
|
||||
**Step 3:** Commit
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract agents page to @multica/views/agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire web agents route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/agents/page.tsx — 1 line replaces 1,279
|
||||
export { AgentsPage as default } from "@multica/views/agents";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace agents page with @multica/views/agents import`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Wire desktop agents route
|
||||
|
||||
```typescript
|
||||
// router.tsx
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
{ path: "agents", element: <AgentsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire agents page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Extract Inbox Page (468 lines → shared module)
|
||||
|
||||
### Task 11: Create `@multica/views/inbox`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/inbox/components/inbox-page.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-list-item.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-detail-label.tsx`
|
||||
- Create: `packages/views/inbox/components/index.ts`
|
||||
- Create: `packages/views/inbox/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./inbox"` export)
|
||||
|
||||
**Key migration:**
|
||||
- `import { useSearchParams } from "next/navigation"` → `import { useNavigation } from "../navigation"` — use `searchParams` from adapter
|
||||
- `window.history.replaceState(null, "", url)` → `replace(url)` from `useNavigation()`
|
||||
- `@/platform/*` → `@multica/core/*` singletons
|
||||
|
||||
Commit: `feat(views): extract inbox page to @multica/views/inbox`
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Wire web inbox route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/inbox/page.tsx — 1 line replaces 468
|
||||
export { InboxPage as default } from "@multica/views/inbox";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace inbox page with @multica/views/inbox import`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Wire desktop inbox route
|
||||
|
||||
```typescript
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
{ path: "inbox", element: <InboxPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire inbox page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Extract Settings Page (1,277 lines → shared module)
|
||||
|
||||
### Task 14: Create `@multica/views/settings`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/settings/components/settings-page.tsx`
|
||||
- Create: `packages/views/settings/components/account-tab.tsx`
|
||||
- Create: `packages/views/settings/components/appearance-tab.tsx`
|
||||
- Create: `packages/views/settings/components/tokens-tab.tsx`
|
||||
- Create: `packages/views/settings/components/workspace-tab.tsx`
|
||||
- Create: `packages/views/settings/components/members-tab.tsx`
|
||||
- Create: `packages/views/settings/components/repositories-tab.tsx`
|
||||
- Create: `packages/views/settings/components/index.ts`
|
||||
- Create: `packages/views/settings/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./settings"` export)
|
||||
|
||||
**Key migration:** Same pattern — `@/platform/*` → `@multica/core/*` singletons.
|
||||
|
||||
Commit: `feat(views): extract settings page to @multica/views/settings`
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Wire web settings route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/settings/page.tsx — 1 line replaces 1,277 (page + 6 tabs)
|
||||
export { SettingsPage as default } from "@multica/views/settings";
|
||||
```
|
||||
|
||||
Delete `apps/web/app/(dashboard)/settings/_components/` (all 6 files).
|
||||
|
||||
Commit: `refactor(web): replace settings page with @multica/views/settings import`
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Wire desktop settings route
|
||||
|
||||
```typescript
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
{ path: "settings", element: <SettingsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire settings page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Cleanup
|
||||
|
||||
### Task 17: Delete dead code
|
||||
|
||||
- Delete `apps/desktop/src/renderer/src/pages/placeholder.tsx`
|
||||
- Delete `apps/web/platform/` directory entirely (only `navigation.tsx` remains — move to `apps/web/app/` or `apps/web/lib/`)
|
||||
- Delete `apps/desktop/src/renderer/src/platform/` directory (only `navigation.tsx` remains — move)
|
||||
- Remove unused imports across both apps
|
||||
- Clean up `apps/web/features/auth/` — only `auth-cookie.ts` should remain
|
||||
|
||||
Commit: `chore: delete dead platform code after monorepo extraction`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Full verification
|
||||
|
||||
Run: `make check`
|
||||
Expected: ALL PASS
|
||||
|
||||
---
|
||||
|
||||
## Final Architecture
|
||||
|
||||
### Each app after extraction
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/
|
||||
│ ├── layout.tsx # CoreProvider + WebNavigationProvider + ThemeProvider
|
||||
│ ├── (auth)/login/page.tsx # Web 独有:CLI callback, Google OAuth
|
||||
│ ├── (dashboard)/
|
||||
│ │ ├── layout.tsx # DashboardLayout + SearchCommand (10 行)
|
||||
│ │ ├── issues/page.tsx # 1 行 re-export
|
||||
│ │ ├── agents/page.tsx # 1 行 re-export
|
||||
│ │ ├── inbox/page.tsx # 1 行 re-export
|
||||
│ │ ├── settings/page.tsx # 1 行 re-export
|
||||
│ │ └── ... (all 1-line)
|
||||
│ └── (landing)/ # Web 独有
|
||||
├── lib/
|
||||
│ └── navigation.tsx # WebNavigationProvider(唯一平台代码)
|
||||
├── features/
|
||||
│ ├── auth/auth-cookie.ts # Web 独有
|
||||
│ ├── landing/ # Web 独有
|
||||
│ └── search/ # Web 独有
|
||||
└── components/ # theme, icon, loading (少量)
|
||||
|
||||
apps/desktop/
|
||||
├── src/main/ # Electron 主进程
|
||||
├── src/preload/ # preload bridge
|
||||
├── src/renderer/src/
|
||||
│ ├── App.tsx # CoreProvider + RouterProvider + ThemeProvider
|
||||
│ ├── router.tsx # 路由表(全部 @multica/views/*)
|
||||
│ ├── lib/
|
||||
│ │ └── navigation.tsx # DesktopNavigationProvider(唯一平台代码)
|
||||
│ ├── components/
|
||||
│ │ ├── dashboard-shell.tsx # DashboardLayout + TitleBar (10 行)
|
||||
│ │ ├── title-bar.tsx # Desktop 独有
|
||||
│ │ └── multica-icon.tsx # Desktop 独有
|
||||
│ └── pages/
|
||||
│ └── login.tsx # LoginPage + TitleBar (10 行)
|
||||
```
|
||||
|
||||
### 数字对比
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| Web platform 文件 | 6 个 | 1 个 (navigation.tsx) |
|
||||
| Desktop platform 文件 | 7 个 | 1 个 (navigation.tsx) |
|
||||
| Web agents/page.tsx | 1,279 行 | 1 行 |
|
||||
| Web inbox/page.tsx | 468 行 | 1 行 |
|
||||
| Web settings/ 总计 | 1,277 行 | 1 行 |
|
||||
| Web sidebar | 239 行 | 0 (共享) |
|
||||
| Desktop sidebar | 236 行 (重复) | 0 (共享) |
|
||||
| Desktop placeholders | 3 个 | 0 |
|
||||
| 共享 views 模块 | 7 个 | 12 个 |
|
||||
| 两端重复代码 | ~1,500 行 | 0 行 |
|
||||
@@ -1,319 +0,0 @@
|
||||
# Upload & Attachment Fixes Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix 5 interrelated upload/attachment issues discovered during drag-upload development (MUL-410).
|
||||
|
||||
**Architecture:** Three backend fixes (content-type sniffing, Content-Disposition, list API optimization) + one frontend fix (decouple description editor uploads from attachment records) + one no-code confirmation (agent file discovery paths). All changes follow existing patterns — no new abstractions.
|
||||
|
||||
**Tech Stack:** Go backend (Chi, sqlc, S3), Next.js frontend (TanStack Query, Tiptap editor), PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| # | Issue | Type | Files |
|
||||
|---|-------|------|-------|
|
||||
| 1 | SVG content-type sniffing | Backend bug | `server/internal/handler/file.go` |
|
||||
| 2 | Content-Disposition inline vs attachment | Backend bug | `server/internal/storage/s3.go` |
|
||||
| 3 | Attachment records / editor sync | Frontend fix | `packages/views/issues/components/issue-detail.tsx` |
|
||||
| 4 | List Issues returns full description | Backend optimization | `server/pkg/db/queries/issue.sql`, `server/internal/handler/issue.go`, `server/pkg/db/generated/issue.sql.go` |
|
||||
| 5 | Agent file discovery redundancy | No code change | Confirmed by #3 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SVG Content-Type Extension Fallback
|
||||
|
||||
**Problem:** `http.DetectContentType()` returns `text/xml` for SVG files. CloudFront serves them with wrong content-type, `<img>` tags can't render.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/file.go:1-16` (imports), `server/internal/handler/file.go:123` (after sniff)
|
||||
|
||||
**Step 1: Add extension-based content-type override map and import**
|
||||
|
||||
After line 16 (imports block), add `"strings"` import and a package-level `extContentTypes` map. After line 123 (`contentType := http.DetectContentType(buf[:n])`), add fallback lookup:
|
||||
|
||||
```go
|
||||
// In imports, add "strings"
|
||||
|
||||
// After the imports block:
|
||||
var extContentTypes = map[string]string{
|
||||
".svg": "image/svg+xml",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
".json": "application/json",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
// After line 123 (contentType := http.DetectContentType(buf[:n])):
|
||||
if ct, ok := extContentTypes[strings.ToLower(path.Ext(header.Filename))]; ok {
|
||||
contentType = ct
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build and verify**
|
||||
|
||||
Run: `cd server && go build ./...`
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/handler/file.go
|
||||
git commit -m "fix(upload): add extension-based content-type fallback for SVG and other sniff-misdetected types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Content-Disposition Inline vs Attachment
|
||||
|
||||
**Problem:** All uploads set `Content-Disposition: inline`. Browsers display CSV/PDF inline instead of downloading.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/storage/s3.go:126-136` (Upload function)
|
||||
|
||||
**Step 1: Add disposition logic in Upload function**
|
||||
|
||||
Before the `PutObject` call (line 128), determine disposition based on content-type. Images, video, audio, and PDF stay `inline`; everything else becomes `attachment`:
|
||||
|
||||
```go
|
||||
// Add before PutObject call:
|
||||
func isInlineContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/") ||
|
||||
strings.HasPrefix(ct, "video/") ||
|
||||
strings.HasPrefix(ct, "audio/") ||
|
||||
ct == "application/pdf"
|
||||
}
|
||||
|
||||
// In Upload(), after sanitizeFilename:
|
||||
disposition := "attachment"
|
||||
if isInlineContentType(contentType) {
|
||||
disposition = "inline"
|
||||
}
|
||||
|
||||
// Change line 133 from:
|
||||
ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)),
|
||||
// To:
|
||||
ContentDisposition: aws.String(fmt.Sprintf(`%s; filename="%s"`, disposition, safe)),
|
||||
```
|
||||
|
||||
**Step 2: Build and verify**
|
||||
|
||||
Run: `cd server && go build ./...`
|
||||
Expected: Clean build.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/storage/s3.go
|
||||
git commit -m "fix(upload): use Content-Disposition attachment for non-media files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Decouple Description Editor Uploads from Attachment Records
|
||||
|
||||
**Problem:** Description editor uploads create attachment records linked to the issue. When users delete images from the editor, attachment records become stale. The URL already lives in the markdown — attachment records are redundant for description content.
|
||||
|
||||
**Fix:** Description editor uploads should NOT pass `issueId`. Comment/reply uploads continue passing `issueId` (comments are not frequently edited, and agents need attachment records for comment file discovery).
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/views/issues/components/issue-detail.tsx:339-341`
|
||||
|
||||
**Step 1: Remove issueId from description upload**
|
||||
|
||||
Change the `handleDescriptionUpload` callback (line 339-341) from:
|
||||
|
||||
```typescript
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
);
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```typescript
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file),
|
||||
[uploadWithToast],
|
||||
);
|
||||
```
|
||||
|
||||
This means description image uploads will still go to S3 and return a URL (which gets embedded in the markdown), but no `attachment` DB record will be linked to the issue. The backend `UploadFile` handler already handles this — when no `issue_id` form field is sent, the attachment record is created without an issue link (or falls back to the no-workspace path for non-workspace uploads, but workspace context is still present via headers so a record IS still created, just without `issue_id` set).
|
||||
|
||||
**Step 2: Verify typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: Clean.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/views/issues/components/issue-detail.tsx
|
||||
git commit -m "fix(editor): decouple description uploads from attachment records"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Omit Description from List Issues Response
|
||||
|
||||
**Problem:** `GET /api/issues` returns full `description` for every issue. With embedded images, descriptions contain CDN URLs making list payloads large. List pages only show titles.
|
||||
|
||||
**Approach:** Change `ListIssues` and `ListOpenIssues` SQL queries to select specific columns (excluding `description`, `acceptance_criteria`, `context_refs`). Regenerate sqlc. Add converter functions for the new row types. Frontend already handles `null` description gracefully.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/pkg/db/queries/issue.sql` (lines 1-8, 60-66)
|
||||
- Regenerate: `server/pkg/db/generated/issue.sql.go`
|
||||
- Modify: `server/internal/handler/issue.go` (add converters, update ListIssues handler)
|
||||
|
||||
**Step 1: Update SQL queries**
|
||||
|
||||
Change `ListIssues` (lines 1-8) from `SELECT *` to explicit columns:
|
||||
|
||||
```sql
|
||||
-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, position, due_date, created_at, updated_at, number, project_id
|
||||
FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
```
|
||||
|
||||
Change `ListOpenIssues` (lines 60-66) similarly:
|
||||
|
||||
```sql
|
||||
-- name: ListOpenIssues :many
|
||||
SELECT id, workspace_id, title, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, position, due_date, created_at, updated_at, number, project_id
|
||||
FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND status NOT IN ('done', 'cancelled')
|
||||
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
```
|
||||
|
||||
**Step 2: Regenerate sqlc**
|
||||
|
||||
Run: `make sqlc`
|
||||
|
||||
This will generate `ListIssuesRow` and `ListOpenIssuesRow` types without `Description`, `AcceptanceCriteria`, `ContextRefs`.
|
||||
|
||||
**Step 3: Add converter functions in issue.go**
|
||||
|
||||
After `issueToResponse` (line 66), add two new converters for the list row types:
|
||||
|
||||
```go
|
||||
func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueResponse {
|
||||
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
||||
return IssueResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
Number: i.Number,
|
||||
Identifier: identifier,
|
||||
Title: i.Title,
|
||||
Status: i.Status,
|
||||
Priority: i.Priority,
|
||||
AssigneeType: textToPtr(i.AssigneeType),
|
||||
AssigneeID: uuidToPtr(i.AssigneeID),
|
||||
CreatorType: i.CreatorType,
|
||||
CreatorID: uuidToString(i.CreatorID),
|
||||
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
||||
ProjectID: uuidToPtr(i.ProjectID),
|
||||
Position: i.Position,
|
||||
DueDate: timestampToPtr(i.DueDate),
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
UpdatedAt: timestampToString(i.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
|
||||
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
|
||||
return IssueResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
Number: i.Number,
|
||||
Identifier: identifier,
|
||||
Title: i.Title,
|
||||
Status: i.Status,
|
||||
Priority: i.Priority,
|
||||
AssigneeType: textToPtr(i.AssigneeType),
|
||||
AssigneeID: uuidToPtr(i.AssigneeID),
|
||||
CreatorType: i.CreatorType,
|
||||
CreatorID: uuidToString(i.CreatorID),
|
||||
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
||||
ProjectID: uuidToPtr(i.ProjectID),
|
||||
Position: i.Position,
|
||||
DueDate: timestampToPtr(i.DueDate),
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
UpdatedAt: timestampToString(i.UpdatedAt),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Update ListIssues handler**
|
||||
|
||||
In `ListIssues` handler:
|
||||
- Line 257: change `issueToResponse(issue, prefix)` → `openIssueRowToResponse(issue, prefix)`
|
||||
- Line 312: change `issueToResponse(issue, prefix)` → `issueListRowToResponse(issue, prefix)`
|
||||
|
||||
**Step 5: Build and verify**
|
||||
|
||||
Run: `cd server && go build ./...`
|
||||
Expected: Clean build.
|
||||
|
||||
**Frontend impact (no changes needed):**
|
||||
- Board card (board-card.tsx:61): `storeProperties.description && issue.description` — short-circuits on `null`, won't render description. Correct behavior.
|
||||
- Issue detail (issue-detail.tsx:210): `initialData: () => allIssues.find(...)` — the seeded issue will have `null` description, but the detail query fetches full issue with description. Brief loading state is acceptable.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/pkg/db/queries/issue.sql server/pkg/db/generated/issue.sql.go server/internal/handler/issue.go
|
||||
git commit -m "perf(api): omit description from list issues response to reduce payload size"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Confirm Agent File Discovery (No Code Change)
|
||||
|
||||
**Confirmation:** With Task 3 implemented:
|
||||
- **Description files:** Agent reads issue description markdown → finds CDN URLs directly. No attachment record needed.
|
||||
- **Comment files:** Agent uses `GET /api/issues/{id}` → `attachments` array for issue-linked files, plus comment content markdown URLs.
|
||||
- **CLI attachment download:** `multica attachment download <id>` works for files that DO have attachment records (comment uploads).
|
||||
- **No redundancy:** Two paths serve different purposes — markdown URLs for inline content, attachment records for standalone files.
|
||||
|
||||
No code change required. This task is resolved by Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Run Full Verification
|
||||
|
||||
**Step 1: Run all checks**
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
This runs: typecheck → TS tests → Go tests → E2E.
|
||||
|
||||
**Step 2: Fix any failures and re-run**
|
||||
|
||||
**Step 3: Final commit if any fixes needed**
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
Tasks 1, 2, 3 are independent — can be parallelized.
|
||||
Task 4 depends on sqlc regeneration.
|
||||
Task 5 is confirmation only.
|
||||
Task 6 runs after all code changes.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,357 +0,0 @@
|
||||
# Unify Workspace Identity Resolver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix broken file uploads caused by the workspace slug refactor (v2, PR #1138/#1141), and eliminate the structural bug source that allowed it. File uploads from within a workspace on the desktop and web apps currently land in S3 without a corresponding DB attachment record — the file is orphaned and the UI never sees it.
|
||||
|
||||
**Architecture:** The server currently has **two independent implementations** of the same logic — extract the workspace UUID from an HTTP request. One lives in the workspace middleware (post-v2, accepts slug header → DB lookup → UUID). The other lives inside the handler package (pre-v2, only accepts UUID header/query). The v2 refactor updated the middleware one and forgot the handler one; routes that sit *outside* the workspace middleware group (notably `/api/upload-file`) still run through the stale resolver and can't translate the frontend's new `X-Workspace-Slug` header.
|
||||
|
||||
The root cause is duplication. The fix is to collapse both resolvers into a single shared function that middleware and handlers both delegate to, so any future change to "how do we read workspace identity" is impossible to forget. The existing middleware's resolver already has the full logic; we extract it into a package-level function and have the handler helper call it.
|
||||
|
||||
**Tech Stack:** Go (Chi router, sqlc, pgx).
|
||||
|
||||
**Non-goals:**
|
||||
- No frontend changes. The frontend has been sending `X-Workspace-Slug` since v2; this plan makes the server finish accepting it everywhere.
|
||||
- No route reshuffling. `/api/upload-file` stays outside `RequireWorkspaceMember` because it serves two distinct use cases (avatar upload + workspace attachment); the avatar path needs to work without a workspace context.
|
||||
- No change to CLI / daemon clients. They still send `X-Workspace-ID` (UUID); the resolver keeps UUID as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| # | Change | Type | Files |
|
||||
|---|--------|------|-------|
|
||||
| 1 | Extract shared resolver into middleware package | Refactor | `server/internal/middleware/workspace.go` |
|
||||
| 2 | Promote handler `resolveWorkspaceID` to `(h *Handler).resolveWorkspaceID` + delegate to shared | Refactor | `server/internal/handler/handler.go` |
|
||||
| 3 | Rename 47 call sites from `resolveWorkspaceID(r)` → `h.resolveWorkspaceID(r)` | Mechanical | handler/*.go (see exhaustive list in task 3) |
|
||||
| 4 | Add test for upload-file with slug header | Test | `server/internal/handler/file_test.go` |
|
||||
| 5 | Add test for shared resolver | Test | `server/internal/middleware/workspace_test.go` |
|
||||
| 6 | `make check` and commit | Verify | — |
|
||||
|
||||
---
|
||||
|
||||
## Background: what's broken and why
|
||||
|
||||
**Frontend (current, post-v2):** `ApiClient.authHeaders()` in `packages/core/api/client.ts:121` sends:
|
||||
```
|
||||
X-Workspace-Slug: <slug>
|
||||
```
|
||||
|
||||
**Server middleware resolver** (`server/internal/middleware/workspace.go:53-86`, `resolveWorkspaceUUID`): accepts the slug header, looks up the slug via `queries.GetWorkspaceBySlug`, and writes the resolved UUID into the request context. Every handler behind `RequireWorkspaceMember` / `RequireWorkspaceRole` / `RequireWorkspaceMemberFromURL` sees the UUID in context and works correctly.
|
||||
|
||||
**Handler resolver** (`server/internal/handler/handler.go:155-165`, `resolveWorkspaceID`): a parallel implementation used by handlers that are NOT behind the workspace middleware. It only checks:
|
||||
1. `middleware.WorkspaceIDFromContext(r.Context())`
|
||||
2. `?workspace_id` query param
|
||||
3. `X-Workspace-ID` header
|
||||
|
||||
Never touches slug, because it has no `*db.Queries` access (it's a package-level function, not a method).
|
||||
|
||||
**Impact:** `/api/upload-file` (registered at `server/cmd/server/router.go:166`, in the user-scoped group, outside workspace middleware) calls `resolveWorkspaceID(r)`, gets `""` because the frontend only sends slug, thinks "no workspace context", and silently skips the DB attachment record creation (`server/internal/handler/file.go:235-245`). The file reaches S3; the UI never sees it.
|
||||
|
||||
**Why `/api/upload-file` is outside workspace middleware:** it serves both "avatar upload (no workspace)" and "attachment upload (with workspace)", branching on the resolved workspace ID inside the handler. Moving it under `RequireWorkspaceMember` would break avatar uploads.
|
||||
|
||||
**Structural root cause:** two resolvers, same job, divergent capabilities. The duplication is what let v2 ship "mostly working" — most handlers live behind middleware, so the broken handler resolver had a low blast radius that wasn't caught in review.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract shared resolver into middleware package
|
||||
|
||||
**Problem:** The middleware's `resolveWorkspaceUUID` closure captures `*db.Queries` and can look up slugs. The handler's `resolveWorkspaceID` is a bare package-level function without queries access. We need a single implementation both sides can reuse. Putting it in the `middleware` package is fine — the `handler` package already imports `middleware`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/middleware/workspace.go`
|
||||
|
||||
**Step 1: Add `ResolveWorkspaceIDFromRequest` export**
|
||||
|
||||
After `errWorkspaceNotFound` (around line 45), add a package-level exported function that takes `(r *http.Request, queries *db.Queries)` and returns the workspace UUID as a string (empty if none found or slug doesn't resolve).
|
||||
|
||||
Priority order (mirrors `resolveWorkspaceUUID`, plus a context lookup first so handlers behind middleware still get the fast path):
|
||||
|
||||
```go
|
||||
// ResolveWorkspaceIDFromRequest returns the workspace UUID for an HTTP
|
||||
// request, using the same priority order as the workspace middleware.
|
||||
// Handlers behind workspace middleware get it from context (cheap); handlers
|
||||
// outside middleware (e.g. /api/upload-file) still resolve slug → UUID via
|
||||
// a DB lookup instead of silently falling through to "no workspace".
|
||||
//
|
||||
// Priority:
|
||||
// 1. middleware-injected context (if the route is behind workspace middleware)
|
||||
// 2. X-Workspace-Slug header → GetWorkspaceBySlug → UUID (post-refactor frontend)
|
||||
// 3. ?workspace_slug query → GetWorkspaceBySlug → UUID
|
||||
// 4. X-Workspace-ID header (CLI/daemon compat)
|
||||
// 5. ?workspace_id query (CLI/daemon compat)
|
||||
//
|
||||
// Returns "" when no identifier was provided OR a slug was provided but doesn't
|
||||
// resolve to any workspace. Callers that need the "slug provided but invalid"
|
||||
// distinction should use the resolver inside the middleware directly.
|
||||
func ResolveWorkspaceIDFromRequest(r *http.Request, queries *db.Queries) string {
|
||||
if id := WorkspaceIDFromContext(r.Context()); id != "" {
|
||||
return id
|
||||
}
|
||||
if slug := r.Header.Get("X-Workspace-Slug"); slug != "" {
|
||||
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
|
||||
return util.UUIDToString(ws.ID)
|
||||
}
|
||||
}
|
||||
if slug := r.URL.Query().Get("workspace_slug"); slug != "" {
|
||||
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
|
||||
return util.UUIDToString(ws.ID)
|
||||
}
|
||||
}
|
||||
if id := r.Header.Get("X-Workspace-ID"); id != "" {
|
||||
return id
|
||||
}
|
||||
return r.URL.Query().Get("workspace_id")
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Refactor `resolveWorkspaceUUID` to delegate**
|
||||
|
||||
The existing middleware closure has slightly different semantics (returns `errWorkspaceNotFound` when a slug was provided but doesn't resolve, so middleware can 404 instead of 400). Keep that, but share the resolution logic:
|
||||
|
||||
Leave `resolveWorkspaceUUID` as-is for now — it distinguishes "no identifier" (400) from "invalid slug" (404). `ResolveWorkspaceIDFromRequest` returns "" in both cases because handler-level callers don't need that distinction (they just check for empty).
|
||||
|
||||
Document in a comment near `resolveWorkspaceUUID` that it's an internal variant that preserves the error distinction for middleware gating, and point to `ResolveWorkspaceIDFromRequest` as the handler-facing API.
|
||||
|
||||
**Step 3: Build and verify**
|
||||
|
||||
```bash
|
||||
cd server && go build ./...
|
||||
```
|
||||
Expected: clean build.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
refactor(server): extract ResolveWorkspaceIDFromRequest from middleware
|
||||
|
||||
Introduces a shared helper that consolidates the workspace-identity
|
||||
resolution logic used by both the workspace middleware and the handler
|
||||
package. No behavior change yet — callers still use the old functions.
|
||||
Sets up the next commit to fix the /api/upload-file slug bug by routing
|
||||
the handler-side resolver through this shared function.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Promote handler resolver to a method + delegate
|
||||
|
||||
**Problem:** The package-level `resolveWorkspaceID(r *http.Request)` in `handler.go` can't call `GetWorkspaceBySlug` because it has no queries access. Promoting it to a method on `*Handler` gives it access to `h.Queries` at no syntactic cost elsewhere.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/handler.go:155-165`
|
||||
|
||||
**Step 1: Replace `resolveWorkspaceID` with a Handler method**
|
||||
|
||||
```go
|
||||
// resolveWorkspaceID resolves the workspace UUID for this request.
|
||||
// Delegates to middleware.ResolveWorkspaceIDFromRequest so routes inside
|
||||
// and outside workspace middleware see identical resolution behavior.
|
||||
//
|
||||
// Returns "" when no workspace identifier was provided or a slug was
|
||||
// provided but doesn't match any workspace.
|
||||
func (h *Handler) resolveWorkspaceID(r *http.Request) string {
|
||||
return middleware.ResolveWorkspaceIDFromRequest(r, h.Queries)
|
||||
}
|
||||
```
|
||||
|
||||
Delete the old package-level `resolveWorkspaceID` function.
|
||||
|
||||
**Step 2: Build — expect errors at 47 call sites**
|
||||
|
||||
```bash
|
||||
cd server && go build ./... 2>&1 | head -60
|
||||
```
|
||||
|
||||
Expected: `resolveWorkspaceID is not a value` or `undefined: resolveWorkspaceID` errors at each existing call site. That's the signal to run Task 3.
|
||||
|
||||
**Do not commit yet.** Task 2 and 3 are a single logical change; they commit together after Task 3 fixes the compile.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rename 47 call sites to `h.resolveWorkspaceID(r)`
|
||||
|
||||
**Problem:** Every `resolveWorkspaceID(r)` call in the handler package now fails to compile because the function became a method. All 47 call sites are inside methods on `*Handler` (or similar receiver types that have access to `h`), so the rename is mechanical.
|
||||
|
||||
**Files affected** (verified via `grep -rn "resolveWorkspaceID" server/internal/handler/`):
|
||||
|
||||
- `server/internal/handler/handler.go:275, 365, 388` (3 sites)
|
||||
- `server/internal/handler/issue.go:447, 559, 731, 783, 1294, 1476` (6 sites)
|
||||
- `server/internal/handler/activity.go:133` (1 site)
|
||||
- `server/internal/handler/autopilot.go:178, 203, 255, 306, 386, 414, 490, 578, 615, 662` (10 sites)
|
||||
- `server/internal/handler/project.go:80, 127, 150, 192, 273, 430` (6 sites)
|
||||
- `server/internal/handler/comment.go:443, 510` (2 sites)
|
||||
- `server/internal/handler/runtime.go:207, 247, 296` (3 sites)
|
||||
- `server/internal/handler/pin.go:59, 105, 175, 202` (4 sites)
|
||||
- `server/internal/handler/reaction.go:43, 110` (2 sites)
|
||||
- `server/internal/handler/skill.go:126, 146, 187, 384, 815` (5 sites)
|
||||
- `server/internal/handler/agent.go:158, 254` (2 sites)
|
||||
- `server/internal/handler/file.go:83, 115, 282, 306` (4 sites)
|
||||
|
||||
Total: 48 (the resolver declaration itself + 47 callers).
|
||||
|
||||
**Step 1: Mechanical rename**
|
||||
|
||||
For each file above, change every `resolveWorkspaceID(r)` to `h.resolveWorkspaceID(r)`. In the one case in `file.go:83` inside `groupAttachments`, the receiver is already `*Handler`, so the method is accessible.
|
||||
|
||||
**Semantic check:** all 47 call sites are on methods with an `h *Handler` receiver (verifiable by scrolling up a few lines from each grep match). If any call site is inside a non-method function, that site needs to either take `*Handler` as a parameter or be skipped from this rename. Spot-check three sites before doing the rename.
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
```bash
|
||||
cd server && go build ./...
|
||||
```
|
||||
Expected: clean build.
|
||||
|
||||
**Step 3: Run Go tests**
|
||||
|
||||
```bash
|
||||
cd server && go test ./...
|
||||
```
|
||||
Expected: all pass. The 46 call sites behind workspace middleware hit the context branch (identical behavior to before). Only `UploadFile` gains new capability (slug resolution); it wasn't tested before, will be covered in Task 4.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
fix(server): resolve X-Workspace-Slug in /api/upload-file and other middleware-less handlers
|
||||
|
||||
The v2 workspace URL refactor updated the workspace middleware to accept
|
||||
X-Workspace-Slug but left the handler-package resolveWorkspaceID helper
|
||||
(used by handlers outside the middleware group) stuck on X-Workspace-ID.
|
||||
The frontend switched to the slug header, so /api/upload-file was
|
||||
receiving a slug it couldn't translate to a UUID, silently falling
|
||||
through to the avatar-upload branch and skipping DB attachment record
|
||||
creation — files were landing in S3 with no database reference.
|
||||
|
||||
Promote resolveWorkspaceID to a Handler method and delegate to the new
|
||||
middleware.ResolveWorkspaceIDFromRequest so middleware-behind and
|
||||
middleware-outside handlers share the same resolution logic. The 46
|
||||
call sites that live inside the workspace middleware group are
|
||||
unaffected (context lookup still wins). /api/upload-file now correctly
|
||||
recognizes slug requests and creates the attachment record.
|
||||
|
||||
Fixes: missing DB attachment rows for files uploaded since v2 (#1141)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add handler test for upload-file with slug header
|
||||
|
||||
**Problem:** The bug manifested exactly because there was no test covering the "upload-file with only a slug header" code path. Prevent regression.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/file_test.go` (or create if absent)
|
||||
|
||||
**Step 1: Locate existing upload-file test infrastructure**
|
||||
|
||||
```bash
|
||||
grep -rn "UploadFile\|upload-file" server/internal/handler/*_test.go
|
||||
```
|
||||
|
||||
If there's an existing upload-file test, add a new test case alongside it. If not, scaffold one using the same `handler_test.go` fixture pattern (`testWorkspaceID`, `testUserID`, seeded workspace).
|
||||
|
||||
**Step 2: Write the test**
|
||||
|
||||
Test name: `TestUploadFile_ResolvesWorkspaceViaSlugHeader`.
|
||||
|
||||
Flow:
|
||||
1. Seed a workspace with a known slug and the default test user as a member.
|
||||
2. POST a multipart form to `/api/upload-file` with an `issue_id` field referencing a seeded issue, with only `X-Workspace-Slug: <slug>` in headers (no `X-Workspace-ID`).
|
||||
3. Assert response is 200.
|
||||
4. Assert a DB row exists in `attachments` with the expected `workspace_id`, `uploader_id`, `issue_id`, and `filename`.
|
||||
|
||||
Anti-regression: also add `TestUploadFile_ResolvesWorkspaceViaIDHeaderStill` to confirm legacy `X-Workspace-ID` header still works (CLI / daemon compat).
|
||||
|
||||
**Step 3: Run the new test**
|
||||
|
||||
```bash
|
||||
cd server && go test ./internal/handler/ -run UploadFile
|
||||
```
|
||||
Expected: both pass.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
test(server): cover upload-file slug and UUID header resolution
|
||||
|
||||
Regression test for the v2 refactor bug: uploads from the frontend
|
||||
(which sends X-Workspace-Slug) now reach the workspace-aware branch
|
||||
and create attachment records.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add unit test for the shared resolver
|
||||
|
||||
**Problem:** The shared function will be the single point through which all workspace identity resolution flows. It deserves table-driven test coverage for each priority level.
|
||||
|
||||
**Files:**
|
||||
- Create or modify: `server/internal/middleware/workspace_test.go`
|
||||
|
||||
**Step 1: Table test**
|
||||
|
||||
Cases to cover:
|
||||
- Context UUID present → returns context UUID, ignores headers/query
|
||||
- Only `X-Workspace-Slug` → DB lookup succeeds → returns UUID
|
||||
- Only `X-Workspace-Slug` → DB lookup fails → returns ""
|
||||
- Only `?workspace_slug` → DB lookup succeeds → returns UUID
|
||||
- Only `X-Workspace-ID` → returns UUID
|
||||
- Only `?workspace_id` → returns UUID
|
||||
- Slug header + UUID header both present → slug wins (frontend priority)
|
||||
- Nothing → returns ""
|
||||
|
||||
**Step 2: Run**
|
||||
|
||||
```bash
|
||||
cd server && go test ./internal/middleware/ -run ResolveWorkspaceIDFromRequest
|
||||
```
|
||||
Expected: all cases pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```
|
||||
test(server): table-driven coverage for ResolveWorkspaceIDFromRequest
|
||||
|
||||
Pins down the priority order (context > slug header > slug query >
|
||||
UUID header > UUID query) so future changes can't silently diverge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full verification
|
||||
|
||||
**Step 1: `make check`**
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
Expected: typecheck, TS tests, Go tests, E2E (if backend+frontend up) all green.
|
||||
|
||||
**Step 2: Manual smoke test**
|
||||
|
||||
1. Start desktop dev environment.
|
||||
2. Open an issue, attach a file via drag-and-drop or the file picker.
|
||||
3. Refresh the issue. The attachment should appear in the attachments list.
|
||||
|
||||
Before this fix: attachment silently disappears on refresh (file is in S3, DB has no row).
|
||||
|
||||
**Step 3: Open PR**
|
||||
|
||||
Branch name: `fix/unify-workspace-identity-resolver`.
|
||||
|
||||
Title: `fix(server): resolve X-Workspace-Slug in middleware-less handlers`
|
||||
|
||||
Body should:
|
||||
- Link to the symptom PR (v2 refactor #1141) and reference that it's a latent follow-up.
|
||||
- Describe the structural change (two resolvers → one).
|
||||
- Note that 46 of 47 call sites see zero behavior change (context branch wins); only `/api/upload-file` gains capability.
|
||||
|
||||
---
|
||||
|
||||
## Risk / blast radius
|
||||
|
||||
**Low risk.** The 46 middleware-protected callers hit the context branch in `ResolveWorkspaceIDFromRequest` identically to how they hit `WorkspaceIDFromContext` before — zero semantic change. The only new code path exercised in production is the slug-header branch for `/api/upload-file`, which is already exercised by every other slug-header-carrying request (just via the middleware's version of the same logic). Task 4 and 5 lock the behavior down with tests.
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If a regression surfaces after deploy, revert the single commit from Task 3. `ResolveWorkspaceIDFromRequest` and the Handler method remain but are unused — harmless dead code until the next attempt.
|
||||
@@ -1,109 +0,0 @@
|
||||
# Workspace URL 化重构 — 项目汇报
|
||||
|
||||
**日期**:2026-04-15
|
||||
**作者**:Naiyuan
|
||||
**状态**:调研完成,待评审
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里**不含任何 workspace 信息**。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
这个设计已经在产品里直接表现为 3 个已知问题:
|
||||
|
||||
1. **分享链接不可靠**(MUL-43):`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
|
||||
2. **手机端无法切 workspace**(MUL-509):切换只靠 sidebar UI,手机端不展开 sidebar 就没有切换入口
|
||||
3. **多 tab 互相覆盖**:`multica_workspace_id` 是全局 localStorage key,两个 tab 打开不同 workspace 会互相污染
|
||||
|
||||
除了这 3 个显性 bug,架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
|
||||
|
||||
行业惯例(Linear / Notion / Vercel / GitHub)都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
|
||||
|
||||
## 二、调研结论
|
||||
|
||||
### 好消息:基础设施已经就位
|
||||
|
||||
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
|
||||
- 后端已有 `GetWorkspaceBySlug` 查询
|
||||
- 前端 `Workspace` 类型已包含 `slug` 字段
|
||||
- Web 端认证已经切换为 HttpOnly cookie 模式,Next.js middleware 可读到登录态
|
||||
|
||||
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
|
||||
|
||||
### 坏消息:范围比最初估计大
|
||||
|
||||
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
|
||||
|
||||
1. **URL 路由重组**:web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`;desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
|
||||
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions(切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
|
||||
3. **所有路径引用替换**:`push("/issues")` 改为 path builder(`paths.issues()`),影响 ~25 个组件文件
|
||||
4. **Mutation 副作用重构**:`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
|
||||
5. **桌面端 tab 系统适配**:tab 路径天然包含 workspace,切 workspace = 开新 tab 或导航,不再有全局切换动作
|
||||
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug),需要更新
|
||||
7. **后端保留词校验**:slug 不能和前端顶级路由冲突(`login`、`onboarding`、`invite`、`api`、`settings` 等),后端创建时校验
|
||||
8. **内部 markdown 链接兼容**:issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
|
||||
|
||||
### 不需要改的(边界已确认)
|
||||
|
||||
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
|
||||
- `mention://type/id` 协议 — 只存 UUID,workspace-agnostic
|
||||
- CLI 登录 URL — `/login` 也是 pre-workspace,不需要 slug
|
||||
- 后端 API 路径 — 保持 `/api/workspaces/{id}`,slug 仅用于前端 URL
|
||||
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
**核心原则**:URL 是 workspace 上下文的唯一 source of truth,其他状态都是派生态。
|
||||
|
||||
**URL 形状**:`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
|
||||
|
||||
**切换 workspace = 导航**:sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
|
||||
|
||||
**预估影响面**:~30-35 个文件,其中约 20 个是机械替换(hardcoded 路径 → path builder),真正需要思考的核心逻辑改动集中在 5-6 个文件。
|
||||
|
||||
**一个 PR 合并**:中间状态不可运行(URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
|
||||
|
||||
## 四、执行与测试计划
|
||||
|
||||
### 执行阶段
|
||||
|
||||
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
|
||||
2. **下一步**:在独立 worktree 上开发,AI 辅助写代码,过程中人工 review
|
||||
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E)
|
||||
|
||||
### 测试阶段
|
||||
|
||||
1. **本地自测**:
|
||||
- 已知功能路径(创建 / 浏览 / 搜索 issue,切换 workspace,接受邀请,分享链接)
|
||||
- 已知 bug 场景(MUL-43 / MUL-509 / MUL-727 / MUL-820)逐一验证已修复
|
||||
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
|
||||
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
|
||||
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程)
|
||||
- URL 使用感受(分享、收藏、刷新)
|
||||
3. **灰度 / 生产**:测试环境稳定后推生产
|
||||
|
||||
### 风险提示
|
||||
|
||||
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404(产品还没正式 ship、用户量可忽略,所以不做兼容性重定向)
|
||||
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
|
||||
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate(可能性极低,因 slug 限制较严)
|
||||
|
||||
## 五、附注
|
||||
|
||||
这次重构会**顺带修掉**以下已登记 issue,不需要单独开 PR:
|
||||
|
||||
| Issue | 修复方式 |
|
||||
|---|---|
|
||||
| MUL-43(切换 workspace 报错 / 分享链接失效) | URL 带 slug,根本解决 |
|
||||
| MUL-509(手机端无法切 workspace) | 切换变导航,手机能点链接就能切 |
|
||||
| MUL-723(workspace 不在 URL) | 核心目标 |
|
||||
| MUL-727(创建 workspace 闪 /issues) | 删除 mutation 里的 switchWorkspace 副作用 |
|
||||
| MUL-728(删除 workspace 后留在 /settings) | 删除成功后 navigate 到下一个 workspace |
|
||||
| MUL-820(sidebar Join 不切 workspace) | Join 改成跳转到 `/invite/{id}` 走统一路径 |
|
||||
|
||||
不在本次范围内的:Issue #951(WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
|
||||
|
||||
---
|
||||
|
||||
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。
|
||||
@@ -109,13 +109,13 @@ export function useRouteAnchorCandidate(wsId: string): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus-mode toggle. Three visual states driven by two dimensions:
|
||||
* - focusMode (persisted) on | off
|
||||
* - candidate present yes | no
|
||||
* 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.
|
||||
*
|
||||
* off → ghost + muted, clickable (→ turns on)
|
||||
* no candidate → disabled
|
||||
* off + candidate → ghost + muted, clickable (→ turns on)
|
||||
* on + candidate → secondary (bright), clickable (→ turns off)
|
||||
* on + no candidate → disabled (can't click until a focus target exists)
|
||||
*/
|
||||
export function ContextAnchorButton() {
|
||||
const wsId = useWorkspaceId();
|
||||
@@ -124,16 +124,16 @@ export function ContextAnchorButton() {
|
||||
const setFocusMode = useChatStore((s) => s.setFocusMode);
|
||||
|
||||
const hasAnchor = !!candidate;
|
||||
const isDisabled = focusMode && !hasAnchor && !isResolving;
|
||||
const isDisabled = !hasAnchor && !isResolving;
|
||||
const isBright = focusMode && hasAnchor;
|
||||
|
||||
const tooltipText = !focusMode
|
||||
? "Let Multica know what you're viewing"
|
||||
: 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`
|
||||
: "Nothing to share with Multica on this page";
|
||||
: "Let Multica know what you're viewing";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
|
||||
Reference in New Issue
Block a user