mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 22:17:11 +02:00
Compare commits
105 Commits
agent/lamb
...
v0.2.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387f76d328 | ||
|
|
3fd2fb2ae3 | ||
|
|
1a565a221a | ||
|
|
536f4286f1 | ||
|
|
c6d54e8ce5 | ||
|
|
20c9d985f5 | ||
|
|
6366e2f4ba | ||
|
|
642844c736 | ||
|
|
6ecf15e62c | ||
|
|
52c9bd72cb | ||
|
|
7ada72faa6 | ||
|
|
df86f559e0 | ||
|
|
d5071abb75 | ||
|
|
ba003eee83 | ||
|
|
a3a6158d96 | ||
|
|
9481350ef0 | ||
|
|
637bdc8eb3 | ||
|
|
6f63fae41a | ||
|
|
c5a00d8b8c | ||
|
|
4ac43e9e49 | ||
|
|
03e21aee80 | ||
|
|
632fdde700 | ||
|
|
cc1ccedaf3 | ||
|
|
8eb81aa396 | ||
|
|
965bf731ab | ||
|
|
0db7d2fb64 | ||
|
|
4368e1be18 | ||
|
|
bb31afbbce | ||
|
|
4a25b91590 | ||
|
|
9e47b83f02 | ||
|
|
b291db11c2 | ||
|
|
824d943848 | ||
|
|
779c72e835 | ||
|
|
e830575efc | ||
|
|
193046fabc | ||
|
|
c76c790b32 | ||
|
|
07034f4455 | ||
|
|
9fa08fb16a | ||
|
|
cf74327aa6 | ||
|
|
951f51408a | ||
|
|
be78b66e4e | ||
|
|
ec73710dd2 | ||
|
|
62a7c05589 | ||
|
|
c0be1b7ce9 | ||
|
|
4ce3e5ddf4 | ||
|
|
bd445782d5 | ||
|
|
5fa1da448f | ||
|
|
556c68292f | ||
|
|
96ee5bba52 | ||
|
|
2ab89d4690 | ||
|
|
b428f36ca6 | ||
|
|
239ce3d40f | ||
|
|
a7e9801c83 | ||
|
|
b8907dda8d | ||
|
|
6cd49e132d | ||
|
|
a6db465e46 | ||
|
|
965561a6cc | ||
|
|
163f34f918 | ||
|
|
2317533da4 | ||
|
|
d81e6a14a6 | ||
|
|
e198a67f8f | ||
|
|
0ed16fc1b1 | ||
|
|
746f33a38b | ||
|
|
aa9305f7e4 | ||
|
|
63800f05ff | ||
|
|
133a1f1c16 | ||
|
|
b1b66ab05d | ||
|
|
0fc9641bf6 | ||
|
|
4223d32b37 | ||
|
|
b2307a5ee9 | ||
|
|
c85c43ed0e | ||
|
|
eecb3a2bc8 | ||
|
|
2c1478a69c | ||
|
|
bf31fa4b39 | ||
|
|
9b45e0d4a6 | ||
|
|
7c6158f3c9 | ||
|
|
4bd8533269 | ||
|
|
488aed6abf | ||
|
|
a73336dcf8 | ||
|
|
ce610a6414 | ||
|
|
5a6a44a69e | ||
|
|
423ceaf8f4 | ||
|
|
9e15b17c92 | ||
|
|
e9131dfe2b | ||
|
|
462ff88df5 | ||
|
|
ea02a394dc | ||
|
|
fe01d58064 | ||
|
|
fc1938fe7d | ||
|
|
1ea6e6a078 | ||
|
|
c15212c0e4 | ||
|
|
b5de04da59 | ||
|
|
131fee36d7 | ||
|
|
c157f74a4d | ||
|
|
702156904a | ||
|
|
3ea6b5c7b8 | ||
|
|
c22a9bd88e | ||
|
|
dcd050ca69 | ||
|
|
80a24bf627 | ||
|
|
65e2bf937e | ||
|
|
763c0cd25f | ||
|
|
c2f7dc49f8 | ||
|
|
0a1c82730f | ||
|
|
7dc37e87df | ||
|
|
cf8a9647bb | ||
|
|
d7a8e9041e |
49
.env.example
49
.env.example
@@ -4,8 +4,23 @@ POSTGRES_USER=multica
|
||||
POSTGRES_PASSWORD=multica
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
|
||||
# You can also set pool_max_conns / pool_min_conns as query params on
|
||||
# DATABASE_URL; env vars below take precedence over URL params.
|
||||
# DATABASE_MAX_CONNS=25
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
@@ -22,7 +37,8 @@ MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
@@ -40,6 +56,13 @@ CLOUDFRONT_KEY_PAIR_ID=
|
||||
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
|
||||
CLOUDFRONT_PRIVATE_KEY=
|
||||
CLOUDFRONT_DOMAIN=
|
||||
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
|
||||
# Leave empty for single-host deployments (localhost, LAN IP, or a single
|
||||
# hostname) — session cookies become host-only, which is what the browser
|
||||
# wants. Only set it when the frontend and backend sit on different
|
||||
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
|
||||
# to an IP address: RFC 6265 forbids IP literals in the cookie Domain
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
@@ -63,3 +86,27 @@ NEXT_PUBLIC_WS_URL=
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
|
||||
# ==================== Self-hosting: Control Signups (fixes #930) ====================
|
||||
# Set to "false" to completely disable new user signups (recommended for private instances)
|
||||
ALLOW_SIGNUP=true
|
||||
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
|
||||
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
|
||||
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
|
||||
NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||
|
||||
# Optional: Only allow emails from these domains (comma-separated)
|
||||
ALLOWED_EMAIL_DOMAINS=
|
||||
|
||||
# Optional: Only allow these exact email addresses (comma-separated)
|
||||
ALLOWED_EMAILS=
|
||||
|
||||
# ==================== Analytics (PostHog) ====================
|
||||
# Product analytics events feed the acquisition → activation → expansion funnel.
|
||||
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
|
||||
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build, type check, and test
|
||||
run: pnpm build && pnpm typecheck && pnpm test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
59
.github/workflows/desktop-smoke.yml
vendored
Normal file
59
.github/workflows/desktop-smoke.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Desktop Smoke Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
desktop:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish never
|
||||
|
||||
- name: Upload Desktop artifacts (${{ matrix.target }})
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.target }}
|
||||
path: apps/desktop/dist
|
||||
if-no-files-found: error
|
||||
75
.github/workflows/release.yml
vendored
75
.github/workflows/release.yml
vendored
@@ -3,7 +3,10 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# GitHub Actions uses glob patterns here, not regex. Match versioned
|
||||
# tags broadly at the trigger layer, then enforce strict semver below.
|
||||
- "v*.*.*"
|
||||
- "!v*-dirty*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -17,6 +20,19 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag name
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
echo "Triggered by tag: $tag"
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$tag" == *-dirty* ]]; then
|
||||
echo "::error::Refusing to release from dirty tag '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -34,3 +50,60 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
# Build the Desktop installers for Linux and Windows and upload them to
|
||||
# the GitHub Release that the `release` job above just published. macOS
|
||||
# Desktop continues to ship via the manual `release-desktop` skill so it
|
||||
# can be signed + notarized with Apple Developer credentials that are
|
||||
# not (yet) wired into CI.
|
||||
desktop:
|
||||
needs: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# electron-builder's GitHub publisher reads this:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable code signing on Linux/Windows for now — the public
|
||||
# release is unsigned for these platforms, the CLI carries the
|
||||
# trust boundary. Set CSC_LINK in repo secrets to enable
|
||||
# Windows signing later.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always
|
||||
|
||||
@@ -21,12 +21,12 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
# Legacy archive name kept so already-released CLIs (whose `multica update`
|
||||
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
|
||||
# once those versions are no longer in use.
|
||||
- id: legacy
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
@@ -34,6 +34,16 @@ archives:
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
# Versioned archive name used by current CLI / install scripts /
|
||||
# desktop bootstrap going forward.
|
||||
- id: versioned
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
@@ -48,6 +58,8 @@ changelog:
|
||||
|
||||
brews:
|
||||
- name: multica
|
||||
ids:
|
||||
- versioned
|
||||
repository:
|
||||
owner: multica-ai
|
||||
name: homebrew-tap
|
||||
|
||||
76
CLAUDE.md
76
CLAUDE.md
@@ -106,6 +106,7 @@ pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
@@ -162,7 +163,7 @@ When the two apps need different behavior for the same concept (e.g., different
|
||||
When adding a new page or feature:
|
||||
|
||||
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
|
||||
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
|
||||
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
@@ -176,6 +177,79 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## Desktop-specific Rules
|
||||
|
||||
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
|
||||
|
||||
### Route categories
|
||||
|
||||
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
|
||||
|
||||
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
|
||||
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
|
||||
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
|
||||
|
||||
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
|
||||
|
||||
### Workspace identity singleton
|
||||
|
||||
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
|
||||
|
||||
1. API client's `X-Workspace-Slug` header.
|
||||
2. Zustand per-workspace storage namespace.
|
||||
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
|
||||
|
||||
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order:
|
||||
|
||||
1. Read destination from cached workspace list (no extra fetch).
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
Reversing step 4 with steps 1–3 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS window-move)
|
||||
|
||||
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
|
||||
|
||||
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
|
||||
|
||||
```tsx
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col px-6 pb-12">
|
||||
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
|
||||
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
|
||||
|
||||
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
|
||||
|
||||
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
|
||||
|
||||
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
|
||||
|
||||
### UX vs platform chrome
|
||||
|
||||
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
|
||||
|
||||
@@ -278,7 +278,7 @@ multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -293,7 +293,7 @@ multica issue get <id> --output json
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -332,6 +332,27 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
# List subscribers of an issue
|
||||
multica issue subscriber list <issue-id>
|
||||
|
||||
# Subscribe yourself to an issue
|
||||
multica issue subscriber add <issue-id>
|
||||
|
||||
# Subscribe another member or agent by name
|
||||
multica issue subscriber add <issue-id> --user "Lambda"
|
||||
|
||||
# Unsubscribe yourself
|
||||
multica issue subscriber remove <issue-id>
|
||||
|
||||
# Unsubscribe another member or agent
|
||||
multica issue subscriber remove <issue-id> --user "Lambda"
|
||||
```
|
||||
|
||||
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
|
||||
|
||||
### Execution History
|
||||
|
||||
```bash
|
||||
@@ -349,6 +370,70 @@ multica issue run-messages <task-id> --since 42 --output json
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Projects
|
||||
|
||||
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
|
||||
belongs to a workspace and can optionally have a lead (member or agent).
|
||||
|
||||
### List Projects
|
||||
|
||||
```bash
|
||||
multica project list
|
||||
multica project list --status in_progress
|
||||
multica project list --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`.
|
||||
|
||||
### Get Project
|
||||
|
||||
```bash
|
||||
multica project get <id>
|
||||
multica project get <id> --output json
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
```bash
|
||||
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Update Project
|
||||
|
||||
```bash
|
||||
multica project update <id> --title "New title" --status in_progress
|
||||
multica project update <id> --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
multica project status <id> in_progress
|
||||
```
|
||||
|
||||
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
|
||||
|
||||
### Delete Project
|
||||
|
||||
```bash
|
||||
multica project delete <id>
|
||||
```
|
||||
|
||||
### Associating Issues with Projects
|
||||
|
||||
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
|
||||
project, or on `issue list` to filter issues by project:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Login bug" --project <project-id>
|
||||
multica issue update <issue-id> --project <project-id>
|
||||
multica issue list --project <project-id>
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
@@ -385,6 +470,63 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
|
||||
### List Autopilots
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot list --status active --output json
|
||||
```
|
||||
|
||||
### Get Autopilot Details
|
||||
|
||||
```bash
|
||||
multica autopilot get <id>
|
||||
multica autopilot get <id> --output json # includes triggers
|
||||
```
|
||||
|
||||
### Create / Update / Delete
|
||||
|
||||
```bash
|
||||
multica autopilot create \
|
||||
--title "Nightly bug triage" \
|
||||
--description "Scan todo issues and prioritize." \
|
||||
--agent "Lambda" \
|
||||
--mode create_issue
|
||||
|
||||
multica autopilot update <id> --status paused
|
||||
multica autopilot update <id> --description "New prompt"
|
||||
multica autopilot delete <id>
|
||||
```
|
||||
|
||||
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <id> # Fires the autopilot once, returns the run
|
||||
```
|
||||
|
||||
### Run History
|
||||
|
||||
```bash
|
||||
multica autopilot runs <id>
|
||||
multica autopilot runs <id> --limit 50 --output json
|
||||
```
|
||||
|
||||
### Schedule Triggers
|
||||
|
||||
```bash
|
||||
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
|
||||
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
|
||||
multica autopilot trigger-delete <autopilot-id> <trigger-id>
|
||||
```
|
||||
|
||||
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
|
||||
|
||||
## Other Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -76,7 +76,8 @@ fi
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
VERSION="${LATEST#v}"
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
|
||||
@@ -592,6 +592,19 @@ If you want to stop PostgreSQL and keep your local databases:
|
||||
make db-down
|
||||
```
|
||||
|
||||
If you want a fresh database for the current checkout only (drops the
|
||||
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
|
||||
|
||||
```bash
|
||||
make stop # stop backend/frontend first
|
||||
make db-reset
|
||||
make start
|
||||
```
|
||||
|
||||
- only affects the current env's database; other worktree databases are untouched
|
||||
- refuses to run if `DATABASE_URL` points at a remote host
|
||||
- pass `ENV_FILE=.env.worktree` to target a specific worktree
|
||||
|
||||
If you want to wipe all local PostgreSQL data for this repo:
|
||||
|
||||
```bash
|
||||
|
||||
25
Makefile
25
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-stop
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -66,7 +66,8 @@ selfhost:
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
@@ -136,6 +137,26 @@ db-up:
|
||||
db-down:
|
||||
@$(COMPOSE) down
|
||||
|
||||
# Drop + recreate the current env's database, then run all migrations.
|
||||
# Use for a clean slate in local dev. Only affects the DB named in
|
||||
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
|
||||
# worktree DBs are untouched. Refuses to run against a remote host.
|
||||
db-reset:
|
||||
$(REQUIRE_ENV)
|
||||
@case "$(DATABASE_URL)" in \
|
||||
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
|
||||
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
|
||||
esac
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
|
||||
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
|
||||
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
|
||||
@echo "==> Running migrations..."
|
||||
cd server && go run ./cmd/migrate up
|
||||
@echo ""
|
||||
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
|
||||
|
||||
worktree-env:
|
||||
@bash scripts/init-worktree-env.sh .env.worktree
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ multica setup self-host
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -63,9 +63,13 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ All configuration is done via environment variables. Copy `.env.example` as a st
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Database Pool Tuning (Optional)
|
||||
|
||||
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
|
||||
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
@@ -23,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -44,7 +53,14 @@ For file uploads and attachments, configure S3 and CloudFront:
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Cookies
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
|
||||
|
||||
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
|
||||
|
||||
### Server
|
||||
|
||||
|
||||
@@ -21,25 +21,34 @@ mac:
|
||||
- zip
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
|
||||
# so the filename alone surfaces kind, version, platform, and CPU arch.
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
# Align with our CLI release flow which pre-creates a *published* GitHub
|
||||
# Release via `gh release create`. The electron-builder default of
|
||||
# `releaseType: draft` conflicts with `existingType=release` and causes
|
||||
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
|
||||
# which breaks electron-updater auto-update on installed clients.
|
||||
releaseType: release
|
||||
npmRebuild: false
|
||||
|
||||
@@ -10,4 +10,28 @@ export default [
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
// Security: every renderer-controlled URL that reaches the OS shell must
|
||||
// flow through openExternalSafely in src/main/external-url.ts (scheme
|
||||
// allowlist). Enforce it statically so a direct shell.openExternal call
|
||||
// cannot silently regress the protection.
|
||||
{
|
||||
files: ["src/main/**/*.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.name='shell'][callee.property.name='openExternal']",
|
||||
message:
|
||||
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/main/external-url.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
"name": "@multica/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
"homepage": "https://multica.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/multica-ai/multica.git",
|
||||
"directory": "apps/desktop"
|
||||
},
|
||||
"author": {
|
||||
"name": "Multica",
|
||||
"email": "support@multica.ai"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
@@ -13,6 +25,7 @@
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "node scripts/package.mjs",
|
||||
"package:all": "node scripts/package.mjs --all-platforms --publish never",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
@@ -25,6 +38,7 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -23,8 +23,54 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const PLATFORM_TO_GOOS = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
|
||||
|
||||
function runtimePlatformFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-platform");
|
||||
if (flagIndex === -1) return process.platform;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function runtimeArchFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-arch");
|
||||
if (flagIndex === -1) return process.arch;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function normalizeRuntimePlatform(platform) {
|
||||
if (platform in PLATFORM_TO_GOOS) return platform;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target platform: ${platform}. ` +
|
||||
"Use darwin, linux, or win32.",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeArch(arch) {
|
||||
if (SUPPORTED_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target architecture: ${arch}. ` +
|
||||
"Use x64 or arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
function binaryNameForPlatform(platform) {
|
||||
return platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
const targetPlatform = normalizeRuntimePlatform(
|
||||
runtimePlatformFromArgs(process.argv.slice(2)),
|
||||
);
|
||||
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
|
||||
const goos = PLATFORM_TO_GOOS[targetPlatform];
|
||||
const goarch = targetArch === "x64" ? "amd64" : targetArch;
|
||||
const binName = binaryNameForPlatform(targetPlatform);
|
||||
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
@@ -61,8 +107,9 @@ if (hasGo()) {
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
|
||||
);
|
||||
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
@@ -70,10 +117,19 @@ if (hasGo()) {
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
join("bin", binName),
|
||||
srcBinary,
|
||||
"./cmd/multica",
|
||||
],
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
{
|
||||
cwd: serverDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -88,9 +144,11 @@ if (!(await exists(srcBinary))) {
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
|
||||
// into resources/bin/), then `electron-vite build` to produce the
|
||||
// main/preload/renderer bundles under out/, then invokes electron-builder
|
||||
// with `-c.extraMetadata.version=<derived>` so the override applies at
|
||||
// build time without mutating the tracked package.json.
|
||||
// Builds the Electron bundles once, then for each requested target
|
||||
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
|
||||
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
|
||||
// the override applies at build time without mutating the tracked
|
||||
// package.json.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
@@ -25,11 +25,50 @@
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { delimiter, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
const bundleCliScript = resolve(here, "bundle-cli.mjs");
|
||||
|
||||
const PLATFORM_CONFIG = {
|
||||
mac: {
|
||||
aliases: new Set(["--mac", "--macos", "-m"]),
|
||||
builderFlag: "--mac",
|
||||
runtimePlatform: "darwin",
|
||||
label: "macOS",
|
||||
},
|
||||
win: {
|
||||
aliases: new Set(["--win", "--windows", "-w"]),
|
||||
builderFlag: "--win",
|
||||
runtimePlatform: "win32",
|
||||
label: "Windows",
|
||||
},
|
||||
linux: {
|
||||
aliases: new Set(["--linux", "-l"]),
|
||||
builderFlag: "--linux",
|
||||
runtimePlatform: "linux",
|
||||
label: "Linux",
|
||||
},
|
||||
};
|
||||
|
||||
const ARCH_FLAGS = new Map([
|
||||
["--x64", "x64"],
|
||||
["--arm64", "arm64"],
|
||||
["--ia32", "ia32"],
|
||||
["--armv7l", "armv7l"],
|
||||
["--universal", "universal"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
|
||||
const MAC_ALL_PLATFORM_TARGETS = [
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
];
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
@@ -77,20 +116,231 @@ function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
export function envWithLocalBins(env = process.env, root = desktopRoot) {
|
||||
const pathKey =
|
||||
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
|
||||
const existingPath = env[pathKey] ?? "";
|
||||
const localBins = uniqueOrdered([
|
||||
resolve(root, "node_modules", ".bin"),
|
||||
resolve(root, "..", "..", "node_modules", ".bin"),
|
||||
]);
|
||||
const mergedPath = uniqueOrdered([
|
||||
...localBins,
|
||||
...String(existingPath)
|
||||
.split(delimiter)
|
||||
.filter(Boolean),
|
||||
]).join(delimiter);
|
||||
return { ...env, [pathKey]: mergedPath };
|
||||
}
|
||||
|
||||
function hostPlatformKey(platform = process.platform) {
|
||||
if (platform === "darwin") return "mac";
|
||||
if (platform === "win32") return "win";
|
||||
if (platform === "linux") return "linux";
|
||||
throw new Error(`[package] unsupported host platform: ${platform}`);
|
||||
}
|
||||
|
||||
function hostArchKey(arch = process.arch) {
|
||||
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
function expandPlatformShorthand(token) {
|
||||
if (!/^-[mwl]{2,}$/.test(token)) return null;
|
||||
const expanded = [];
|
||||
for (const char of token.slice(1)) {
|
||||
if (char === "m") expanded.push("mac");
|
||||
if (char === "w") expanded.push("win");
|
||||
if (char === "l") expanded.push("linux");
|
||||
}
|
||||
return uniqueOrdered(expanded);
|
||||
}
|
||||
|
||||
function platformKeyForToken(token) {
|
||||
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
|
||||
if (config.aliases.has(token)) return platform;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function platformTargetsTemplate() {
|
||||
return { mac: [], win: [], linux: [] };
|
||||
}
|
||||
|
||||
export function parsePackageArgs(argv) {
|
||||
const sharedArgs = [];
|
||||
const platformTargets = platformTargetsTemplate();
|
||||
const requestedPlatforms = [];
|
||||
const requestedArchs = [];
|
||||
let allPlatforms = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--all-platforms") {
|
||||
allPlatforms = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expandedPlatforms = expandPlatformShorthand(token);
|
||||
if (expandedPlatforms) {
|
||||
requestedPlatforms.push(...expandedPlatforms);
|
||||
continue;
|
||||
}
|
||||
|
||||
const platform = platformKeyForToken(token);
|
||||
if (platform) {
|
||||
requestedPlatforms.push(platform);
|
||||
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
||||
platformTargets[platform].push(argv[i + 1]);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const arch = ARCH_FLAGS.get(token);
|
||||
if (arch) {
|
||||
requestedArchs.push(arch);
|
||||
continue;
|
||||
}
|
||||
|
||||
sharedArgs.push(token);
|
||||
}
|
||||
|
||||
return {
|
||||
allPlatforms,
|
||||
sharedArgs,
|
||||
platformTargets,
|
||||
requestedPlatforms: uniqueOrdered(requestedPlatforms),
|
||||
requestedArchs: uniqueOrdered(requestedArchs),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
|
||||
if (parsed.allPlatforms) {
|
||||
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
|
||||
throw new Error(
|
||||
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
|
||||
);
|
||||
}
|
||||
if (platform !== "darwin") {
|
||||
throw new Error(
|
||||
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
|
||||
);
|
||||
}
|
||||
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
|
||||
}
|
||||
|
||||
const platforms =
|
||||
parsed.requestedPlatforms.length > 0
|
||||
? parsed.requestedPlatforms
|
||||
: [hostPlatformKey(platform)];
|
||||
const archs =
|
||||
parsed.requestedArchs.length > 0
|
||||
? parsed.requestedArchs
|
||||
: [hostArchKey(arch)];
|
||||
|
||||
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
|
||||
"Use --x64 or --arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
return platforms.flatMap((targetPlatform) =>
|
||||
archs.map((targetArch) => ({
|
||||
platform: targetPlatform,
|
||||
arch: targetArch,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTarget(target) {
|
||||
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
|
||||
}
|
||||
|
||||
export function builderArgsForTarget(
|
||||
target,
|
||||
parsed,
|
||||
version,
|
||||
{
|
||||
disableMacNotarize = false,
|
||||
hostPlatform = process.platform,
|
||||
useScopedOutputDir = false,
|
||||
} = {},
|
||||
) {
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
|
||||
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
|
||||
const requestedTargets = parsed.platformTargets[target.platform];
|
||||
if (
|
||||
target.platform === "linux" &&
|
||||
hostPlatform !== "linux" &&
|
||||
requestedTargets.length === 0
|
||||
) {
|
||||
// electron-builder only guarantees AppImage/Snap when cross-building
|
||||
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
|
||||
// to AppImage unless the caller explicitly requests Linux targets.
|
||||
builderArgs.push("AppImage");
|
||||
} else {
|
||||
builderArgs.push(...requestedTargets);
|
||||
}
|
||||
builderArgs.push(`--${target.arch}`);
|
||||
builderArgs.push(...parsed.sharedArgs);
|
||||
if (useScopedOutputDir) {
|
||||
builderArgs.push(
|
||||
`-c.directories.output=dist/${target.platform}-${target.arch}`,
|
||||
);
|
||||
}
|
||||
// electron-builder's update metadata file is `latest.yml` for Windows
|
||||
// regardless of arch (only Linux gets an arch suffix automatically — see
|
||||
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
|
||||
// channel override, building Windows x64 and arm64 in two invocations
|
||||
// makes both publish `latest.yml` to the same GitHub Release, so the
|
||||
// second upload overwrites the first and one of the two architectures
|
||||
// ends up with no auto-update metadata. Route Windows arm64 to its own
|
||||
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
|
||||
// the renderer-side updater pins the matching channel per arch.
|
||||
if (target.platform === "win" && target.arch === "arm64") {
|
||||
builderArgs.push("-c.publish.channel=latest-arm64");
|
||||
}
|
||||
return builderArgs;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const parsed = parsePackageArgs(passthrough);
|
||||
const buildMatrix = resolveBuildMatrix(parsed);
|
||||
console.log(
|
||||
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
|
||||
);
|
||||
|
||||
// Step 1: build the Electron main/preload/renderer bundles. Without
|
||||
// this step electron-builder silently packages whatever is already in
|
||||
// out/, which on a fresh checkout (or after a partial build) ships an
|
||||
// app that white-screens because the renderer bundle is missing.
|
||||
//
|
||||
// CI invokes this script via `node scripts/package.mjs`, so we cannot
|
||||
// rely on pnpm/npm to inject package-local binaries into PATH.
|
||||
//
|
||||
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
|
||||
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
|
||||
// PATHEXT when spawning a bare command without a shell — it would fail
|
||||
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
|
||||
// through the shell is harmless. See
|
||||
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
const viteResult = spawnSync("electron-vite", ["build"], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
env: envWithLocalBins(),
|
||||
shell: true,
|
||||
});
|
||||
if (viteResult.error) {
|
||||
console.error(
|
||||
@@ -103,7 +353,7 @@ function main() {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -113,43 +363,62 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
|
||||
if (disableMacNotarize) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
const useScopedOutputDir = buildMatrix.length > 1;
|
||||
|
||||
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
// Step 3: for each requested target, build the matching CLI into
|
||||
// resources/bin/ and package that target in isolation.
|
||||
for (const target of buildMatrix) {
|
||||
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
bundleCliScript,
|
||||
"--target-platform",
|
||||
PLATFORM_CONFIG[target.platform].runtimePlatform,
|
||||
"--target-arch",
|
||||
target.arch,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
const builderArgs = builderArgsForTarget(target, parsed, version, {
|
||||
disableMacNotarize,
|
||||
hostPlatform: process.platform,
|
||||
useScopedOutputDir,
|
||||
});
|
||||
|
||||
// Step 4: invoke electron-builder for the current target only.
|
||||
// `shell: true` for the same Windows `.cmd` shim reason as the
|
||||
// electron-vite invocation above.
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
env: envWithLocalBins(),
|
||||
shell: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { delimiter, resolve } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
import {
|
||||
builderArgsForTarget,
|
||||
envWithLocalBins,
|
||||
normalizeGitVersion,
|
||||
parsePackageArgs,
|
||||
resolveBuildMatrix,
|
||||
stripLeadingSeparator,
|
||||
} from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
@@ -59,3 +67,207 @@ describe("stripLeadingSeparator", () => {
|
||||
expect(stripLeadingSeparator([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePackageArgs", () => {
|
||||
it("collects per-platform targets and shared args", () => {
|
||||
expect(
|
||||
parsePackageArgs([
|
||||
"--win", "nsis",
|
||||
"--mac", "dmg", "zip",
|
||||
"--arm64",
|
||||
"--publish", "never",
|
||||
]),
|
||||
).toEqual({
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: {
|
||||
mac: ["dmg", "zip"],
|
||||
win: ["nsis"],
|
||||
linux: [],
|
||||
},
|
||||
requestedPlatforms: ["win", "mac"],
|
||||
requestedArchs: ["arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("expands combined short flags", () => {
|
||||
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
|
||||
"mac",
|
||||
"win",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks the all-platforms shortcut", () => {
|
||||
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBuildMatrix", () => {
|
||||
it("defaults to the current host platform and arch", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([{ platform: "mac", arch: "arm64" }]);
|
||||
});
|
||||
|
||||
it("expands all-platforms on macOS", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: true,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported architectures", () => {
|
||||
expect(() =>
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["universal"],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/unsupported Desktop CLI architecture/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("builderArgsForTarget", () => {
|
||||
it("adds scoped output directories for multi-target builds", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["arm64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{
|
||||
disableMacNotarize: true,
|
||||
hostPlatform: "darwin",
|
||||
useScopedOutputDir: true,
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"-c.mac.notarize=false",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--arm64",
|
||||
"--publish",
|
||||
"never",
|
||||
"-c.directories.output=dist/win-arm64",
|
||||
"-c.publish.channel=latest-arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "always"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "win32", useScopedOutputDir: true },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"always",
|
||||
"-c.directories.output=dist/win-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["linux"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "darwin" },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--linux",
|
||||
"AppImage",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"never",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envWithLocalBins", () => {
|
||||
it("prepends desktop-local binary directories to PATH", () => {
|
||||
const desktopRoot = "/repo/apps/desktop";
|
||||
const result = envWithLocalBins(
|
||||
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
|
||||
desktopRoot,
|
||||
);
|
||||
expect(result.PATH.split(delimiter)).toEqual([
|
||||
resolve(desktopRoot, "node_modules", ".bin"),
|
||||
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves an existing Path key and avoids duplicate entries", () => {
|
||||
const desktopRoot = "/repo/apps/desktop";
|
||||
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
|
||||
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
|
||||
const result = envWithLocalBins(
|
||||
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
|
||||
desktopRoot,
|
||||
);
|
||||
expect(result).not.toHaveProperty("PATH");
|
||||
expect(result.Path.split(delimiter)).toEqual([
|
||||
desktopBin,
|
||||
workspaceBin,
|
||||
"runner-bin",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,35 +8,15 @@ import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
// Desktop prefers the bundled `multica` CLI shipped inside the app for
|
||||
// same-repo builds, but it can also repair or bootstrap a managed copy in
|
||||
// userData on first launch when the bundled binary is missing or unusable.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
@@ -92,14 +72,8 @@ async function sha256OfFile(path: string): Promise<string> {
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
@@ -118,7 +92,14 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const assetName = platformAssetName();
|
||||
const checksums = await fetchChecksums();
|
||||
const assetName = selectPlatformReleaseAssetName(checksums.keys());
|
||||
const expectedChecksum = checksums.get(assetName);
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
@@ -130,7 +111,7 @@ async function installFresh(): Promise<string> {
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
await verifyChecksum(archivePath, assetName, expectedChecksum);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
@@ -143,6 +124,7 @@ async function installFresh(): Promise<string> {
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rm(target, { force: true }).catch(() => {});
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
@@ -166,8 +148,10 @@ async function installFresh(): Promise<string> {
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
export async function ensureManagedCli(
|
||||
options: { forceInstall?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target)) return target;
|
||||
if (existsSync(target) && !options.forceInstall) return target;
|
||||
return installFresh();
|
||||
}
|
||||
|
||||
59
apps/desktop/src/main/cli-release-asset.test.ts
Normal file
59
apps/desktop/src/main/cli-release-asset.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
describe("selectPlatformReleaseAssetName", () => {
|
||||
it("prefers the versioned archive name when both exist", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the legacy archive name when only legacy is present", () => {
|
||||
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed darwin archive from release assets", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-arm64.tar.gz",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed windows zip archive", () => {
|
||||
const assetNames = [
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the current platform asset is missing", () => {
|
||||
expect(() =>
|
||||
selectPlatformReleaseAssetName(
|
||||
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/no release asset found/);
|
||||
});
|
||||
});
|
||||
62
apps/desktop/src/main/cli-release-asset.ts
Normal file
62
apps/desktop/src/main/cli-release-asset.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
|
||||
|
||||
function platformArchiveDescriptor(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): { os: string; arch: string; ext: string } {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[platform];
|
||||
const mappedArch = archMap[arch];
|
||||
if (!os || !mappedArch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
|
||||
);
|
||||
}
|
||||
const ext = platform === "win32" ? "zip" : "tar.gz";
|
||||
return { os, arch: mappedArch, ext };
|
||||
}
|
||||
|
||||
export function selectPlatformReleaseAssetName(
|
||||
assetNames: Iterable<string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string {
|
||||
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
|
||||
platform,
|
||||
arch,
|
||||
);
|
||||
const names = [...assetNames];
|
||||
|
||||
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
|
||||
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
|
||||
// only ship the legacy archive keep working.
|
||||
const suffix = `-${os}-${mappedArch}.${ext}`;
|
||||
const matches = names.filter(
|
||||
(name) =>
|
||||
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
|
||||
);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
|
||||
if (names.includes(legacyName)) {
|
||||
return legacyName;
|
||||
}
|
||||
|
||||
throw new Error(`no release asset found for current platform: ${suffix}`);
|
||||
}
|
||||
@@ -316,6 +316,36 @@ function bundledCliPath(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function probeCliBinary(
|
||||
bin: string,
|
||||
source: "bundled" | "managed" | "path",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
||||
return parsed.version;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
|
||||
);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
@@ -339,27 +369,55 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
const version = await probeCliBinary(bundled, "bundled");
|
||||
if (version) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
cachedCliBinaryVersion = version;
|
||||
return bundled;
|
||||
}
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
const version = await probeCliBinary(managed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = managed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return managed;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
const installed = await ensureManagedCli({
|
||||
forceInstall: existsSync(managed),
|
||||
});
|
||||
const version = await probeCliBinary(installed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = installed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return installed;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] managed CLI at ${installed} failed validation after install`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
|
||||
const onPath = findCliOnPath();
|
||||
if (onPath) {
|
||||
const version = await probeCliBinary(onPath, "path");
|
||||
if (version) {
|
||||
cachedCliBinary = onPath;
|
||||
cachedCliBinaryVersion = version;
|
||||
return onPath;
|
||||
}
|
||||
}
|
||||
|
||||
cachedCliBinary = null;
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -370,11 +428,10 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Reads the version of the currently resolved CLI binary. Cached for the
|
||||
* process lifetime — the bundled binary doesn't change after bundle time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* etc.) so callers can fail open.
|
||||
* wrong-arch bundled binary, etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
@@ -383,24 +440,7 @@ async function getCliBinaryVersion(): Promise<string | null> {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
|
||||
73
apps/desktop/src/main/external-url.test.ts
Normal file
73
apps/desktop/src/main/external-url.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
shell: { openExternal: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
import { shell } from "electron";
|
||||
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
|
||||
|
||||
describe("isSafeExternalHttpUrl", () => {
|
||||
it("allows http and https URLs", () => {
|
||||
expect(isSafeExternalHttpUrl("https://multica.ai")).toBe(true);
|
||||
expect(isSafeExternalHttpUrl("http://localhost:3000/auth")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows https URLs with embedded credentials", () => {
|
||||
// WHATWG URL parses these as https; OS-level handling is the shell's concern.
|
||||
expect(isSafeExternalHttpUrl("https://user:pass@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes scheme casing so uppercase variants can't bypass", () => {
|
||||
expect(isSafeExternalHttpUrl("HTTPS://example.com")).toBe(true);
|
||||
expect(isSafeExternalHttpUrl("FILE:///etc/passwd")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects dangerous pseudo-schemes", () => {
|
||||
expect(isSafeExternalHttpUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(
|
||||
isSafeExternalHttpUrl("data:text/html,<script>alert(1)</script>"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects filesystem and network transport schemes", () => {
|
||||
expect(isSafeExternalHttpUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("ftp://example.com/x")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("smb://share/x")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects local-handler schemes used in past RCE chains", () => {
|
||||
expect(isSafeExternalHttpUrl("vscode://file/test")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("ms-msdt:/id%20PCWDiagnostic")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects mailto and other non-web schemes", () => {
|
||||
expect(isSafeExternalHttpUrl("mailto:test@example.com")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("tel:+15551234567")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty, whitespace, and malformed input", () => {
|
||||
expect(isSafeExternalHttpUrl("")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl(" ")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("not a url")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("http://")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openExternalSafely", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(shell.openExternal).mockClear();
|
||||
});
|
||||
|
||||
it("forwards http/https URLs to shell.openExternal", () => {
|
||||
openExternalSafely("https://multica.ai");
|
||||
expect(shell.openExternal).toHaveBeenCalledWith("https://multica.ai");
|
||||
});
|
||||
|
||||
it("does not call shell.openExternal for rejected schemes", () => {
|
||||
openExternalSafely("file:///etc/passwd");
|
||||
openExternalSafely("javascript:alert(1)");
|
||||
openExternalSafely("not a url");
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
38
apps/desktop/src/main/external-url.ts
Normal file
38
apps/desktop/src/main/external-url.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { shell } from "electron";
|
||||
|
||||
// True when the URL parses and uses http/https — the only schemes we let
|
||||
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
|
||||
// URL parser lowercases the protocol field.
|
||||
export function isSafeExternalHttpUrl(url: string): boolean {
|
||||
return getHttpProtocol(url) !== null;
|
||||
}
|
||||
|
||||
// Canonical wrapper around shell.openExternal. All renderer-controlled URLs
|
||||
// that eventually reach the OS shell MUST flow through here; direct calls
|
||||
// to `shell.openExternal` elsewhere in the main process are banned by the
|
||||
// no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
|
||||
export function openExternalSafely(url: string): Promise<void> | void {
|
||||
if (getHttpProtocol(url) === null) {
|
||||
console.warn(`[security] blocked openExternal: ${describeScheme(url)}`);
|
||||
return;
|
||||
}
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
|
||||
function getHttpProtocol(url: string): "http:" | "https:" | null {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
if (protocol === "http:" || protocol === "https:") return protocol;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeScheme(url: string): string {
|
||||
try {
|
||||
return `scheme=${new URL(url).protocol}`;
|
||||
} catch {
|
||||
return "invalid URL";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -48,6 +49,19 @@ function handleDeepLink(url: string): void {
|
||||
if (token && mainWindow) {
|
||||
mainWindow.webContents.send("auth:token", token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// multica://invite/<invitationId>
|
||||
// Dispatched from the web invite page when the user chooses "Open in
|
||||
// desktop app". The renderer opens the invite overlay — no tab, no
|
||||
// route persistence, so deep-linking the same invite twice stays safe.
|
||||
if (parsed.hostname === "invite") {
|
||||
const id = parsed.pathname.replace(/^\//, "");
|
||||
if (id && mainWindow) {
|
||||
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
@@ -91,7 +105,7 @@ function createWindow(): void {
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
@@ -170,9 +184,13 @@ if (!gotTheLock) {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// IPC: open URL in default browser (used by renderer for Google login)
|
||||
// IPC: open URL in default browser (used by renderer for Google login).
|
||||
// All scheme-allowlist enforcement lives in openExternalSafely — this
|
||||
// is the single audit point for renderer-controlled URLs reaching the
|
||||
// OS shell under the app's intentional webSecurity: false + sandbox:
|
||||
// false configuration.
|
||||
ipcMain.handle("shell:openExternal", (_event, url: string) => {
|
||||
return shell.openExternal(url);
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
|
||||
// arches would otherwise collide on the same file in the GitHub Release.
|
||||
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
|
||||
// of this pact. Pin the channel here so arm64 clients fetch
|
||||
// `latest-arm64.yml` instead of the x64 metadata.
|
||||
if (process.platform === "win32" && process.arch === "arm64") {
|
||||
autoUpdater.channel = "latest-arm64";
|
||||
}
|
||||
|
||||
const STARTUP_CHECK_DELAY_MS = 5_000;
|
||||
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export type ManualUpdateCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
available: boolean;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
const win = getMainWindow();
|
||||
@@ -37,10 +59,42 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
// staged rollouts, downgrades, and minimum-system-version gates — in
|
||||
// those cases updateInfo.version differs from app.getVersion() but no
|
||||
// `update-available` event fires, so showing "available" here would
|
||||
// promise a download prompt that never appears.
|
||||
return {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
latestVersion: result?.updateInfo.version ?? currentVersion,
|
||||
available: result?.isUpdateAvailable ?? false,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, 5000);
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
6
apps/desktop/src/preload/index.d.ts
vendored
6
apps/desktop/src/preload/index.d.ts
vendored
@@ -3,6 +3,8 @@ import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
interface DesktopAPI {
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
@@ -51,6 +53,10 @@ interface UpdaterAPI {
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -11,6 +11,15 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener("auth:token", handler);
|
||||
};
|
||||
},
|
||||
/** Listen for invitation IDs delivered via deep link */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
|
||||
callback(invitationId);
|
||||
ipcRenderer.on("invite:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("invite:open", handler);
|
||||
};
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
@@ -87,6 +96,10 @@ const updaterAPI = {
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
checkForUpdates: (): Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
> => ipcRenderer.invoke("updater:check"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
@@ -11,6 +12,8 @@ import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -31,6 +34,17 @@ function AppContent() {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
// in, InvitePage's queries will fail and render the "not found" state,
|
||||
// which is acceptable; the expected pre-flight happens in the web app
|
||||
// (login + next=/invite/... dance) before the deep link is ever dispatched.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInviteOpen((invitationId) => {
|
||||
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
|
||||
// daemonAPI.syncToken is handled separately by the [user] effect below, which
|
||||
// fires whenever a user logs in (deep link, session restore, account switch).
|
||||
@@ -77,28 +91,47 @@ function AppContent() {
|
||||
// account switches (user A logout → user B login) should not trigger a
|
||||
// daemon restart here — daemon-manager already restarts on user change
|
||||
// via syncToken.
|
||||
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
|
||||
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const wsCount = workspaces?.length ?? 0;
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Validate persisted tab paths against the current user's workspace list.
|
||||
// Tabs survive across app restarts and account switches (persisted to
|
||||
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
|
||||
// reference a workspace the current user can't access — showing
|
||||
// NoAccessPage every time they open the app.
|
||||
//
|
||||
// Run synchronously in render phase rather than in useEffect so the first
|
||||
// render already sees validated tabs. useEffect runs AFTER commit, which
|
||||
// means the initial render would briefly show NoAccessPage before the
|
||||
// effect resets the tab. Zustand supports render-phase setState; the
|
||||
// validator is idempotent (exits early if nothing changed) so this
|
||||
// doesn't loop.
|
||||
if (workspaces) {
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
// (synchronously after render, before paint) rather than the render
|
||||
// phase — the original render-phase pattern triggered React's
|
||||
// "Cannot update a component while rendering a different component"
|
||||
// warning because `switchWorkspace` is a Zustand setState that the
|
||||
// TabBar is subscribed to. useLayoutEffect flushes both renders before
|
||||
// the user sees anything, so there's no visible flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (!workspaces) return;
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
}
|
||||
const tabStore = useTabStore.getState();
|
||||
tabStore.validateWorkspaceSlugs(validSlugs);
|
||||
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
|
||||
tabStore.switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [workspaces]);
|
||||
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
// false = session started with >=1 workspace, OR we've already restarted; skip
|
||||
@@ -135,9 +168,14 @@ function AppContent() {
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
|
||||
// useLogout clears the storage key, but the live stores stay populated until
|
||||
// we explicitly reset them here.
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
|
||||
@@ -13,11 +13,13 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
@@ -113,7 +115,8 @@ export function DesktopShell() {
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
|
||||
users are routed to /workspaces/new by IndexRedirect. */}
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
@@ -132,6 +135,8 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { isGlobalPath, paths } from "@multica/core/paths";
|
||||
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Inbox,
|
||||
@@ -67,16 +67,13 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
const handleClick = () => {
|
||||
if (isActive) return;
|
||||
setActiveTab(tab.id);
|
||||
// No navigate() — Activity handles visibility
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
// No navigate() — store handles activeTabId switch
|
||||
};
|
||||
|
||||
// Stop pointer down on close so it doesn't start a drag on the parent button.
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
@@ -125,22 +122,13 @@ function NewTabButton() {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
|
||||
const handleClick = () => {
|
||||
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
|
||||
// opens in the same context as the active one. Read the slug from the
|
||||
// active tab's path directly rather than from getCurrentSlug(), because
|
||||
// that singleton is "last tab to render" (non-deterministic with N tabs
|
||||
// mounted under <Activity>), while activeTabId is the unambiguous truth.
|
||||
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
|
||||
// tab is on a global route (e.g. /workspaces/new, /login).
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
|
||||
let slug: string | null = null;
|
||||
if (activePath !== "/" && !isGlobalPath(activePath)) {
|
||||
slug = activePath.split("/").filter(Boolean)[0] ?? null;
|
||||
}
|
||||
const path = slug ? paths.workspace(slug).issues() : "/";
|
||||
// New tab opens in the currently active workspace — tabs are scoped
|
||||
// per workspace, so there is no cross-workspace ambiguity to resolve.
|
||||
const activeSlug = useTabStore.getState().activeWorkspaceSlug;
|
||||
if (!activeSlug) return;
|
||||
const path = paths.workspace(activeSlug).issues();
|
||||
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
|
||||
setActiveTab(tabId);
|
||||
if (tabId) setActiveTab(tabId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -155,17 +143,17 @@ function NewTabButton() {
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const group = useActiveGroup();
|
||||
const moveTab = useTabStore((s) => s.moveTab);
|
||||
|
||||
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -195,7 +183,7 @@ export function TabBar() {
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<NewTabButton />
|
||||
{group && <NewTabButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import { Activity, useEffect } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useActiveGroup } from "@/stores/tab-store";
|
||||
import { TabNavigationProvider } from "@/platform/navigation";
|
||||
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
|
||||
import type { Tab } from "@/stores/tab-store";
|
||||
|
||||
/** Inner wrapper rendered inside each tab's RouterProvider. */
|
||||
function TabRouterInner({ tabId }: { tabId: string }) {
|
||||
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
|
||||
useTabRouterSync(tabId, tab!.router);
|
||||
/**
|
||||
* Inner wrapper rendered inside each tab's RouterProvider. The router
|
||||
* reference is stable for a tab's lifetime, so passing it in directly
|
||||
* (instead of re-deriving from the store) avoids needless re-renders.
|
||||
*/
|
||||
function TabRouterInner({ tab }: { tab: Tab }) {
|
||||
useTabRouterSync(tab.id, tab.router);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all tabs using Activity for state preservation.
|
||||
* Renders the active workspace's tabs using Activity for state preservation.
|
||||
* Only the active tab is visible; hidden tabs keep their DOM and React state.
|
||||
*
|
||||
* When switching workspaces, the previous workspace's tabs unmount entirely
|
||||
* and the new workspace's tabs mount fresh — cross-workspace state
|
||||
* preservation is an explicit non-goal (keeping all workspaces' tabs warm
|
||||
* simultaneously would bloat memory and make workspace switching feel
|
||||
* anything but "switching").
|
||||
*/
|
||||
export function TabContent() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const group = useActiveGroup();
|
||||
|
||||
// Sync document.title when switching tabs
|
||||
// Sync document.title when switching tabs within the active workspace.
|
||||
useEffect(() => {
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!group) return;
|
||||
const tab = group.tabs.find((t) => t.id === group.activeTabId);
|
||||
if (tab) document.title = tab.title;
|
||||
}, [activeTabId, tabs]);
|
||||
}, [group?.activeTabId, group?.tabs]);
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
{group.tabs.map((tab) => (
|
||||
<Activity
|
||||
key={tab.id}
|
||||
mode={tab.id === activeTabId ? "visible" : "hidden"}
|
||||
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
|
||||
>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tabId={tab.id} />
|
||||
<TabRouterInner tab={tab} />
|
||||
</TabNavigationProvider>
|
||||
</Activity>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
const result = await window.updater.checkForUpdates();
|
||||
if (!result.ok) {
|
||||
setState({ status: "error", message: result.error });
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheck}
|
||||
disabled={state.status === "checking"}
|
||||
>
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
"Check now"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
79
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: renders above the tab system when the
|
||||
* user is in a pre-workspace flow (onboarding, create workspace, accept
|
||||
* invite).
|
||||
*
|
||||
* This component is intentionally thin — just a fixed positioning shell
|
||||
* that covers the tab system. It does NOT hide traffic lights or provide
|
||||
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
|
||||
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
|
||||
* native macOS traffic lights stay visible and the page content can fill
|
||||
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
|
||||
* pre-dashboard flows and keeps platform chrome consistent across every
|
||||
* "not-in-dashboard" surface.
|
||||
*
|
||||
* All UX affordances (Back button, Log out button, welcome copy, invite
|
||||
* card) live inside the shared view components under `packages/views/`,
|
||||
* so web and desktop render identical content.
|
||||
*/
|
||||
export function WindowOverlay() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
if (!overlay) return null;
|
||||
return <WindowOverlayInner />;
|
||||
}
|
||||
|
||||
function WindowOverlayInner() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const close = useWindowOverlayStore((s) => s.close);
|
||||
const { push } = useNavigation();
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
if (!overlay) return null;
|
||||
|
||||
// Back is only meaningful when there's somewhere to go — i.e. the user
|
||||
// has at least one workspace. Zero-workspace users can only Log out or
|
||||
// complete the flow.
|
||||
const onBack = wsList.length > 0 ? close : undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col overflow-auto bg-background">
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { useEffect } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
workspaceBySlugOptions,
|
||||
workspaceListOptions,
|
||||
} from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
@@ -17,9 +20,13 @@ import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we render NoAccessPage instead of silently redirecting — users get
|
||||
* explicit feedback for stale bookmarks or revoked access.
|
||||
* Unlike web, desktop never renders a "workspace not available" page: the
|
||||
* app has no URL bar and no clickable links from outside the session, so
|
||||
* landing on an inaccessible slug can only mean stale state (a persisted
|
||||
* tab group for a workspace the current user no longer has access to, or
|
||||
* active eviction). Both cases resolve by dropping the stale tab group
|
||||
* from the tab store — the TabBar then renders a different workspace or
|
||||
* the WindowOverlay takes over (zero valid workspaces).
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
@@ -27,10 +34,7 @@ export function WorkspaceRouteLayout() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated (token
|
||||
// expired, logged out from another tab, etc.), bounce to /login.
|
||||
// Without this, the layout renders null and the user sees a blank page
|
||||
// stuck on /{slug}/...
|
||||
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
|
||||
}, [isAuthLoading, user, navigate]);
|
||||
@@ -40,36 +44,41 @@ export function WorkspaceRouteLayout() {
|
||||
enabled: !!user && !!workspaceSlug,
|
||||
});
|
||||
|
||||
const { data: wsList } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Feed the URL slug into the platform singleton so the API client's
|
||||
// X-Workspace-Slug header and persist namespace follow the active tab.
|
||||
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
|
||||
// every render (matters on desktop, where N tabs each mount their own
|
||||
// layout). Rehydrate is the singleton's internal side effect.
|
||||
// setCurrentWorkspace self-dedupes on slug equality.
|
||||
if (workspace && workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
// Remember whether this slug has resolved before (see hook docs). Gates
|
||||
// the NoAccessPage render below so active workspace removal doesn't
|
||||
// flash "Workspace not available" before the navigate lands.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
// Stale-slug auto-heal: when this tab's slug fails to resolve, drop the
|
||||
// whole workspace group from the tab store. Per-workspace tab grouping
|
||||
// means the cleanup is a single validator call — the TabContent will
|
||||
// unmount this tab (and all siblings in the stale group) once the store
|
||||
// updates. We don't navigate this tab's router because the tab's path
|
||||
// is scoped to the stale slug; navigating to "/" would create an
|
||||
// inconsistent "tab in group X with path /" state.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!listFetched) return;
|
||||
if (workspace) return;
|
||||
if (hasBeenSeen) return; // active eviction in flight — let the other path win
|
||||
if (!wsList) return;
|
||||
const validSlugs = new Set(wsList.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
}, [user, listFetched, workspace, hasBeenSeen, wsList]);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the workspace list hasn't populated or the slug is
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// Active workspace just removed (delete/leave/realtime eviction) —
|
||||
// navigate is in flight; hold null briefly instead of flashing
|
||||
// NoAccessPage.
|
||||
if (hasBeenSeen) return null;
|
||||
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
|
||||
// link from a former teammate's workspace) → explicit feedback.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
if (!workspace) return null; // auto-heal effect above handles the cleanup
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
|
||||
"Apple Garamond", Baskerville, "Times New Roman", serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
|
||||
@@ -9,32 +9,32 @@ import { useTabStore } from "@/stores/tab-store";
|
||||
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
|
||||
|
||||
/**
|
||||
* Per-tab back/forward navigation derived from the active tab's history state.
|
||||
* Replaces the old global useNavigationHistory() hook.
|
||||
* Per-tab back/forward navigation derived from the active workspace's
|
||||
* active tab.
|
||||
*
|
||||
* Subscribed via primitive selectors so this hook only re-renders when
|
||||
* the numeric history state actually changes — path ticks on the active
|
||||
* tab (which don't shift historyIndex) don't churn the back/forward
|
||||
* buttons.
|
||||
*/
|
||||
export function useTabHistory() {
|
||||
// Return the actual tab object from the store — stable reference.
|
||||
// Do NOT create a new object in the selector (causes infinite re-renders).
|
||||
const activeTab = useTabStore((s) =>
|
||||
s.tabs.find((t) => t.id === s.activeTabId),
|
||||
);
|
||||
const router = useActiveTabRouter();
|
||||
const { historyIndex, historyLength } = useActiveTabHistory();
|
||||
|
||||
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
|
||||
const canGoForward =
|
||||
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < historyLength - 1;
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex <= 0) return;
|
||||
popDirectionHints.set(activeTab.router, "back");
|
||||
activeTab.router.navigate(-1);
|
||||
}, [activeTab]);
|
||||
if (!router || historyIndex <= 0) return;
|
||||
popDirectionHints.set(router, "back");
|
||||
router.navigate(-1);
|
||||
}, [router, historyIndex]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
|
||||
return;
|
||||
popDirectionHints.set(activeTab.router, "forward");
|
||||
activeTab.router.navigate(1);
|
||||
}, [activeTab]);
|
||||
if (!router || historyIndex >= historyLength - 1) return;
|
||||
popDirectionHints.set(router, "forward");
|
||||
router.navigate(1);
|
||||
}, [router, historyIndex, historyLength]);
|
||||
|
||||
return { canGoBack, canGoForward, goBack, goForward };
|
||||
}
|
||||
|
||||
@@ -2,20 +2,23 @@ import { useEffect } from "react";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Watches document.title via MutationObserver and updates the active tab's title.
|
||||
*
|
||||
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
|
||||
* This observer picks up the change and syncs it to the tab store.
|
||||
* Watches document.title via MutationObserver and updates the active tab's
|
||||
* title. Pages set document.title via TitleSync (route handle.title) or
|
||||
* useDocumentTitle(). This observer picks up the change and syncs it to
|
||||
* the tab store.
|
||||
*/
|
||||
export function useActiveTitleSync() {
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const title = document.title;
|
||||
if (!title) return;
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
const state = useTabStore.getState();
|
||||
if (!state.activeWorkspaceSlug) return;
|
||||
const group = state.byWorkspace[state.activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
const activeTab = group.tabs.find((t) => t.id === group.activeTabId);
|
||||
if (activeTab && activeTab.title !== title) {
|
||||
useTabStore.getState().updateTab(activeTabId, { title });
|
||||
state.updateTab(activeTab.id, { title });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import App from "./App";
|
||||
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
|
||||
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
|
||||
import "@fontsource-variable/inter";
|
||||
// Editorial serif — matches web's next/font Source_Serif_4. Loaded app-wide so
|
||||
// onboarding headings and any future editorial surface can use `font-serif`
|
||||
// (see tokens.css @theme inline). Variable font = one file covers all weights.
|
||||
import "@fontsource-variable/source-serif-4";
|
||||
import "@fontsource-variable/source-serif-4/wght-italic.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
@@ -14,11 +15,7 @@ export function DesktopLoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
<div
|
||||
className="h-[38px] shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<DragStrip />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -5,16 +5,101 @@ import {
|
||||
type NavigationAdapter,
|
||||
} from "@multica/views/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { isReservedSlug } from "@multica/core/paths";
|
||||
import {
|
||||
useTabStore,
|
||||
resolveRouteIcon,
|
||||
useActiveTabIdentity,
|
||||
useActiveTabRouter,
|
||||
getActiveTab,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
* workspace-scoped (root, login, any reserved prefix).
|
||||
*/
|
||||
function extractWorkspaceSlug(path: string): string | null {
|
||||
const first = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!first) return null;
|
||||
if (isReservedSlug(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept navigation to "transition" paths — pre-workspace flows that on
|
||||
* desktop are rendered as a window-level overlay instead of a tab route.
|
||||
* Returns `true` if the navigation was handled (caller should NOT proceed).
|
||||
*
|
||||
* Side effect: when opening the new-workspace overlay, the tab router is
|
||||
* ALSO reset to "/". Rationale — the only way a push lands on
|
||||
* /workspaces/new is that the workspace context is gone (fresh install,
|
||||
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
|
||||
* path would keep those components mounted under the overlay; the next
|
||||
* render after the list cache updates would then throw (useWorkspaceId
|
||||
* etc) because the slug no longer resolves.
|
||||
*/
|
||||
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
const overlay = useWindowOverlayStore.getState();
|
||||
if (path === "/workspaces/new") {
|
||||
overlay.open({ type: "new-workspace" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/onboarding") {
|
||||
overlay.open({ type: "onboarding" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
id = decodeURIComponent(path.slice("/invite/".length));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (id) {
|
||||
overlay.open({ type: "invite", invitationId: id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Any other navigation cancels a live overlay.
|
||||
if (overlay.overlay) overlay.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes that change workspace. Returns `true` if the navigation
|
||||
* was delegated to the tab store (caller should NOT proceed).
|
||||
*
|
||||
* This is the entry point that makes shared code platform-agnostic:
|
||||
* sidebar dropdown, cmd+k "switch workspace", post-delete redirects,
|
||||
* invite-accept flow — they all call `useNavigation().push(path)` with a
|
||||
* full workspace URL, and on desktop we translate "target slug differs
|
||||
* from active" into "switch the tab-group that's visible in the TabBar".
|
||||
*/
|
||||
function tryRouteToOtherWorkspace(path: string): boolean {
|
||||
const targetSlug = extractWorkspaceSlug(path);
|
||||
if (!targetSlug) return false;
|
||||
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
|
||||
if (targetSlug === activeWorkspaceSlug) return false;
|
||||
switchWorkspace(targetSlug, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab
|
||||
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
|
||||
*
|
||||
* Reads from the active tab's memory router via router.subscribe().
|
||||
* Does NOT use any react-router hooks — it's above all RouterProviders.
|
||||
@@ -24,50 +109,61 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
const [pathname, setPathname] = useState(
|
||||
router?.state.location.pathname ?? "/",
|
||||
);
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setPathname(activeTab.router.state.location.pathname);
|
||||
return activeTab.router.subscribe((state) => {
|
||||
if (!router) {
|
||||
setPathname("/");
|
||||
return;
|
||||
}
|
||||
setPathname(router.state.location.pathname);
|
||||
return router.subscribe((state) => {
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId, router]);
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (path === "/login") {
|
||||
// DashboardGuard token expired — force back to login screen
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path);
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
active?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
active?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(-1);
|
||||
currentActiveTab()?.router.navigate(-1);
|
||||
},
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
store.switchWorkspace(slug, path);
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
@@ -77,6 +173,10 @@ export function DesktopNavigationProvider({
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
|
||||
function currentActiveTab() {
|
||||
return getActiveTab(useTabStore.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
|
||||
* Subscribes to the tab's own router for up-to-date pathname.
|
||||
@@ -101,16 +201,29 @@ export function TabNavigationProvider({
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => router.navigate(path),
|
||||
replace: (path: string) => router.navigate(path, { replace: true }),
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
const newTabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(newTabId);
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
store.switchWorkspace(slug, path);
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useMatches,
|
||||
} from "react-router-dom";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
@@ -20,13 +19,9 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { Server } from "lucide-react";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
@@ -59,77 +54,28 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorkspaceRoute() {
|
||||
const nav = useNavigation();
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root index route: resolves the URL-less `/` path to a concrete destination.
|
||||
*
|
||||
* Runs both on first login (App.tsx seeded the cache) and on app reopen
|
||||
* (AuthInitializer seeded the cache). Reading from React Query avoids
|
||||
* duplicate fetches across tabs — each tab's memory router hits this
|
||||
* component independently but the query is deduped.
|
||||
*
|
||||
* Sends first-time users without any workspace to /workspaces/new,
|
||||
* everyone else to their first workspace's issues page. Persisted tab
|
||||
* paths that already carry a workspace slug bypass this component
|
||||
* entirely.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
|
||||
|
||||
// Wait for the query to settle so we don't redirect to /workspaces/new
|
||||
// on the initial render before the seeded/fetched data arrives.
|
||||
if (!isFetched) return null;
|
||||
|
||||
const firstWorkspace = wsList?.[0];
|
||||
if (firstWorkspace) {
|
||||
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
|
||||
}
|
||||
return <Navigate to={paths.newWorkspace()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions shared by all tabs.
|
||||
*
|
||||
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
|
||||
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
|
||||
* slug to a workspace and syncing side-effects (api client, persist namespace,
|
||||
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
* Every tab path is workspace-scoped: `/{slug}/{route}/...`. Pre-workspace
|
||||
* flows (create workspace, accept invite) are NOT routes — they render as a
|
||||
* window-level overlay via `WindowOverlay`, dispatched by the navigation
|
||||
* adapter's transition-path interception. The `activeWorkspaceSlug` in the
|
||||
* tab store decides which workspace's tabs are visible in the TabBar;
|
||||
* workspace-less state (zero-workspace user) shows the overlay instead.
|
||||
*
|
||||
* The root index route stays as a harmless safety net. With per-workspace
|
||||
* tabs, nothing should construct a tab at `/` — but if one ever slips
|
||||
* through (malformed persisted state that dodges the migration, direct
|
||||
* router.navigate from unforeseen code), the index falls back to null
|
||||
* rather than 404; App.tsx's bootstrap repoints activeWorkspaceSlug on the
|
||||
* next render pass.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
|
||||
// list from React Query cache (seeded by AuthInitializer on reopen
|
||||
// or App.tsx on deep-link login) and bounces to the first
|
||||
// workspace's issues page — or /workspaces/new if the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "workspaces/new",
|
||||
element: <NewWorkspaceRoute />,
|
||||
handle: { title: "Create Workspace" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{ index: true, element: null },
|
||||
{
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
@@ -185,6 +131,12 @@ export const appRoutes: RouteObject[] = [
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// createTabRouter transitively pulls in route modules that expect a browser
|
||||
// router context. For pure-function tests we stub it out.
|
||||
// router context. For pure store tests we stub it to a minimal disposable.
|
||||
const createTabRouterMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
dispose: vi.fn(),
|
||||
state: { location: { pathname: "/" } },
|
||||
navigate: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
})),
|
||||
);
|
||||
vi.mock("../routes", () => ({
|
||||
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
createTabRouter: createTabRouterMock,
|
||||
}));
|
||||
|
||||
import { sanitizeTabPath } from "./tab-store";
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
beforeEach(() => {
|
||||
createTabRouterMock.mockClear();
|
||||
useTabStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("sanitizeTabPath", () => {
|
||||
it("passes through root sentinel", () => {
|
||||
expect(sanitizeTabPath("/")).toBe("/");
|
||||
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
|
||||
expect(sanitizeTabPath("/")).toBeNull();
|
||||
expect(sanitizeTabPath("")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through global paths", () => {
|
||||
expect(sanitizeTabPath("/login")).toBe("/login");
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
|
||||
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
|
||||
it("silently rejects transition paths (no warn — navigation adapter intercepts them)", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBeNull();
|
||||
expect(sanitizeTabPath("/invite/abc")).toBeNull();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through valid workspace-scoped paths", () => {
|
||||
@@ -25,21 +44,181 @@ describe("sanitizeTabPath", () => {
|
||||
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
|
||||
});
|
||||
|
||||
it("rejects paths whose first segment is a reserved slug", () => {
|
||||
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
|
||||
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
|
||||
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/issues")).toBe("/");
|
||||
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
|
||||
expect(sanitizeTabPath("/settings")).toBe("/");
|
||||
expect(sanitizeTabPath("/issues")).toBeNull();
|
||||
expect(sanitizeTabPath("/settings")).toBeNull();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
|
||||
// A workspace owner could legitimately pick "acme-issues" or
|
||||
// "project-x" as their slug — sanitize must not touch these.
|
||||
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
|
||||
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV1ToV2", () => {
|
||||
it("groups v1 flat tabs by workspace slug", () => {
|
||||
const v1 = {
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
{ id: "t3", path: "/butter/issues", title: "Issues", icon: "ListTodo" },
|
||||
],
|
||||
activeTabId: "t2",
|
||||
};
|
||||
const v2 = migrateV1ToV2(v1);
|
||||
expect(Object.keys(v2.byWorkspace).sort()).toEqual(["acme", "butter"]);
|
||||
expect(v2.byWorkspace.acme.tabs).toHaveLength(2);
|
||||
expect(v2.byWorkspace.butter.tabs).toHaveLength(1);
|
||||
expect(v2.byWorkspace.acme.activeTabId).toBe("t2");
|
||||
expect(v2.byWorkspace.butter.activeTabId).toBe("t3"); // first tab in group
|
||||
expect(v2.activeWorkspaceSlug).toBe("acme"); // contained v1.activeTabId
|
||||
});
|
||||
|
||||
it("drops tabs at root / transition / reserved-slug paths", () => {
|
||||
const v1 = {
|
||||
tabs: [
|
||||
{ id: "t1", path: "/", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/workspaces/new", title: "New", icon: "Plus" },
|
||||
{ id: "t3", path: "/invite/abc", title: "Invite", icon: "Mail" },
|
||||
{ id: "t4", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
],
|
||||
activeTabId: "t1",
|
||||
};
|
||||
const v2 = migrateV1ToV2(v1);
|
||||
expect(Object.keys(v2.byWorkspace)).toEqual(["acme"]);
|
||||
expect(v2.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
// v1.activeTabId was dropped; active falls back to first group's first tab.
|
||||
expect(v2.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v2.byWorkspace.acme.activeTabId).toBe("t4");
|
||||
});
|
||||
|
||||
it("handles empty v1 state gracefully", () => {
|
||||
const v2 = migrateV1ToV2({ tabs: [], activeTabId: "" });
|
||||
expect(v2.byWorkspace).toEqual({});
|
||||
expect(v2.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
|
||||
it("handles v1 with no tabs field (corrupted state)", () => {
|
||||
const v2 = migrateV1ToV2({});
|
||||
expect(v2.byWorkspace).toEqual({});
|
||||
expect(v2.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTabStore actions", () => {
|
||||
it("switchWorkspace creates a new group with a default tab on first entry", () => {
|
||||
useTabStore.getState().switchWorkspace("acme");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
|
||||
});
|
||||
|
||||
it("switchWorkspace without openPath restores the group's last active tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const acmeProjectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
store.setActiveTab(acmeProjectsId);
|
||||
|
||||
// Enter a different workspace then come back
|
||||
store.switchWorkspace("butter");
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("butter");
|
||||
|
||||
store.switchWorkspace("acme");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.activeTabId).toBe(acmeProjectsId);
|
||||
});
|
||||
|
||||
it("switchWorkspace with openPath dedupes into an existing tab with same path", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default /acme/issues
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
|
||||
store.switchWorkspace("acme", "/acme/issues");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(2); // no duplicate created
|
||||
const activeTab = s.byWorkspace.acme.tabs.find(
|
||||
(t) => t.id === s.byWorkspace.acme.activeTabId,
|
||||
);
|
||||
expect(activeTab?.path).toBe("/acme/issues");
|
||||
});
|
||||
|
||||
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("acme", "/acme/issues/bug-42");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
|
||||
const activeTab = s.byWorkspace.acme.tabs.find(
|
||||
(t) => t.id === s.byWorkspace.acme.activeTabId,
|
||||
);
|
||||
expect(activeTab?.path).toBe("/acme/issues/bug-42");
|
||||
});
|
||||
|
||||
it("openTab dedupes by path within the active workspace", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const id1 = store.openTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const id2 = store.openTab("/acme/projects", "Projects", "FolderKanban");
|
||||
expect(id1).toBe(id2);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
|
||||
});
|
||||
|
||||
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
store.closeTab(onlyTabId);
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
|
||||
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("butter");
|
||||
store.switchWorkspace("acme");
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
|
||||
// Admin removed the user from acme
|
||||
store.validateWorkspaceSlugs(new Set(["butter"]));
|
||||
const s = useTabStore.getState();
|
||||
expect(Object.keys(s.byWorkspace)).toEqual(["butter"]);
|
||||
expect(s.activeWorkspaceSlug).toBe("butter");
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs sets activeWorkspaceSlug to null when all groups are dropped", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.validateWorkspaceSlugs(new Set());
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace).toEqual({});
|
||||
expect(s.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
|
||||
it("reset wipes the whole store", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("butter");
|
||||
store.reset();
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBeNull();
|
||||
expect(s.byWorkspace).toEqual({});
|
||||
});
|
||||
|
||||
it("setActiveTab across workspaces also flips the active workspace", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("butter");
|
||||
const acmeTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
store.setActiveTab(acmeTabId);
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
||||
import { isReservedSlug } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createTabRouter } from "../routes";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
/** Every tab path is workspace-scoped: `/{workspaceSlug}/{route}/...`. */
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
@@ -21,33 +22,77 @@ export interface Tab {
|
||||
historyLength: number;
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
export interface WorkspaceTabGroup {
|
||||
tabs: Tab[];
|
||||
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
/** Open a background tab. Deduplicates by path. Returns the tab id. */
|
||||
interface TabStore {
|
||||
/**
|
||||
* The workspace currently visible in the TabBar / TabContent. Null in three
|
||||
* cases:
|
||||
* - Fresh install, before any workspace exists or is selected.
|
||||
* - Logged-out state (reset() wipes it).
|
||||
* - Every workspace the user had access to got deleted / revoked.
|
||||
* When null, TabContent renders nothing and the WindowOverlay takes over.
|
||||
*/
|
||||
activeWorkspaceSlug: string | null;
|
||||
|
||||
/**
|
||||
* Tab groups keyed by workspace slug. Each slug maps to an independent
|
||||
* (tabs, activeTabId) pair; switching workspaces swaps the visible set
|
||||
* without affecting any other group. Cross-workspace tab leakage — the
|
||||
* bug that drove this refactor — is impossible by construction because
|
||||
* there is no global tab array anymore.
|
||||
*/
|
||||
byWorkspace: Record<string, WorkspaceTabGroup>;
|
||||
|
||||
/**
|
||||
* Switch to a workspace.
|
||||
* - If the group doesn't exist yet, create it with a single default tab.
|
||||
* - If `openPath` is given, find a tab with that exact path and activate
|
||||
* it; otherwise add a new tab and activate it.
|
||||
* - If `openPath` is omitted, restore the group's last active tab
|
||||
* (VSCode / Slack behavior — workspaces resume where you left off).
|
||||
*/
|
||||
switchWorkspace: (slug: string, openPath?: string) => void;
|
||||
/** Open-or-activate (dedupes by path) a tab in the active workspace. */
|
||||
openTab: (path: string, title: string, icon: string) => string;
|
||||
/** Always create a new tab (no dedup). Returns the tab id. */
|
||||
/** Always creates a new tab (no dedupe) in the active workspace. */
|
||||
addTab: (path: string, title: string, icon: string) => string;
|
||||
/** Close a tab. Disposes router. */
|
||||
/**
|
||||
* Close a tab. Finds it across all workspaces (callers like the X button
|
||||
* only know the tab id, not the owning workspace). If this is the last
|
||||
* tab in its workspace, reseed a default tab so the invariant
|
||||
* "every live workspace has at least one tab" holds.
|
||||
*/
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switch to a tab by id. */
|
||||
/**
|
||||
* Activate a tab. Finds it across all workspaces. Sets both the owning
|
||||
* workspace as active and that group's activeTabId; needed for any code
|
||||
* path that "jumps" to a tab belonging to a non-active workspace.
|
||||
*/
|
||||
setActiveTab: (tabId: string) => void;
|
||||
/** Update a tab's metadata (path, title, icon — partial). */
|
||||
/** Patch metadata of a tab (router-sync, title-sync). Finds across groups. */
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Update a tab's history tracking. */
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
|
||||
/** Reorder within the active workspace's group only. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Reset any tab whose first path segment references a workspace slug the
|
||||
* current user doesn't have access to. Called after login + workspace list
|
||||
* is populated (and on every subsequent list change, e.g. realtime
|
||||
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
|
||||
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
|
||||
* are untouched.
|
||||
* After the workspace list arrives/changes (login, realtime delete), drop
|
||||
* any tab group whose slug is no longer in `validSlugs`, and repoint
|
||||
* `activeWorkspaceSlug` if it pointed at one of the dropped groups.
|
||||
*/
|
||||
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
|
||||
/**
|
||||
* Wipe everything. Called from logout so the next user doesn't inherit
|
||||
* the prior user's tabs. Zustand persist only writes to localStorage;
|
||||
* clearing the storage key alone would leave this live store intact
|
||||
* until app restart.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,232 +112,594 @@ const ROUTE_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a route icon from a pathname. Title is NOT determined here — it
|
||||
* comes from document.title.
|
||||
* Resolve a route icon from a pathname.
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
|
||||
* Tab paths are always workspace-scoped: `/{slug}/{route}/...`, so the route
|
||||
* segment lives at index 1. Pre-workspace flows (create, invite) are rendered
|
||||
* by the window overlay, never as tabs.
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
* Title is NOT determined here — it comes from document.title.
|
||||
*/
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegment = isGlobalPath(pathname)
|
||||
? (segments[0] ?? "")
|
||||
: (segments[1] ?? "");
|
||||
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
|
||||
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
|
||||
}
|
||||
|
||||
/** Extract the leading workspace slug from a path, or null if the path
|
||||
* isn't workspace-scoped (global path, root, or empty). */
|
||||
function extractWorkspaceSlug(path: string): string | null {
|
||||
const first = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!first) return null;
|
||||
if (isReservedSlug(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path sanitization (defensive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Defensive: catch paths that don't belong in the tab store.
|
||||
*
|
||||
* Two kinds of rejects:
|
||||
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
|
||||
* pre-workspace flows rendered by the window overlay on desktop, not
|
||||
* tab routes. The navigation adapter normally intercepts these before
|
||||
* they reach the store; this guard catches older persisted state.
|
||||
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
|
||||
* was constructed without the workspace prefix. The router would
|
||||
* interpret `issues` as a workspace slug → NoAccessPage.
|
||||
*
|
||||
* Returns null for rejects (caller decides how to recover — usually by
|
||||
* dropping the tab or substituting a default). Unlike the prior design,
|
||||
* there is no root "/" sentinel — tabs are always scoped.
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string | null {
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!firstSegment) return null;
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// Don't log for known transition paths — these are legitimate inputs
|
||||
// at the interception boundary (older persisted state or stale callers).
|
||||
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
|
||||
if (!isTransition) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Dropping.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createId(): string {
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
return {
|
||||
id: createId(),
|
||||
path,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default entry point for a workspace — its issues list. */
|
||||
function defaultPathFor(slug: string): string {
|
||||
return `/${slug}/issues`;
|
||||
}
|
||||
|
||||
function defaultTabFor(slug: string): Tab {
|
||||
const path = defaultPathFor(slug);
|
||||
return makeTab(path, "Issues", resolveRouteIcon(path));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findTabLocation(
|
||||
byWorkspace: Record<string, WorkspaceTabGroup>,
|
||||
tabId: string,
|
||||
): { slug: string; group: WorkspaceTabGroup; index: number } | null {
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
const group = byWorkspace[slug];
|
||||
const index = group.tabs.findIndex((t) => t.id === tabId);
|
||||
if (index >= 0) return { slug, group, index };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sentinel path for new tabs with no explicit destination. The tab store is
|
||||
* workspace-implicit — it doesn't know which workspace is active, so it can't
|
||||
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
|
||||
* matches the top-level index route, which redirects to the workspace default
|
||||
* (slug-aware redirect lives in routes.tsx / App.tsx).
|
||||
*
|
||||
* `title` and `icon` on the placeholder tab get overwritten by
|
||||
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
|
||||
*/
|
||||
const DEFAULT_PATH = "/";
|
||||
|
||||
function createId(): string {
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: catch tab paths that were constructed without a workspace slug
|
||||
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
|
||||
* paths would get matched as `workspaceSlug="issues"` by the router and
|
||||
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
|
||||
* a valid workspace).
|
||||
*
|
||||
* Passes through:
|
||||
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
|
||||
* - workspace-scoped paths whose first segment is not a reserved word
|
||||
*
|
||||
* Rejects (and rewrites to "/"):
|
||||
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
|
||||
* means the caller forgot to prefix the workspace. Logs a warning so the
|
||||
* buggy call site is easy to find.
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string {
|
||||
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
const safePath = sanitizeTabPath(path);
|
||||
return {
|
||||
id: createId(),
|
||||
path: safePath,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(safePath),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
|
||||
export const useTabStore = create<TabStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [initialTab],
|
||||
activeTabId: initialTab.id,
|
||||
activeWorkspaceSlug: null,
|
||||
byWorkspace: {},
|
||||
|
||||
openTab(path, title, icon) {
|
||||
const { tabs } = get();
|
||||
const existing = tabs.find((t) => t.path === path);
|
||||
if (existing) return existing.id;
|
||||
switchWorkspace(slug, openPath) {
|
||||
// Defensive no-op if slug is empty/invalid — callers like the
|
||||
// NavigationAdapter's path-parser should already have filtered
|
||||
// these, but belt-and-braces keeps garbage out of the store.
|
||||
if (!slug) return;
|
||||
const { byWorkspace } = get();
|
||||
const existing = byWorkspace[slug];
|
||||
|
||||
const tab = makeTab(path, title, icon);
|
||||
set({ tabs: [...tabs, tab] });
|
||||
return tab.id;
|
||||
},
|
||||
// Decide the desired active path for this workspace.
|
||||
const desiredPath = openPath ?? (existing ? null : defaultPathFor(slug));
|
||||
|
||||
addTab(path, title, icon) {
|
||||
const tab = makeTab(path, title, icon);
|
||||
set((s) => ({ tabs: [...s.tabs, tab] }));
|
||||
return tab.id;
|
||||
},
|
||||
if (!existing) {
|
||||
// First time entering this workspace — create the group.
|
||||
const seedPath =
|
||||
desiredPath && sanitizeTabPath(desiredPath) === desiredPath
|
||||
? desiredPath
|
||||
: defaultPathFor(slug);
|
||||
const tab = makeTab(seedPath, "Issues", resolveRouteIcon(seedPath));
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: [tab], activeTabId: tab.id },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
closeTab(tabId) {
|
||||
const { tabs, activeTabId } = get();
|
||||
// Workspace already has tabs. Either dedupe into an existing tab or
|
||||
// add a new one (when openPath was supplied and no tab matches it).
|
||||
if (desiredPath) {
|
||||
const clean = sanitizeTabPath(desiredPath);
|
||||
if (clean) {
|
||||
const match = existing.tabs.find((t) => t.path === clean);
|
||||
if (match) {
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...existing, activeTabId: match.id },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tab = makeTab(clean, "Issues", resolveRouteIcon(clean));
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: {
|
||||
tabs: [...existing.tabs, tab],
|
||||
activeTabId: tab.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const closingTab = tabs.find((t) => t.id === tabId);
|
||||
// No openPath (or openPath was rejected) — just restore the group.
|
||||
set({ activeWorkspaceSlug: slug });
|
||||
},
|
||||
|
||||
// Never close the last tab — replace with default
|
||||
if (tabs.length === 1) {
|
||||
closingTab?.router.dispose();
|
||||
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
set({ tabs: [fresh], activeTabId: fresh.id });
|
||||
return;
|
||||
}
|
||||
openTab(path, title, icon) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
const clean = sanitizeTabPath(path);
|
||||
if (!activeWorkspaceSlug || !clean) return "";
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return "";
|
||||
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
const existing = group.tabs.find((t) => t.path === clean);
|
||||
if (existing) {
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: { ...group, activeTabId: existing.id },
|
||||
},
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
closingTab?.router.dispose();
|
||||
const next = tabs.filter((t) => t.id !== tabId);
|
||||
const tab = makeTab(clean, title, icon);
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
tabs: [...group.tabs, tab],
|
||||
activeTabId: group.activeTabId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const newActive = next[Math.min(idx, next.length - 1)];
|
||||
set({ tabs: next, activeTabId: newActive.id });
|
||||
} else {
|
||||
set({ tabs: next });
|
||||
}
|
||||
},
|
||||
addTab(path, title, icon) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
const clean = sanitizeTabPath(path);
|
||||
if (!activeWorkspaceSlug || !clean) return "";
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return "";
|
||||
|
||||
setActiveTab(tabId) {
|
||||
set({ activeTabId: tabId });
|
||||
},
|
||||
const tab = makeTab(clean, title, icon);
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
tabs: [...group.tabs, tab],
|
||||
activeTabId: group.activeTabId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
updateTab(tabId, patch) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, ...patch } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
closeTab(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
|
||||
updateTabHistory(tabId, historyIndex, historyLength) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
const closing = group.tabs[index];
|
||||
closing.router.dispose();
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
|
||||
},
|
||||
if (group.tabs.length === 1) {
|
||||
// Last tab in this workspace — reseed a default so the workspace
|
||||
// always has at least one tab. Closing a workspace as an explicit
|
||||
// action is a separate concern (Leave/Delete in Settings).
|
||||
const fresh = defaultTabFor(slug);
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: [fresh], activeTabId: fresh.id },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { tabs } = get();
|
||||
let changed = false;
|
||||
const nextTabs = tabs.map((t) => {
|
||||
// Skip tabs on non-workspace-scoped paths — nothing to validate.
|
||||
if (t.path === "/" || isGlobalPath(t.path)) return t;
|
||||
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
|
||||
const nextActiveTabId =
|
||||
group.activeTabId === tabId
|
||||
? nextTabs[Math.min(index, nextTabs.length - 1)].id
|
||||
: group.activeTabId;
|
||||
|
||||
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (validSlugs.has(firstSegment)) return t;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Stale slug: dispose the old router and replace with a fresh one
|
||||
// pointing at `/`. IndexRedirect will send the tab to a valid
|
||||
// workspace (or /workspaces/new if the user now has none).
|
||||
changed = true;
|
||||
t.router.dispose();
|
||||
return {
|
||||
...t,
|
||||
path: DEFAULT_PATH,
|
||||
title: "Issues",
|
||||
icon: resolveRouteIcon(DEFAULT_PATH),
|
||||
router: createTabRouter(DEFAULT_PATH),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
setActiveTab(tabId) {
|
||||
const { byWorkspace, activeWorkspaceSlug } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group } = hit;
|
||||
if (slug === activeWorkspaceSlug && group.activeTabId === tabId) return;
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, activeTabId: tabId },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
if (!changed) return;
|
||||
set({ tabs: nextTabs });
|
||||
},
|
||||
updateTab(tabId, patch) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const next: Tab = { ...current, ...patch };
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateTabHistory(tabId, historyIndex, historyLength) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const next: Tab = { ...current, historyIndex, historyLength };
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
...group,
|
||||
tabs: arrayMove(group.tabs, fromIndex, toIndex),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
let changed = false;
|
||||
const nextByWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
if (validSlugs.has(slug)) {
|
||||
nextByWorkspace[slug] = byWorkspace[slug];
|
||||
} else {
|
||||
changed = true;
|
||||
for (const t of byWorkspace[slug].tabs) t.router.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
let nextActive = activeWorkspaceSlug;
|
||||
if (nextActive && !validSlugs.has(nextActive)) {
|
||||
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
|
||||
},
|
||||
|
||||
reset() {
|
||||
const { byWorkspace } = get();
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
for (const t of byWorkspace[slug].tabs) t.router.dispose();
|
||||
}
|
||||
set({ activeWorkspaceSlug: null, byWorkspace: {} });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 1,
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
}
|
||||
return persistedState as V2Persisted;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(
|
||||
({ router, historyIndex, historyLength, ...rest }) => rest,
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
byWorkspace: Object.fromEntries(
|
||||
Object.entries(state.byWorkspace).map(([slug, group]) => [
|
||||
slug,
|
||||
{
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map(
|
||||
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
|
||||
rest,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
activeTabId: state.activeTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as
|
||||
| Pick<TabStore, "tabs" | "activeTabId">
|
||||
| undefined;
|
||||
if (!persisted?.tabs?.length) return currentState;
|
||||
const persisted = persistedState as Partial<V2Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => {
|
||||
// Sanitize persisted paths against reserved-slug rules. Catches
|
||||
// both pre-refactor paths like "/issues/abc" (missing workspace
|
||||
// slug) and any other malformed paths that slipped past the
|
||||
// write-time guard. The defense across makeTab + merge + runtime
|
||||
// validate ensures stale or malformed paths never reach the
|
||||
// router.
|
||||
const path = sanitizeTabPath(tab.path);
|
||||
return {
|
||||
...tab,
|
||||
path,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
for (const [slug, pGroup] of Object.entries(persisted.byWorkspace)) {
|
||||
const tabs: Tab[] = [];
|
||||
for (const pTab of pGroup.tabs) {
|
||||
const clean = sanitizeTabPath(pTab.path);
|
||||
// Persisted path may have come from a stale version or a
|
||||
// manual edit. Drop rather than rewrite so we never silently
|
||||
// put users on a path that doesn't match the group's slug.
|
||||
if (!clean || extractWorkspaceSlug(clean) !== slug) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] dropping persisted tab "${pTab.path}" from ` +
|
||||
`group "${slug}" — path/slug mismatch`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tabs.push({
|
||||
id: pTab.id,
|
||||
path: clean,
|
||||
title: pTab.title,
|
||||
icon: pTab.icon,
|
||||
router: createTabRouter(clean),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
});
|
||||
}
|
||||
if (tabs.length === 0) continue;
|
||||
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
|
||||
? pGroup.activeTabId
|
||||
: tabs[0].id;
|
||||
byWorkspace[slug] = { tabs, activeTabId };
|
||||
}
|
||||
|
||||
// Validate activeTabId — fall back to first tab if stale
|
||||
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
|
||||
? persisted.activeTabId
|
||||
: tabs[0].id;
|
||||
const activeWorkspaceSlug =
|
||||
persisted.activeWorkspaceSlug && byWorkspace[persisted.activeWorkspaceSlug]
|
||||
? persisted.activeWorkspaceSlug
|
||||
: (Object.keys(byWorkspace)[0] ?? null);
|
||||
|
||||
return { ...currentState, tabs, activeTabId };
|
||||
return { ...currentState, byWorkspace, activeWorkspaceSlug };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted shapes (for migration)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface V1Tab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface V1Persisted {
|
||||
tabs: V1Tab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V2PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface V2PersistedGroup {
|
||||
tabs: V2PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V2Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
for (const tab of oldTabs) {
|
||||
const slug = extractWorkspaceSlug(tab.path);
|
||||
if (!slug) continue; // drop root / global-path tabs
|
||||
if (!byWorkspace[slug]) byWorkspace[slug] = { tabs: [], activeTabId: "" };
|
||||
byWorkspace[slug].tabs.push({
|
||||
id: tab.id,
|
||||
path: tab.path,
|
||||
title: tab.title,
|
||||
icon: tab.icon,
|
||||
});
|
||||
}
|
||||
|
||||
// Each group needs a valid activeTabId. Prefer the one from v1 if it
|
||||
// landed in this group; otherwise fall back to the first tab.
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
const group = byWorkspace[slug];
|
||||
const hasOldActive = group.tabs.some((t) => t.id === v1.activeTabId);
|
||||
group.activeTabId = hasOldActive
|
||||
? (v1.activeTabId as string)
|
||||
: group.tabs[0].id;
|
||||
}
|
||||
|
||||
// Active workspace: whichever group inherited the v1 activeTab, falling
|
||||
// back to the first group we created (arbitrary but deterministic given
|
||||
// Object.keys iteration order on string keys).
|
||||
let activeWorkspaceSlug: string | null = null;
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
if (byWorkspace[slug].activeTabId === v1.activeTabId) {
|
||||
activeWorkspaceSlug = slug;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!activeWorkspaceSlug) {
|
||||
activeWorkspaceSlug = Object.keys(byWorkspace)[0] ?? null;
|
||||
}
|
||||
|
||||
return { activeWorkspaceSlug, byWorkspace };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors (convenience hooks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pure non-hook helper — useful from event handlers / effects that already
|
||||
* need `.getState()`. For React subscriptions prefer the stable selectors
|
||||
* below.
|
||||
*/
|
||||
export function getActiveTab(s: TabStore): Tab | null {
|
||||
if (!s.activeWorkspaceSlug) return null;
|
||||
const group = s.byWorkspace[s.activeWorkspaceSlug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active workspace's tab group, or null when no workspace is active.
|
||||
*
|
||||
* Zustand compares selector returns with `Object.is`. Because `updateTab`
|
||||
* / `updateTabHistory` replace the group object on every router tick
|
||||
* (immutable update), this selector returns a new reference on every
|
||||
* router event — that's fine for TabBar which needs to observe tab-list
|
||||
* changes, but don't use this selector from components that only care
|
||||
* about one primitive (use `useActiveTabHistory` / `useActiveTabRouter`
|
||||
* instead).
|
||||
*/
|
||||
export function useActiveGroup(): WorkspaceTabGroup | null {
|
||||
return useTabStore((s) =>
|
||||
s.activeWorkspaceSlug ? (s.byWorkspace[s.activeWorkspaceSlug] ?? null) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active tab id + active workspace slug as a compact pair. Both primitives
|
||||
* are stable across unrelated store updates — e.g. an inactive tab's
|
||||
* router tick doesn't churn these, so consumers don't re-render.
|
||||
*
|
||||
* Useful anywhere you'd previously have reached for `useActiveTab()` and
|
||||
* only needed the identity (for memoization, effect deps, ipc).
|
||||
*/
|
||||
export function useActiveTabIdentity(): { slug: string | null; tabId: string | null } {
|
||||
const slug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const tabId = useTabStore((s) =>
|
||||
s.activeWorkspaceSlug
|
||||
? (s.byWorkspace[s.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
);
|
||||
return { slug, tabId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Active tab's router — a stable reference across tab updates, because
|
||||
* routers are created once per tab and never replaced by `updateTab`.
|
||||
* Subscribers only re-render when the active tab *changes*, not on
|
||||
* router events within the current tab.
|
||||
*/
|
||||
export function useActiveTabRouter(): DataRouter | null {
|
||||
return useTabStore((s) => getActiveTab(s)?.router ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* History tracking for the active tab as primitives. Subscribers re-render
|
||||
* only when the numeric index / length change (i.e. on actual navigations),
|
||||
* not on unrelated store updates.
|
||||
*/
|
||||
export function useActiveTabHistory(): {
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
} {
|
||||
const historyIndex = useTabStore((s) => getActiveTab(s)?.historyIndex ?? 0);
|
||||
const historyLength = useTabStore((s) => getActiveTab(s)?.historyLength ?? 1);
|
||||
return { historyIndex, historyLength };
|
||||
}
|
||||
|
||||
30
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
30
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: pre-workspace flows that are NOT pages
|
||||
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
|
||||
* auto-redirect, or deep link; rendered above the tab system as a full-window
|
||||
* takeover.
|
||||
*
|
||||
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
|
||||
* desktop the URL is invisible to users — routes are an implementation detail
|
||||
* of the tab system. Representing transitions as routes meant tabs tried to
|
||||
* persist them, TabBar rendered on top, and invite deep-linking had no clean
|
||||
* dispatch target. Modeling them as application state removes all three.
|
||||
*/
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
overlay: WindowOverlay | null;
|
||||
open: (overlay: WindowOverlay) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
|
||||
overlay: null,
|
||||
open: (overlay) => set({ overlay }),
|
||||
close: () => set({ overlay: null }),
|
||||
}));
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Multica Documentation
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-fd-muted-foreground">
|
||||
The open-source managed agents platform. Turn coding agents into real
|
||||
teammates — assign tasks, track progress, compound skills.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/multica-ai/multica"
|
||||
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
@@ -29,12 +29,12 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
export function generateStaticParams() {
|
||||
return source.generateParams().filter((p) => p.slug.length > 0);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
@@ -1,12 +0,0 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
|
||||
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
@@ -8,11 +7,6 @@ export const baseOptions: BaseLayoutProps = {
|
||||
),
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "Documentation",
|
||||
url: "/docs",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "GitHub",
|
||||
url: "https://github.com/multica-ai/multica",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import "./global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -16,7 +19,11 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<RootProvider>{children}</RootProvider>
|
||||
<RootProvider>
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
18
apps/docs/app/not-found.tsx
Normal file
18
apps/docs/app/not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-24 text-center">
|
||||
<h1 className="text-3xl font-semibold">Page not found</h1>
|
||||
<p className="text-fd-muted-foreground">
|
||||
The page you are looking for doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center rounded-md bg-fd-primary px-4 py-2 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
|
||||
>
|
||||
Back to docs
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
37
apps/docs/app/page.tsx
Normal file
37
apps/docs/app/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { source } from "@/lib/source";
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsTitle,
|
||||
} from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export default function Page() {
|
||||
const page = source.getPage([]);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
const page = source.getPage([]);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -68,7 +68,7 @@ multica setup
|
||||
|
||||
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
|
||||
|
||||
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
|
||||
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/getting-started/self-hosting) for details.
|
||||
|
||||
## Verify
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -227,7 +227,7 @@ multica issue get <id> --output json
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -281,6 +281,70 @@ multica issue run-messages <task-id> --output json
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
|
||||
belongs to a workspace and can optionally have a lead (member or agent).
|
||||
|
||||
### List Projects
|
||||
|
||||
```bash
|
||||
multica project list
|
||||
multica project list --status in_progress
|
||||
multica project list --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`.
|
||||
|
||||
### Get Project
|
||||
|
||||
```bash
|
||||
multica project get <id>
|
||||
multica project get <id> --output json
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
```bash
|
||||
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Update Project
|
||||
|
||||
```bash
|
||||
multica project update <id> --title "New title" --status in_progress
|
||||
multica project update <id> --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
multica project status <id> in_progress
|
||||
```
|
||||
|
||||
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
|
||||
|
||||
### Delete Project
|
||||
|
||||
```bash
|
||||
multica project delete <id>
|
||||
```
|
||||
|
||||
### Associating Issues with Projects
|
||||
|
||||
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
|
||||
project, or on `issue list` to filter issues by project:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Login bug" --project <project-id>
|
||||
multica issue update <issue-id> --project <project-id>
|
||||
multica issue list --project <project-id>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
|
||||
@@ -169,6 +169,16 @@ Stop PostgreSQL and keep local databases:
|
||||
make db-down
|
||||
```
|
||||
|
||||
Reset only the current checkout's database (drops `POSTGRES_DB`, recreates it, re-runs all migrations). Other worktree databases are untouched.
|
||||
|
||||
```bash
|
||||
make stop
|
||||
make db-reset
|
||||
make start
|
||||
```
|
||||
|
||||
> `make db-reset` refuses to run if `DATABASE_URL` points at a remote host.
|
||||
|
||||
Wipe all local PostgreSQL data:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -64,10 +64,14 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
<Callout>
|
||||
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
@@ -198,7 +202,14 @@ For file uploads and attachments, configure S3 and CloudFront:
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Cookies
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
|
||||
|
||||
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
|
||||
|
||||
### Server
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Cloud Quickstart](/docs/getting-started/cloud-quickstart)
|
||||
- [Self-Hosting](/docs/getting-started/self-hosting)
|
||||
- [CLI Installation](/docs/cli/installation)
|
||||
- [Contributing](/docs/developers/contributing)
|
||||
- [Cloud Quickstart](/getting-started/cloud-quickstart)
|
||||
- [Self-Hosting](/getting-started/self-hosting)
|
||||
- [CLI Installation](/cli/installation)
|
||||
- [Contributing](/developers/contributing)
|
||||
|
||||
@@ -2,6 +2,6 @@ import { docs } from "@/.source";
|
||||
import { loader } from "fumadocs-core/source";
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: "/docs",
|
||||
baseUrl: "/",
|
||||
source: docs.toFumadocsSource(),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ const withMDX = createMDX();
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
basePath: "/docs",
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
@@ -11,6 +13,10 @@ export default function InviteAcceptPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const { data: wsList = [] } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Redirect to login if not authenticated, with a redirect back to this page.
|
||||
useEffect(() => {
|
||||
@@ -23,5 +29,8 @@ export default function InviteAcceptPage() {
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitePage invitationId={params.id} />;
|
||||
const onBack =
|
||||
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
|
||||
|
||||
return <InvitePage invitationId={params.id} onBack={onBack} />;
|
||||
}
|
||||
|
||||
@@ -11,32 +11,51 @@ function createWrapper() {
|
||||
);
|
||||
}
|
||||
|
||||
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockSendCode,
|
||||
mockVerifyCode,
|
||||
mockIssueCliToken,
|
||||
searchParamsState,
|
||||
authStateRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
mockIssueCliToken: vi.fn(),
|
||||
searchParamsState: { params: new URLSearchParams() },
|
||||
authStateRef: {
|
||||
state: {
|
||||
sendCode: vi.fn(),
|
||||
verifyCode: vi.fn(),
|
||||
user: null as null | { id: string; email: string },
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/login",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useSearchParams: () => searchParamsState.params,
|
||||
}));
|
||||
|
||||
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading)
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const authState = {
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
};
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
|
||||
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
|
||||
// than silently drifting behind a mock reimplementation.
|
||||
vi.mock("@multica/core/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
authStateRef.state.sendCode = mockSendCode;
|
||||
authStateRef.state.verifyCode = mockVerifyCode;
|
||||
const useAuthStore = Object.assign(
|
||||
(selector: (s: typeof authState) => unknown) => selector(authState),
|
||||
{ getState: () => authState },
|
||||
(selector: (s: typeof authStateRef.state) => unknown) =>
|
||||
selector(authStateRef.state),
|
||||
{ getState: () => authStateRef.state },
|
||||
);
|
||||
return { useAuthStore };
|
||||
return { ...actual, useAuthStore };
|
||||
});
|
||||
|
||||
// Mock auth-cookie
|
||||
@@ -51,6 +70,7 @@ vi.mock("@multica/core/api", () => ({
|
||||
verifyCode: vi.fn(),
|
||||
setToken: vi.fn(),
|
||||
getMe: vi.fn(),
|
||||
issueCliToken: mockIssueCliToken,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -59,6 +79,9 @@ import LoginPage from "./page";
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsState.params = new URLSearchParams();
|
||||
authStateRef.state.user = null;
|
||||
authStateRef.state.isLoading = false;
|
||||
});
|
||||
|
||||
it("renders login form with email input and continue button", () => {
|
||||
@@ -131,4 +154,44 @@ describe("LoginPage", () => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: MUL-1080 — if the user is already authenticated on the web
|
||||
// and the Desktop app redirects them to /login?platform=desktop, the web
|
||||
// must exchange the cookie session for a bearer token and hand it off via
|
||||
// the multica:// deep link, not silently redirect to the workspace page.
|
||||
it("mints a token and deep-links to Desktop when already logged in with platform=desktop", async () => {
|
||||
searchParamsState.params = new URLSearchParams({ platform: "desktop" });
|
||||
authStateRef.state.user = { id: "u1", email: "test@multica.ai" };
|
||||
mockIssueCliToken.mockImplementation(() =>
|
||||
Promise.resolve({ token: "handoff-jwt" }),
|
||||
);
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockIssueCliToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"multica://auth/callback?token=handoff-jwt",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Open Multica Desktop" }),
|
||||
).toBeInTheDocument();
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import {
|
||||
paths,
|
||||
resolvePostAuthDestination,
|
||||
useHasOnboarded,
|
||||
} from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
@@ -22,40 +36,67 @@ function LoginPageContent() {
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const platform = searchParams.get("platform");
|
||||
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
|
||||
// `next` carries a protected URL the user was originally headed to
|
||||
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
|
||||
// "/issues" default — if `next` is absent we decide after login based on
|
||||
// the user's workspace list.
|
||||
const nextUrl = searchParams.get("next");
|
||||
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
|
||||
// cannot bounce the user off-origin after a successful login.
|
||||
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
|
||||
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
const [desktopError, setDesktopError] = useState("");
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
// (or /onboarding if the user has none). Skip this entire path when
|
||||
// the user arrived to authorize the CLI.
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
if (isDesktopHandoff) {
|
||||
// Desktop opened the browser for login but the web session is already
|
||||
// authenticated — mint a bearer token from the cookie session and hand
|
||||
// it off via deep link instead of silently redirecting to the workspace.
|
||||
api
|
||||
.issueCliToken()
|
||||
.then(({ token }) => {
|
||||
setDesktopToken(token);
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
|
||||
})
|
||||
.catch((err) => {
|
||||
setDesktopError(
|
||||
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
// Read the latest user snapshot directly — the closure's `hasOnboarded`
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
// The LoginPage view populates the workspace list cache before calling
|
||||
// onSuccess, so it's safe to read here.
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.push(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
router.push(resolvePostAuthDestination(list, onboarded));
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
@@ -67,6 +108,52 @@ function LoginPageContent() {
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
// While the desktop handoff is in progress (or has produced a token/error),
|
||||
// render a dedicated screen instead of flashing the login form or redirecting
|
||||
// away to a workspace page.
|
||||
if (isDesktopHandoff && user) {
|
||||
if (desktopError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
|
||||
<CardDescription>{desktopError}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Opening Multica</CardTitle>
|
||||
<CardDescription>
|
||||
{desktopToken
|
||||
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
|
||||
: "Preparing Desktop sign-in..."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
{desktopToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
|
||||
}}
|
||||
>
|
||||
Open Multica Desktop
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginPage
|
||||
onSuccess={handleSuccess}
|
||||
|
||||
84
apps/web/app/(auth)/onboarding/page.tsx
Normal file
84
apps/web/app/(auth)/onboarding/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
paths,
|
||||
resolvePostAuthDestination,
|
||||
useHasOnboarded,
|
||||
} from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboarding";
|
||||
|
||||
/**
|
||||
* Web shell for the onboarding flow. The route is the platform chrome on
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
*
|
||||
* The CLI install card is wired here so its `multica setup` command
|
||||
* points at THIS server — dev landing on localhost gets a localhost
|
||||
* self-host command, prod cloud gets the plain `multica setup`, prod
|
||||
* self-host gets one with explicit URLs. `appUrl` lives in useState
|
||||
* so SSR doesn't error on `window` — it fills in on mount.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
});
|
||||
const [appUrl, setAppUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setAppUrl(window.location.origin);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
// owns the per-step width constraint internally — Welcome renders a
|
||||
// wide two-column hero, all other steps wrap themselves at max-w-xl.
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
}
|
||||
}}
|
||||
runtimeInstructions={
|
||||
<CliInstallInstructions
|
||||
apiUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
appUrl={appUrl}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const { data: wsList = [] } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
@@ -17,9 +23,16 @@ export default function Page() {
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
// Back goes to the root path — the workspace layout redirects from
|
||||
// there to the user's default workspace. Only show Back when there's
|
||||
// somewhere to go back to (user already has at least one workspace).
|
||||
const onBack =
|
||||
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
|
||||
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,21 @@ import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
extra={
|
||||
<>
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 flex-1 max-w-md" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
export default function WorkspaceLayout({
|
||||
@@ -60,11 +61,17 @@ export default function WorkspaceLayout({
|
||||
// and we just need to hold null briefly.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
const loadingIndicator = (
|
||||
<div className="flex h-svh items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isAuthLoading) return loadingIndicator;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the list hasn't populated or the slug is unknown — gating
|
||||
// here makes that invariant hold for every descendant.
|
||||
if (!listFetched) return null;
|
||||
if (!listFetched) return loadingIndicator;
|
||||
if (!workspace) {
|
||||
// If we've resolved this slug before in this session, it was just
|
||||
// removed from our list (deleted/left/evicted). A navigate is almost
|
||||
|
||||
112
apps/web/app/auth/callback/page.test.tsx
Normal file
112
apps/web/app/auth/callback/page.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
||||
id: "user-1",
|
||||
name: "Test",
|
||||
email: "test@multica.ai",
|
||||
avatar_url: null,
|
||||
onboarded_at: null,
|
||||
onboarding_questionnaire: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
||||
// exercised rather than silently diverging from the source of truth.
|
||||
vi.mock("@multica/core/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuthStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({ loginWithGoogle: mockLoginWithGoogle }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: mockListWorkspaces,
|
||||
googleLogin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import CallbackPage from "./page";
|
||||
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
mockSearchParams.set("state", "next:https://evil.example");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
||||
});
|
||||
|
||||
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
@@ -42,7 +42,9 @@ function CallbackContent() {
|
||||
const stateParts = state.split(",");
|
||||
const isDesktop = stateParts.includes("platform:desktop");
|
||||
const nextPart = stateParts.find((p) => p.startsWith("next:"));
|
||||
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
|
||||
// Strip "next:" prefix, then drop anything that isn't a safe relative path
|
||||
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
|
||||
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
@@ -60,18 +62,17 @@ function CallbackContent() {
|
||||
} else {
|
||||
// Normal web flow
|
||||
loginWithGoogle(code, redirectUri)
|
||||
.then(async () => {
|
||||
.then(async (loggedInUser) => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
// URL is now the source of truth for the current workspace — the
|
||||
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
|
||||
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
|
||||
// in the first workspace's issues, or /workspaces/new for zero-workspace users.
|
||||
const [first] = wsList;
|
||||
const defaultDest = first
|
||||
? paths.workspace(first.slug).issues()
|
||||
: paths.newWorkspace();
|
||||
router.push(nextUrl || defaultDest);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter, Geist_Mono } from "next/font/google";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -39,6 +39,23 @@ const geistMono = Geist_Mono({
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
// Editorial serif used for onboarding headlines. Italic support for h1 em
|
||||
// accents (e.g. "...on one shared board."). Only loaded on routes that
|
||||
// render the font; layout-shift-prevention handled by next/font's synthetic
|
||||
// fallback metrics, same as Inter.
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
variable: "--font-serif",
|
||||
fallback: [
|
||||
"ui-serif",
|
||||
"Iowan Old Style",
|
||||
"Apple Garamond",
|
||||
"Baskerville",
|
||||
"Times New Roman",
|
||||
"serif",
|
||||
],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -89,7 +106,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
|
||||
29
apps/web/components/pageview-tracker.tsx
Normal file
29
apps/web/components/pageview-tracker.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the Next.js App Router path or query
|
||||
* string changes. Mounted once at the root so every route transition is
|
||||
* covered, including transitions into workspace-scoped subtrees.
|
||||
*
|
||||
* PostHog's own `capture_pageview: true` auto-capture is deliberately
|
||||
* disabled in `initAnalytics` so we own the event shape — this component
|
||||
* is what actually fires the event. Before this existed the acquisition
|
||||
* funnel's `/ → signup` step was empty.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const qs = searchParams?.toString();
|
||||
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||
capturePageview(url);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "@/features/auth/auth-cookie";
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
// Legacy token in localStorage → keep this session in token mode so users who
|
||||
// logged in before the cookie-auth migration stay authed. They migrate to
|
||||
@@ -42,6 +44,11 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
{/* Suspense boundary is required by Next.js for useSearchParams in
|
||||
a client component mounted this high in the tree. */}
|
||||
<Suspense fallback={null}>
|
||||
<PageviewTracker />
|
||||
</Suspense>
|
||||
<WebNavigationProvider>{children}</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceListOptions } from "@multica/core/workspace";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths";
|
||||
|
||||
/**
|
||||
* Client-side fallback redirect for authenticated visitors on the landing page.
|
||||
@@ -16,7 +16,7 @@ import { paths } from "@multica/core/paths";
|
||||
* login* — before the user has ever visited a workspace — the cookie is
|
||||
* absent, so the proxy falls through to the landing page. This component
|
||||
* covers that gap: once auth is resolved and the workspace list has loaded,
|
||||
* push the user into their workspace (or /workspaces/new if they have none).
|
||||
* push the user into their workspace (or /onboarding if they have none).
|
||||
*
|
||||
* Renders nothing. Uses `router.replace` so the landing page never enters
|
||||
* browser history for authenticated users.
|
||||
@@ -25,21 +25,17 @@ export function RedirectIfAuthenticated() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
const { data: list } = useQuery({
|
||||
const { data: list = [], isFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || !list) return;
|
||||
const [first] = list;
|
||||
if (!first) {
|
||||
router.replace(paths.newWorkspace());
|
||||
return;
|
||||
}
|
||||
router.replace(paths.workspace(first.slug).issues());
|
||||
}, [isLoading, user, list, router]);
|
||||
if (isLoading || !user || !isFetched) return;
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
}, [isLoading, user, isFetched, list, hasOnboarded, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const en: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
@@ -120,9 +122,10 @@ export const en: LandingDict = {
|
||||
headlineFaded: "in the next hour.",
|
||||
steps: [
|
||||
{
|
||||
title: "Sign up & create your workspace",
|
||||
description:
|
||||
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
|
||||
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
|
||||
description: ALLOW_SIGNUP
|
||||
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
|
||||
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
|
||||
},
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
@@ -279,6 +282,82 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
title: "Desktop Cross-Platform Packaging, CLI Self-Update & Board Pagination",
|
||||
changes: [],
|
||||
features: [
|
||||
"Desktop app cross-platform packaging — macOS, Windows, and Linux artifacts from a single release pipeline",
|
||||
"`multica update` self-update command — upgrade the CLI and local daemon without reinstalling",
|
||||
"Issue board paginates every status column, not only Done — large backlogs stay responsive",
|
||||
],
|
||||
fixes: [
|
||||
"Workspace isolation enforced end-to-end for agent execution on the local daemon (security)",
|
||||
"Windows daemon stays alive after the terminal closes, so background agents keep running",
|
||||
"Board cards render their description preview again — list queries no longer strip the description field",
|
||||
"OpenClaw agent runtime now reads the real model from agent metadata instead of falling back to a default",
|
||||
"Comment Markdown preserved end-to-end — the HTML sanitizer that was stripping formatting has been removed",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Per-Agent Models, Kimi Runtime & Self-Host Auth",
|
||||
changes: [],
|
||||
features: [
|
||||
"Per-agent `model` field with a provider-aware dropdown — pick the LLM model for each agent from the UI or via `multica agent create/update --model`, with live discovery from each runtime's CLI",
|
||||
"Kimi CLI as a new agent runtime (Moonshot AI's `kimi-cli` over ACP), with model selection, auto-approved tool permissions, and streaming tool-call rendering",
|
||||
"Expand toggle on inline comment and reply editors for composing long text",
|
||||
],
|
||||
fixes: [
|
||||
"Posting the result comment is now an explicit, numbered step in agent workflows so final replies reach the issue instead of terminal output",
|
||||
"Agent live status card no longer leaks across issues when switching via Cmd+K",
|
||||
"Self-hosted session cookies honor the `FRONTEND_ORIGIN` scheme — plain-HTTP deployments stop silently dropping cookies, and `COOKIE_DOMAIN=<ip>` now falls back to host-only with a warning instead of breaking login",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
title: "Sub-Issues from Editor, Self-Host Gating & MCP",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create sub-issue directly from selected text in the editor bubble menu",
|
||||
"Self-hosted instance gating — `ALLOW_SIGNUP` and `ALLOWED_EMAIL_*` env vars to restrict account creation",
|
||||
"Per-agent `mcp_config` field to restore MCP access",
|
||||
"Desktop app hourly update poll with manual check button in settings",
|
||||
],
|
||||
fixes: [
|
||||
"Session hand-off to desktop when already logged in on web",
|
||||
"Open redirect vulnerability on `?next=` validated",
|
||||
"OpenClaw stops passing unsupported flags and properly delivers AgentInstructions",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
title: "CLI Autopilot, Cmd+K & Daemon Identity",
|
||||
changes: [],
|
||||
features: [
|
||||
"CLI `autopilot` commands for managing scheduled and triggered automations",
|
||||
"CLI `issue subscriber` commands for subscription management",
|
||||
"Cmd+K palette extended — theme toggle, quick new issue/project, copy link, switch workspace",
|
||||
"Project and sub-issue progress as optional card properties on the issue list",
|
||||
"Persistent daemon UUID identity — CLI and desktop share one daemon across restarts and machine moves",
|
||||
"Sole-owner workspace leave preflight check",
|
||||
"Persist comment collapse state across sessions",
|
||||
],
|
||||
fixes: [
|
||||
"Agents now triggered on comments regardless of issue status",
|
||||
"Codex sandbox config fixed for macOS network access",
|
||||
"Editor bubble menu rewritten with @floating-ui/dom for reliable scroll hiding",
|
||||
"Autopilot creator automatically subscribed to autopilot-created issues",
|
||||
"Autopilot workspace ID correctly resolved for run-only tasks",
|
||||
"Desktop restricts `shell.openExternal` to http/https schemes (security)",
|
||||
"Duplicate agent names return 409 instead of silently failing",
|
||||
"New tabs in desktop inherit current workspace",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.1",
|
||||
date: "2026-04-16",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const zh: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
@@ -120,9 +122,10 @@ export const zh: LandingDict = {
|
||||
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
|
||||
steps: [
|
||||
{
|
||||
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
|
||||
description:
|
||||
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
|
||||
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
|
||||
description: ALLOW_SIGNUP
|
||||
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
|
||||
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
|
||||
},
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
@@ -279,6 +282,82 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
title: "桌面应用跨平台打包、CLI 自更新与看板分页",
|
||||
changes: [],
|
||||
features: [
|
||||
"桌面应用跨平台打包——同一条发布流水线产出 macOS、Windows 和 Linux 安装包",
|
||||
"新增 `multica update` 自更新命令——无需重装即可升级 CLI 和本地 Daemon",
|
||||
"Issue 看板所有状态列都支持分页(不再只是 Done 列),大积压下依然流畅",
|
||||
],
|
||||
fixes: [
|
||||
"本地 Daemon 对 Agent 执行强制端到端工作区隔离(安全)",
|
||||
"Windows 下 Daemon 终端关闭后继续常驻,后台 Agent 不再被意外终止",
|
||||
"看板卡片重新显示描述预览——列表查询不再丢掉 description 字段",
|
||||
"OpenClaw Agent 改为从 Agent 元数据读取真实模型,不再回退到默认值",
|
||||
"评论 Markdown 全链路保留——移除会误伤格式的 HTML sanitizer",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Agent 模型选择、Kimi Runtime 与自部署登录",
|
||||
changes: [],
|
||||
features: [
|
||||
"Agent 新增 `model` 字段及按 Provider 聚合的模型下拉框——可在界面或通过 `multica agent create/update --model` 为每个 Agent 选择 LLM 模型,并从各 Runtime CLI 实时发现可用模型",
|
||||
"新增 Kimi CLI Agent Runtime(Moonshot AI 的 `kimi-cli`,基于 ACP),支持模型选择、自动授权工具权限以及流式工具调用渲染",
|
||||
"评论和回复编辑器新增放大按钮,便于撰写长文本",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 工作流将“发布结果评论”提升为独立的显式步骤,确保最终回复送达 Issue 而不是只留在终端输出",
|
||||
"通过 Cmd+K 切换 Issue 时不再出现其他 Issue 的 Agent 实时状态残留",
|
||||
"自部署会话 Cookie 的 Secure 标志改由 `FRONTEND_ORIGIN` 协议决定——HTTP 部署不再因浏览器丢弃 Cookie 导致登录失败;`COOKIE_DOMAIN=<ip>` 会自动回退到 host-only 并输出警告",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
title: "编辑器创建子 Issue、自部署门禁与 MCP",
|
||||
changes: [],
|
||||
features: [
|
||||
"直接从编辑器气泡菜单将选中文本创建为子 Issue",
|
||||
"自部署实例账户门禁——`ALLOW_SIGNUP` 和 `ALLOWED_EMAIL_*` 环境变量限制注册",
|
||||
"Agent 新增 `mcp_config` 字段恢复 MCP 支持",
|
||||
"桌面应用每小时检查更新,设置中新增手动检查按钮",
|
||||
],
|
||||
fixes: [
|
||||
"网页已登录时将会话交接给桌面应用",
|
||||
"修复 `?next=` 开放重定向漏洞",
|
||||
"OpenClaw 停止传递不支持的参数,正确传递 AgentInstructions",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
title: "CLI Autopilot、Cmd+K 与 Daemon 身份",
|
||||
changes: [],
|
||||
features: [
|
||||
"CLI `autopilot` 命令,管理定时和触发式自动化",
|
||||
"CLI `issue subscriber` 订阅管理命令",
|
||||
"Cmd+K 命令面板扩展——主题切换、快速创建 Issue/项目、复制链接、切换工作区",
|
||||
"Issue 列表卡片可选显示项目和子 Issue 进度",
|
||||
"Daemon 持久化 UUID 身份——CLI 和桌面应用共用同一个 daemon,跨重启和机器迁移保持一致",
|
||||
"唯一所有者退出工作区的前置检查",
|
||||
"评论折叠状态跨会话持久化",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 现在在任意 Issue 状态下都会响应评论触发",
|
||||
"修复 Codex 沙箱在 macOS 上的网络访问配置",
|
||||
"编辑器气泡菜单改用 @floating-ui/dom 重写,滚动时正确隐藏",
|
||||
"Autopilot 创建者自动订阅其生成的 Issue",
|
||||
"Autopilot run-only 任务正确解析工作区 ID",
|
||||
"桌面应用 `shell.openExternal` 限制仅允许 http/https 协议(安全)",
|
||||
"重名 Agent 创建返回 409 而非静默失败",
|
||||
"桌面应用新建标签页继承当前工作区",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.1",
|
||||
date: "2026-04-16",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolve } from "path";
|
||||
config({ path: resolve(__dirname, "../../.env") });
|
||||
|
||||
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
|
||||
const docsUrl = process.env.DOCS_URL || "http://localhost:4000";
|
||||
|
||||
// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server
|
||||
// allows cross-origin HMR / webpack requests (e.g. from Tailscale IPs).
|
||||
@@ -32,24 +33,39 @@ const nextConfig: NextConfig = {
|
||||
qualities: [75, 80, 85],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${remoteApiUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/ws",
|
||||
destination: `${remoteApiUrl}/ws`,
|
||||
},
|
||||
{
|
||||
source: "/auth/:path*",
|
||||
destination: `${remoteApiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/uploads/:path*",
|
||||
destination: `${remoteApiUrl}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
return {
|
||||
// Run before file-system routes so /docs isn't shadowed by the
|
||||
// [workspaceSlug] dynamic segment.
|
||||
beforeFiles: [
|
||||
{
|
||||
source: "/docs",
|
||||
destination: `${docsUrl}/docs`,
|
||||
},
|
||||
{
|
||||
source: "/docs/:path*",
|
||||
destination: `${docsUrl}/docs/:path*`,
|
||||
},
|
||||
],
|
||||
afterFiles: [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${remoteApiUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/ws",
|
||||
destination: `${remoteApiUrl}/ws`,
|
||||
},
|
||||
{
|
||||
source: "/auth/:path*",
|
||||
destination: `${remoteApiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/uploads/:path*",
|
||||
destination: `${remoteApiUrl}/uploads/:path*`,
|
||||
},
|
||||
],
|
||||
fallback: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ export const mockUser: User = {
|
||||
name: "Test User",
|
||||
email: "test@multica.ai",
|
||||
avatar_url: null,
|
||||
onboarded_at: "2026-01-01T00:00:00Z",
|
||||
onboarding_questionnaire: {},
|
||||
// Matches real server behavior for anyone who onboarded before this
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
@@ -59,6 +64,7 @@ export const mockAgents: Agent[] = [
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
|
||||
interval: 5s
|
||||
@@ -55,7 +56,9 @@ services:
|
||||
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
|
||||
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -71,6 +74,7 @@ services:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
207
docs/analytics.md
Normal file
207
docs/analytics.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Product Analytics
|
||||
|
||||
This document is the source of truth for the analytics events Multica ships
|
||||
to PostHog. Events feed the acquisition → activation → expansion funnel that
|
||||
drives our weekly Active Workspaces (WAW) north-star metric.
|
||||
|
||||
See [MUL-1122](https://github.com/multica-ai/multica) for the design context.
|
||||
|
||||
## Configuration
|
||||
|
||||
All analytics shipping is toggled by environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
|
||||
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
|
||||
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
|
||||
|
||||
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
|
||||
events leave the process unless the operator explicitly opts in**.
|
||||
|
||||
### Self-hosted instances
|
||||
|
||||
Self-hosters should **never inherit a Multica-issued `POSTHOG_API_KEY`** —
|
||||
that would route their users' behavior to our analytics project. The
|
||||
defaults guarantee this:
|
||||
|
||||
- `.env.example` ships `POSTHOG_API_KEY=` empty. The Docker self-host
|
||||
compose does not set a default either.
|
||||
- With the key unset, `NewFromEnv` returns `NoopClient` and logs
|
||||
`analytics: POSTHOG_API_KEY not set, using noop client` at startup — a
|
||||
visible confirmation that nothing is shipped.
|
||||
- Operators who want their own analytics can set `POSTHOG_API_KEY` and
|
||||
`POSTHOG_HOST` to point at their own PostHog project (Cloud or
|
||||
self-hosted PostHog).
|
||||
- The frontend receives the key via `/api/config` (planned for PR 2), so
|
||||
self-hosts' blank server config also disables frontend event shipping
|
||||
automatically — no separate frontend opt-out plumbing required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
|
||||
│
|
||||
▼
|
||||
bounded queue (1024 events)
|
||||
│
|
||||
▼
|
||||
background worker: batch + POST /batch/
|
||||
│
|
||||
▼
|
||||
PostHog
|
||||
```
|
||||
|
||||
- `analytics.Capture` is **never allowed to block a request handler**. A
|
||||
broken backend must not degrade the product — when the queue is full,
|
||||
events are dropped and counted (visible via `slog` + the `dropped` counter
|
||||
on shutdown).
|
||||
- Batches flush either when `BatchSize` is reached or every `FlushEvery`
|
||||
(default 10 s), whichever comes first.
|
||||
- `Close()` drains remaining events during graceful shutdown. Called from
|
||||
`server/cmd/server/main.go` via `defer`.
|
||||
|
||||
## Identity model
|
||||
|
||||
- **`distinct_id`** — always the user's UUID for logged-in events. The
|
||||
frontend's `posthog.identify(user.id)` merges any prior anonymous events
|
||||
under the same identity, so acquisition attribution (UTM / referrer) stays
|
||||
intact across signup.
|
||||
- **`workspace_id`** — added to every event as a property when present. v1
|
||||
uses event property filtering (free tier) rather than PostHog Groups
|
||||
Analytics (paid) to compute workspace-level metrics.
|
||||
- **PII** — events carry `email_domain` (e.g. `gmail.com`), not the full
|
||||
email. Full email is stored once in person properties via `$set_once` so
|
||||
it's available for individual debugging but not broadcast with every
|
||||
event.
|
||||
|
||||
## Event contract
|
||||
|
||||
### `signup`
|
||||
|
||||
Fires when a new user is created. Covers both verification-code and Google
|
||||
OAuth entry points (`findOrCreateUser` is the single emission site).
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email_domain` | string | Lower-cased domain portion of the user's email. |
|
||||
| `signup_source` | string | Opaque attribution bundle from the frontend cookie `multica_signup_source` (UTM + referrer). Empty when the cookie is absent. |
|
||||
| `auth_method` | string | Optional. `"google"` for Google OAuth signups. Absent for verification-code signups. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email` | string | Full email. Never broadcast per-event. |
|
||||
| `signup_source` | string | Same as above; kept on the person for later segmentation. |
|
||||
|
||||
### `workspace_created`
|
||||
|
||||
Fires after a `CreateWorkspace` transaction commits successfully.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Added globally; present here for clarity. |
|
||||
|
||||
**Note on "first workspace" segmentation** — we deliberately do *not* stamp
|
||||
an `is_first_workspace` boolean at emit time. Computing it correctly would
|
||||
require an extra column or transaction-scoped logic that still races under
|
||||
concurrent creates. Instead, PostHog answers the same question exactly by
|
||||
looking at whether the user has a prior `workspace_created` event (use a
|
||||
funnel with "first time user does X" or a cohort on
|
||||
`person_properties.$initial_event`). No information is lost.
|
||||
|
||||
### `runtime_registered`
|
||||
|
||||
Fires the first time a `(workspace_id, daemon_id, provider)` tuple is
|
||||
upserted. Heartbeats and repeat registrations never re-emit. First-time
|
||||
detection uses Postgres `xmax = 0` on the upsert RETURNING clause — no
|
||||
extra query, no race.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
|
||||
| `provider` | string | e.g. `"codex"`, `"claude"`. |
|
||||
| `runtime_version` | string | Version of the agent runtime binary. |
|
||||
| `cli_version` | string | Version of the `multica` CLI that registered it. |
|
||||
|
||||
`distinct_id` is the authenticated owner's user id when the daemon was
|
||||
registered via a member's JWT/PAT; daemon-token registrations fall back to
|
||||
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
|
||||
under a single "anonymous" person.
|
||||
|
||||
### `issue_executed`
|
||||
|
||||
Fires **at most once per issue** — when the first task on that issue
|
||||
reaches terminal `done` state. Backed by an atomic
|
||||
`UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *`;
|
||||
retries, re-assignments, and comment-triggered follow-up tasks all hit the
|
||||
WHERE clause and no-op, so the `≥1 / ≥2 / ≥5 / ≥10` funnel buckets count
|
||||
distinct issues, not tasks.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `issue_id` | string (UUID) | |
|
||||
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
|
||||
|
||||
`distinct_id` prefers the issue's human creator so agent-executed events
|
||||
flow into the issue-author's person profile (same place `signup` and
|
||||
`workspace_created` land). Agent-created issues prefix with `agent:` to
|
||||
keep PostHog from merging the agent into a user record.
|
||||
|
||||
**Note on workspace-Nth ordinals** — we deliberately do *not* stamp
|
||||
`nth_issue_for_workspace` at emit time. Computing it correctly would
|
||||
require either a serialised transaction or an advisory lock per workspace;
|
||||
two concurrent first-completions could otherwise both read `count=1` and
|
||||
emit `n=1`. PostHog answers the same question at query time via
|
||||
`row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp)`,
|
||||
and funnel steps of the form "workspace has had ≥2 `issue_executed`
|
||||
events" are expressible without the property. No information is lost.
|
||||
|
||||
### `team_invite_sent`
|
||||
|
||||
Fires from `CreateInvitation` after the DB row is written.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `invited_email_domain` | string | Lower-cased domain; full email lives in the invitation row, not the event. |
|
||||
| `invite_method` | string | Currently always `"email"`. Future non-email invite flows (share link, SCIM) should pass their own value. |
|
||||
|
||||
`distinct_id` is the inviter's user id.
|
||||
|
||||
### `team_invite_accepted`
|
||||
|
||||
Fires from `AcceptInvitation` after both the invitation row is marked
|
||||
accepted and the member row is inserted in the same transaction.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `days_since_invite` | int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
|
||||
|
||||
`distinct_id` is the invitee's user id — this is the event that closes the
|
||||
expansion funnel.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
every Next.js App Router path or query-string change. The tracker
|
||||
mounts once under `WebProviders` and drives the acquisition funnel's
|
||||
`/ → signup` step. posthog-js's automatic pageview capture is
|
||||
disabled in `initAnalytics` so we own the event shape.
|
||||
- Attribution is NOT a separate event; UTM + referrer origin are written
|
||||
to the `multica_signup_source` cookie on the first anonymous pageview
|
||||
and read by the backend's `signup` emission. The cookie carries a JSON
|
||||
payload URL-encoded at write time (`encodeURIComponent`) and
|
||||
URL-decoded at read time (`url.QueryUnescape`) — the JSON is never
|
||||
mid-truncated; individual values are capped at 96 chars before
|
||||
`JSON.stringify`, and the entire payload is dropped if it still exceeds
|
||||
512 chars. That way PostHog sees either intact JSON or nothing at all.
|
||||
|
||||
## Governance
|
||||
|
||||
Before adding, renaming, or removing any event:
|
||||
|
||||
1. Update this document first.
|
||||
2. Update `server/internal/analytics/events.go` constants and helpers to
|
||||
match.
|
||||
3. PR description must state which existing funnel / insight is affected.
|
||||
107
docs/codex-sandbox-troubleshooting.md
Normal file
107
docs/codex-sandbox-troubleshooting.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Codex sandbox troubleshooting (macOS `no such host`)
|
||||
|
||||
This doc explains the failure mode that caused [MUL-963][mul-963] and the
|
||||
matrix the daemon now follows when writing Codex's per-task `config.toml`.
|
||||
|
||||
[mul-963]: https://multica-api.copilothub.ai/issues/28c34ad2-102a-4f46-91ac-336ed78c5859
|
||||
|
||||
## Symptom fingerprint
|
||||
|
||||
| Error text | Likely cause |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `dial tcp: lookup HOST: no such host` | **Codex Seatbelt sandbox blocking DNS** (macOS, `workspace-write` mode). |
|
||||
| `dial tcp IP:PORT: connect: connection refused` | Server/daemon not running on that port (app-level, not sandbox). |
|
||||
| `dial tcp IP:PORT: i/o timeout` | Container-level network policy or firewall (not Codex sandbox). |
|
||||
| `x509: certificate signed by unknown authority` | TLS/CA issue, unrelated. |
|
||||
|
||||
If you see `no such host` *inside a Codex session on macOS* but `curl https://multica-api.copilothub.ai` from a plain shell on the same machine works, you are hitting the Seatbelt bug below.
|
||||
|
||||
## Root cause
|
||||
|
||||
Upstream issue: [openai/codex#10390][codex-10390]. On macOS, Codex's Seatbelt
|
||||
profile for `sandbox_mode = "workspace-write"` silently ignores the
|
||||
`[sandbox_workspace_write] network_access = true` setting. The seatbelt
|
||||
policy hard-codes `CODEX_SANDBOX_NETWORK_DISABLED=1`, which blocks DNS/UDP
|
||||
syscalls. Go's `net.LookupHost` surfaces that as `no such host`.
|
||||
|
||||
Linux (Landlock) is **not** affected — only macOS Seatbelt.
|
||||
|
||||
[codex-10390]: https://github.com/openai/codex/issues/10390
|
||||
|
||||
## What the daemon does now
|
||||
|
||||
The daemon writes a *multica-managed* block into each task's
|
||||
`$CODEX_HOME/config.toml`, delimited by `# BEGIN multica-managed` /
|
||||
`# END multica-managed` markers. Anything outside the markers is left
|
||||
untouched so users can still tune Codex behavior.
|
||||
|
||||
Decision matrix (see [`server/internal/daemon/execenv/codex_sandbox.go`](../server/internal/daemon/execenv/codex_sandbox.go)):
|
||||
|
||||
| Host OS | Codex version | Managed block emits |
|
||||
| --------- | ------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| non-darwin | any | `sandbox_mode = "workspace-write"` + `sandbox_workspace_write.network_access = true` (dotted-key form) |
|
||||
| darwin | ≥ `CodexDarwinNetworkAccessFixedVersion` | same as above (upstream fix in effect) |
|
||||
| darwin | older / unknown (current default) | `sandbox_mode = "danger-full-access"` + warn-level log |
|
||||
|
||||
The managed block is always hoisted to the top of `config.toml` and uses
|
||||
TOML dotted-key syntax rather than a `[sandbox_workspace_write]` section
|
||||
header. Both are load-bearing: if the block sat after a user table like
|
||||
`[permissions.multica]`, a bare `sandbox_mode = "..."` line would be parsed
|
||||
as `permissions.multica.sandbox_mode` and Codex would silently ignore it.
|
||||
|
||||
`CodexDarwinNetworkAccessFixedVersion` is an empty string today, meaning *no
|
||||
known fixed release yet*. Bump it once a tagged Codex release includes the
|
||||
upstream fix.
|
||||
|
||||
When the daemon falls back to `danger-full-access`, it logs at `WARN`:
|
||||
|
||||
```
|
||||
codex sandbox: falling back to danger-full-access on macOS
|
||||
reason=codex on macOS: seatbelt ignores sandbox_workspace_write.network_access (openai/codex#10390) ...
|
||||
codex_version=0.121.0
|
||||
hint=upgrade Codex CLI (e.g. `brew upgrade codex` or `npm i -g @openai/codex`) ...
|
||||
config_path=/.../codex-home/config.toml
|
||||
```
|
||||
|
||||
## Quick self-check commands
|
||||
|
||||
From the host shell (outside the sandbox):
|
||||
|
||||
```bash
|
||||
# Is the Multica API reachable at all?
|
||||
curl -sSf https://multica-api.copilothub.ai/healthz
|
||||
```
|
||||
|
||||
From inside a Codex session (after the daemon writes its config):
|
||||
|
||||
```bash
|
||||
multica issue list --limit 1 --output json >/dev/null && echo OK
|
||||
```
|
||||
|
||||
If the host curl works but the Codex-session call fails with `no such host`,
|
||||
the sandbox is the culprit; confirm the daemon picked the right policy by
|
||||
looking at the managed block in `$CODEX_HOME/config.toml`.
|
||||
|
||||
## Options and trade-offs
|
||||
|
||||
- **A. Domain-scoped `permissions` profile** (tight): when the upstream
|
||||
`network_access` fix is available, prefer writing a `permissions.multica`
|
||||
profile that allows only `multica-api.copilothub.ai` and
|
||||
`multica-static.copilothub.ai`. Keeps filesystem sandbox intact.
|
||||
- **B. `danger-full-access`** (current macOS fallback): drops the whole
|
||||
Seatbelt profile. Simplest reliable workaround until the upstream fix is
|
||||
released.
|
||||
- **C. Upgrade Codex CLI**: `brew upgrade codex` or `npm i -g @openai/codex`.
|
||||
Once a release containing [openai/codex#10390][codex-10390] is installed,
|
||||
bump `CodexDarwinNetworkAccessFixedVersion` in `codex_sandbox.go` and
|
||||
option A/the workspace-write path takes over automatically.
|
||||
|
||||
## If you need to hand-verify
|
||||
|
||||
```bash
|
||||
# Inspect the managed block the daemon wrote for a given task.
|
||||
sed -n '/# BEGIN multica-managed/,/# END multica-managed/p' \
|
||||
~/multica_workspaces/$WORKSPACE_ID/$TASK_SHORT/codex-home/config.toml
|
||||
```
|
||||
|
||||
The block is idempotent — re-running a task rewrites it in place.
|
||||
611
docs/onboarding-redesign-proposal.md
Normal file
611
docs/onboarding-redesign-proposal.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# Onboarding 重新设计 — 项目提案
|
||||
|
||||
**日期**:2026-04-21
|
||||
**作者**:Naiyuan
|
||||
**状态**:方案定稿,待评审后进入执行
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
### 1.1 数据层面的两个漏斗
|
||||
|
||||
当前产品数据暴露了两个关键的用户流失点:
|
||||
|
||||
1. **第一漏斗**:很多用户创建完 workspace 后,**从未连接本地 daemon**。没有 runtime = 没有 agent = 产品价值归零。这是最严重的漏斗。
|
||||
2. **第二漏斗**:连接了 daemon 的用户中,**约一半从未创建 issue**。他们跨过了最难的技术门槛,却倒在了空 issue 列表面前——因为"该让 agent 做什么"对新用户并不直观。
|
||||
|
||||
这两个漏斗说明:**我们把用户送到了门口,但没有送他们进门**。
|
||||
|
||||
### 1.2 当前 Onboarding 的不足
|
||||
|
||||
代码层面现状(`packages/views/onboarding/` + `apps/web/app/(auth)/onboarding/page.tsx` + `apps/desktop/src/renderer/src/components/window-overlay.tsx`):
|
||||
|
||||
| 环节 | 现状 | 问题 |
|
||||
|---|---|---|
|
||||
| Welcome | 纯打招呼 + "Get started" 按钮 | 0 价值、+1 次点击、文案"takes about a minute"对 web 用户不诚实 |
|
||||
| Workspace 创建 | 复用 `CreateWorkspaceForm` | ✅ 基本合理,保留 |
|
||||
| Runtime 连接 | Desktop 静默、Web 显示 CLI 指南 | ✅ 机制对,但 web 体验上**一路走到第 3 步才撞上 CLI 这堵墙**,没有提前分流 |
|
||||
| Agent 创建 | 2 个模板(Master / Coding)+ 手填 name | Master 模板对 96% 的 solo 用户是噪音;手填 name 是多余决策;没有 Assistant 这种零门槛兜底 |
|
||||
| Complete | 仪式感庆祝 + "Enter workspace" | **aha moment 没发生**。用户被告知 agent 准备好,却看不到它工作,进去就是空 issue 列表——正好是第二漏斗 |
|
||||
| 个性化 | 无 | 所有用户看到同一套流程,不利用任何已知信息 |
|
||||
| 进度持久化 | `useHasOnboarded()` 硬编码 `false` | 中途退出会从头开始;跨端切换完全无法恢复 |
|
||||
|
||||
### 1.3 行业对标
|
||||
|
||||
调研多篇一线案例和数据后,业界已收敛到几条硬原则:
|
||||
|
||||
- **激活 > 教育**:Onboarding 唯一的 KPI 是用户到达 aha moment 的速度和比例。Slack 的 "2000 条消息 → 留存 93%" 是最经典案例
|
||||
- **2 分钟到首次价值**:通用 SaaS 目标
|
||||
- **<90 秒 TTFAC**:Stripe / Vercel 为开发者工具设定的标杆
|
||||
- **开发者工具转化率天然低**:通用 SaaS 试用转化 15–25%,开发者工具只有 8–15%,**68% 放弃原因是 setup 太复杂**
|
||||
- **问卷是杀手**:每多一个表单字段完成率下降 3–5%,某 case 强制问卷导致转化率下降 80%+,另一 case 6→3 题响应率 +11%
|
||||
- **Progressive disclosure 淘汰前置大 tour**:学习应该分散在使用过程中,不是一次性塞给用户
|
||||
- **Notion 模式是黄金范本**:1 题驱动模板选择 + 邮件路径 + 界面预览——"一题多用"
|
||||
|
||||
### 1.4 对标 Multica 的定位
|
||||
|
||||
Multica 不是"做一个 agent"的产品。它的核心价值是**把一支由用户编排的 AI agent 小队组织起来协作**——一个 agent 写代码、一个规划任务、一个做研究、一个写内容——每个 agent 是带配置(provider / runtime / instructions / skills)的独立工作者,像同事一样被指派 issue。
|
||||
|
||||
这意味着:
|
||||
- 用户不是单一场景("AI 帮我写代码"),而是多角色用户都在编排 agent:开发者、产品 / 项目负责人、writer、founder
|
||||
- "用户在用什么本地 CLI"是 daemon 自动探测的技术事实(`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor-agent` 扫 PATH 即可),**不需要问用户**
|
||||
- 真正值得问的是**用户是谁、想让 agent 干什么**——这个答案驱动 Step 4 模板、Step 5 first issue 和 Onboarding Project 的内容
|
||||
|
||||
---
|
||||
|
||||
## 二、调研结论与核心原则
|
||||
|
||||
- 主流程必须严格以激活为目的——Welcome、功能介绍、问卷这些"非激活"内容都要极限压缩或后置
|
||||
- 问卷题数 ≤3 题,且每题答案必须能直接改变下游某个屏的内容,否则砍掉
|
||||
- "Onboarding Project + sub-issues" 属于**教育载体**,不是 onboarding 主流程——它应该在 aha moment 发生后以侧边栏常驻形式出现
|
||||
- Web 不应该是 desktop 的"平行路径",而应该是**漏斗入口**:鼓励用户下载 desktop,保留 web+CLI 作为备选
|
||||
- 进度必须后端持久化,跨端 resume 是硬要求
|
||||
|
||||
主要 Sources 列在文末第八节。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
### 3.1 主流程:5 步(严格有序)
|
||||
|
||||
```
|
||||
Step 0: Welcome (产品介绍, 首次进入时展示, 不入后端 state)
|
||||
Step 1: 3-Q 问卷 (team_size / role / use_case)
|
||||
Step 2: 创建 workspace
|
||||
Step 3: 连接 runtime ← 两端最大差异在这一步
|
||||
Step 4: 创建 agent ← 按 Q1 × Q3 预填
|
||||
Step 5: 🎯 First Issue ← aha moment,按 Q3 驱动文案
|
||||
```
|
||||
|
||||
**Onboarding Project** 在 Step 5 完成的那一刻后台创建,作为进入 workspace 之后的侧边栏常驻项——**不算 onboarding 的一步**。
|
||||
|
||||
### 3.2 两端差异表
|
||||
|
||||
| Step | Desktop | Web |
|
||||
|---|---|---|
|
||||
| 1. 问卷 | 一屏 3 题 | 一屏 3 题(完全一致) |
|
||||
| 2. Workspace | `CreateWorkspaceForm` | 完全一致 |
|
||||
| 3. Runtime | **静默自动**:bundled daemon 1–2s 内 online → 直接跳 Step 4。只在失败时显示诊断 | **分流决策屏**(见 3.3) |
|
||||
| 4. Agent | 一键 Create(按 Q1×Q3 预填模板 + provider) | 完全一致 |
|
||||
| 5. First issue | 跳到 issue 详情页,观察 agent reply | 完全一致 |
|
||||
|
||||
唯一真正不同的是 Step 3。其他"差异"本质是问卷答案驱动的个性化,跨端一致。
|
||||
|
||||
### 3.3 Web 端 Step 3 分流屏
|
||||
|
||||
这是 web 用户创建完 workspace 后看到的屏,**取代当前直接展示 CLI install 指南的做法**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Multica runs on your machine │
|
||||
│ Agents need a local runtime to run. │
|
||||
│ How would you like to set up? │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ [Primary CTA, 80% 视觉权重] │ │
|
||||
│ │ ⬇ Download for macOS (recommended) │ │
|
||||
│ │ Fastest setup, bundled runtime │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Or: Continue on web with CLI │
|
||||
│ Or: I want cloud agents (join waitlist) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
三条路径:
|
||||
|
||||
- **下载桌面端(默认,目标 60%+)**:点下载 → 写 `platform_preference: "desktop"` → 桌面端装完登录同账号 → 后端 state 触发跳 Step 3 → bundled daemon 1s pass → 进 Step 4
|
||||
- **CLI 继续(次选)**:保留现有 `CliInstallInstructions`,但新增预期管理("通常 2–4 分钟")和 60s stuck-state fallback("Stuck? 常见问题")
|
||||
- **Cloud waitlist(soft exit)**:邮箱 capture → 标记为"临时完成"(`onboarded_at` 写当前时间,保留 `cloud_waitlist_email`)→ 进 workspace + 顶部 banner
|
||||
|
||||
### 3.4 三个问题的设计
|
||||
|
||||
**Q1:Who will use this workspace?**(单选)
|
||||
- ○ Just me
|
||||
- ○ My team (2–10 people)
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
注意:删掉了"Just exploring for now"——它本质是"态度"而不是"人数结构",和这题的题意不契合;评估型用户如果真的选项都不合适,可以通过 Other 写自由文本("just trying it out" 等)表达。
|
||||
|
||||
**Q2:What best describes you?**(单选)
|
||||
- ○ Software developer
|
||||
- ○ Product / project lead
|
||||
- ○ Writer or content creator
|
||||
- ○ Founder / solo operator
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
**Q3:What do you want to do first?**(单选)
|
||||
- ○ Write and ship code
|
||||
- ○ Plan and manage projects
|
||||
- ○ Research or write
|
||||
- ○ Just explore what's possible
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
**提交策略(必答)**:
|
||||
- Continue 按钮只在**三题全部有具体选择**时启用;否则禁用
|
||||
- 任一问题选了 Other 但文本框为空 → 也禁用
|
||||
- 从 Other 切回其他选项 → 对应的 `*_other` 字段自动清空
|
||||
- **没有 Skip 路径**。理由:三个答案驱动 Step 4 agent template、Step 5 first-issue prompt、Onboarding Project sub-issue 排序;partial 答案会在下游每一步都留洞。Other 自由文本(+ 80 字符上限)已经兜住所有非典型用户,不需要再开 null 这个口子
|
||||
- 之前允许"全部不选 Skip"的策略在 commit 中已反悔——实测下来"给自由 = 问卷质量塌方"的风险比"多一点摩擦"更值得警惕
|
||||
|
||||
**"Other" 的下游价值——不是兜底,是 escape hatch + 个性化输入**
|
||||
|
||||
Q3 的 `use_case_other` 会**直接嵌入到 Step 5 first issue 的 prompt** 里:
|
||||
|
||||
> "Hi, I'm {user}. I told Multica I want to use you for \"{use_case_other}\". Introduce yourself and give me 3 concrete ways you could help with that."
|
||||
|
||||
也就是说,选 Other 的用户**反而**得到最个性化的 first issue——他们给 agent 的任务描述就是他们亲口写的。Q2 `role_other` 没有同样的嵌入位置,但会存进 state 给市场研究用。
|
||||
|
||||
**被砍掉的问题及理由**:
|
||||
|
||||
- ~~"你在用哪些 AI agent?"~~(原方案 Q1)→ daemon 启动时自动扫 PATH 探测已安装的 CLI(`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor-agent`),比问用户更准——用户可能说"我用 Claude Code"但 PATH 里并不存在。从"问"改成"测",问卷压掉一题
|
||||
- ~~"你是做什么的"(职业)~~ → 原方案砍掉过;现因为定位校准(Multica 不是 coding-focused 产品),重新作为 Q2 加回,驱动 agent template 选择
|
||||
- ~~"公司规模"~~ → solo/team 二分已经够用;具体公司规模属于 Day 3 邮件采集范围
|
||||
- ~~"从哪里知道 Multica"~~ → 归因数据走分析系统,不占问卷位
|
||||
|
||||
### 3.5 个性化映射
|
||||
|
||||
所有个性化来自这三个答案 + daemon 自动探测到的 runtime 列表。**不做 Q 之外的任何猜测**——透明、可预期、可调试。
|
||||
|
||||
#### Runtime 优先级(来自 daemon 探测,不来自问卷)
|
||||
|
||||
Step 3 结束时 daemon 会报告"当前 PATH 上探测到的 CLI 列表"。Step 4 的 provider 预选逻辑:
|
||||
|
||||
| daemon 探测结果 | Step 4 provider 预选 |
|
||||
|---|---|
|
||||
| 有 online runtime | 第一个 online 的 provider |
|
||||
| 列表非空但全 offline | 列表中第一个 |
|
||||
| 列表为空(Cloud waitlist 或 CLI 没装成功) | 不预选,在 Step 4 给用户手选或跳过 |
|
||||
|
||||
provider 值对齐 `packages/views/runtimes/components/provider-logo.tsx` 中已支持的:`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor`。
|
||||
|
||||
#### Q1 (team_size) → Onboarding Project sub-issue 排序
|
||||
|
||||
| Q1 | Onboarding Project 顶部 sub-issue |
|
||||
|---|---|
|
||||
| `solo` | "Assign a real task to your agent" |
|
||||
| `team` | **"Invite teammates"** 置顶 |
|
||||
| `other` | 按 `solo` 路径处理(不强行归类;`team_size_other` 文本存下做市场研究) |
|
||||
|
||||
#### Q2 (role) → Step 4 agent template 默认选择(× Q3 细化)
|
||||
|
||||
Multica 是服务多角色 agent 编排用户的平台,不同 role 在 agent template 上应该看到默认的 role-matched 模板:
|
||||
|
||||
| Q2 role | Q3 use_case | 默认 template |
|
||||
|---|---|---|
|
||||
| `developer` | `coding` | Coding Agent |
|
||||
| `developer` | `planning` | Planning Agent |
|
||||
| `developer` | `writing_research` / `explore` / `other` | Coding Agent(仍默认,因为角色是开发者) |
|
||||
| `product_lead` | `coding` | Coding Agent |
|
||||
| `product_lead` | `planning` | Planning Agent |
|
||||
| `product_lead` | `writing_research` / `explore` / `other` | Planning Agent |
|
||||
| `writer` | `writing_research` | Writing Agent |
|
||||
| `writer` | 其他 | Writing Agent |
|
||||
| `founder` | 任意 | Assistant(founder 什么都干,通用兜底) |
|
||||
| `other` | 任意 | Assistant |
|
||||
|
||||
**Agent 模板集从 3 个扩到 4 个**:Coding Agent / Planning Agent / **Writing Agent(新增)** / Assistant。砍掉旧的 "Master Agent"(对 solo 用户完全不适用)。Writing Agent 的增加是因为产品定位校准——原方案默认 coding-focused,新方案支持 writer 作为一等用户。
|
||||
|
||||
#### Q3 (use_case) → Step 5 first issue prompt
|
||||
|
||||
First issue 的标题和 prompt 都由 Q3 单独驱动(与 Q2 role 解耦——同一个 role 做不同的 first task 是正常的):
|
||||
|
||||
| Q3 | First Issue 标题 | First Issue 描述(= 给 agent 的 prompt) |
|
||||
|---|---|---|
|
||||
| `coding` | "Welcome me and show me what you can do" | "Hi, I'm {user}. I'll use you mostly for coding work. Introduce yourself and suggest 3 concrete coding tasks I could try." |
|
||||
| `planning` | "Help me plan my first project" | "Hi, I'm {user}. I want you to help me plan and break down work. Introduce yourself and suggest 3 types of projects we could tackle." |
|
||||
| `writing_research` | "Show me how you help with research and writing" | "Hi, I'm {user}. I'll use you for research and writing. Introduce yourself and give me 3 examples of how you can help — drafting, summarizing, analysis, etc." |
|
||||
| `explore` | "What can you do?" | "Hi. I'm exploring what Multica can do. Give me a quick tour of what you can help with and suggest 3 concrete things to try." |
|
||||
| `other` | "Help me with what I had in mind" | "Hi, I'm {user}. I told Multica I want to use you for \"{use_case_other}\". Introduce yourself and give me 3 concrete ways you could help with that." |
|
||||
|
||||
`{use_case_other}` 的嵌入是 Other 选项的关键价值——选 Other 的用户不是被降级成通用兜底,反而得到最精准的 first issue。
|
||||
|
||||
### 3.6 Onboarding Project 设计
|
||||
|
||||
Project 名称:"Getting Started"。在 Step 5 完成那一刻后台创建,包含以下 sub-issues。
|
||||
|
||||
**Core sub-issues(所有用户都有)**:
|
||||
|
||||
1. **"Chat with your agent without creating an issue"**
|
||||
> Some tasks are quick back-and-forth — you don't need a full issue. Open the chat panel from the top-right and try asking your agent a question.
|
||||
|
||||
2. **"Assign a real task to your agent"**
|
||||
> You've seen your agent reply in this welcome issue. Now try assigning them something you actually need done. Create a new issue, describe the task, assign it to {agent_name}.
|
||||
|
||||
3. **"Write your Workspace Context"**
|
||||
> Workspace Context is the shared system prompt every agent in this workspace sees. Tell them who you are, what you're building, and how they should behave. Go to Workspace settings → Context.
|
||||
|
||||
4. **"Create a second agent with a different role"**
|
||||
> Multica's real power is running a small team of specialized agents. Create a Planning agent to complement your Coding agent, or a Writing agent to draft content. Go to Agents → "New agent".
|
||||
|
||||
5. **"Configure your agent's skills"**
|
||||
> Skills let you give your agent specific tools and capabilities. Go to your agent's settings and try toggling a skill.
|
||||
|
||||
6. **"Set up an Autopilot for recurring work"**
|
||||
> Autopilot creates issues on a schedule — daily standup summaries, weekly bug triage, monthly reports. Your agent picks them up automatically. Go to Autopilots → "New autopilot".
|
||||
|
||||
**Conditional sub-issues**(按答案插入 / 置顶 / 过滤):
|
||||
|
||||
- **Q1 = `team`** → "**Invite your teammates**" 置顶
|
||||
- **Q2 = `developer`** 或 **Q3 = `coding`** → "**Connect a repo to your workspace**" 加入 core #2 之后
|
||||
- **Q2 = `product_lead`** → "**Create a project with sub-issues**" 置顶
|
||||
- **Q2 = `writer`** → 跳过 "Connect a repo"(coding-specific),其余 core 保留
|
||||
- **runtime 列表为空**(Cloud waitlist 或 CLI 未装成功)→ 插入 "**Install your first local runtime**" 置顶
|
||||
|
||||
**设计原则**:每个 sub-issue 都可以直接 assign 给 agent。Agent 读到 description 后,用自然语言给用户一句引导 + 一个具体建议。这样 sub-issue 既是"教程"又是"和 agent 互动"的自然场景——学习动作本身就是使用产品。
|
||||
|
||||
### 3.7 Resume 策略
|
||||
|
||||
**核心原则**:恢复到上次 step,不重头开始,MVP 阶段不设过期时间,允许任意回退改答案。
|
||||
|
||||
理由:
|
||||
- Onboarding 总时长 <10 分钟,绝大多数用户一口气走完
|
||||
- 中途离开再回来的,基本都是被别的事打断——重头开始是侮辱
|
||||
- 过期策略(7 天后重置之类)是用代码解决还没发生的问题——**等真观察到 abandon-return 模式再加**
|
||||
|
||||
跨端 resume 的完整行为表:
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|---|---|
|
||||
| Web 完成 Step 1&2,关浏览器,2h 后重开 web | 读 state → 跳过 Step 1/2 → 直接 Step 3 |
|
||||
| Web 到 Step 3 点"下载桌面端",装完登录 desktop | Desktop 读 state → 跳 Step 3 → bundled daemon 1s pass → 进 Step 4 |
|
||||
| Web 到 Step 3 点"下载桌面端",没装,3 天后回 web | 检测到 `platform_preference=desktop` 但当前是 web → 显示 "Waiting for you on desktop" 屏 + "改用 web/CLI 继续" 入口 |
|
||||
| Desktop Step 5 first issue 刚创建但没看 agent reply 就关闭 | 重开 desktop → current_step 仍是 `first_issue` → 直接打开那个 issue 详情页 |
|
||||
| Onboarding 完成后再登录 | `onboarded_at` 非 null → 跳过 onboarding → 正常进 workspace |
|
||||
| Onboarding 中创建的 workspace 被删(边缘 case) | `workspace_id` 变 NULL → 下次进 onboarding 检测到 `current_step=runtime` 但 `workspace_id=null` → 回退到 Step 2 重新建 |
|
||||
|
||||
**"回退改答案" 的 UX 细节**:每一步有 "Back" 按钮回上一步。回退**不清空已保存的数据**——用户只是修改,不是重置。
|
||||
|
||||
---
|
||||
|
||||
## 四、后端数据设计
|
||||
|
||||
### 4.1 `user_onboarding` 表 schema
|
||||
|
||||
**设计决策**:稳定字段用列,灵活字段用 JSONB。问卷答案放 JSONB(题目可能演化),其他字段(FK、控制字段、enum)都是独立列。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_onboarding (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 控制状态
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
onboarded_at TIMESTAMPTZ, -- null = 未完成
|
||||
current_step TEXT, -- null after onboarded_at
|
||||
-- 'questionnaire'|'workspace'|'runtime'|'agent'|'first_issue'
|
||||
|
||||
-- 问卷答案(会演化,放 JSONB)
|
||||
questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- 期望结构:
|
||||
-- {
|
||||
-- "team_size": "solo" | "team" | "other", -- Q1
|
||||
-- "team_size_other": "<= 80 chars" | null, -- Q1 自由文本(选 other 时必填)
|
||||
-- "role": "developer" | "product_lead" | "writer" | "founder" | "other", -- Q2
|
||||
-- "role_other": "<= 80 chars" | null, -- Q2 自由文本
|
||||
-- "use_case": "coding" | "planning" | "writing_research" | "explore" | "other", -- Q3
|
||||
-- "use_case_other": "<= 80 chars" | null -- Q3 自由文本(会嵌入 Step 5 prompt)
|
||||
-- }
|
||||
|
||||
-- Onboarding 产物(FK,要 join / 查询)
|
||||
workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
runtime_id UUID REFERENCES agent_runtimes(id) ON DELETE SET NULL,
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
|
||||
first_issue_id UUID REFERENCES issues(id) ON DELETE SET NULL,
|
||||
onboarding_project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||
|
||||
-- Platform 偏好(决定 handoff 和 resume 行为)
|
||||
platform_preference TEXT, -- 'web' | 'desktop' | null
|
||||
|
||||
-- Cloud waitlist 支路(soft exit 记录)
|
||||
cloud_waitlist_email TEXT,
|
||||
|
||||
-- 约束
|
||||
CONSTRAINT current_step_valid CHECK (
|
||||
current_step IS NULL OR
|
||||
current_step IN ('questionnaire','workspace','runtime','agent','first_issue')
|
||||
),
|
||||
CONSTRAINT onboarded_clears_step CHECK (
|
||||
onboarded_at IS NULL OR current_step IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- 只对未完成的做 index(完成后不查),analytics 用
|
||||
CREATE INDEX idx_user_onboarding_incomplete
|
||||
ON user_onboarding (updated_at)
|
||||
WHERE onboarded_at IS NULL;
|
||||
```
|
||||
|
||||
**几个关键决策的理由**:
|
||||
|
||||
- **`ON DELETE SET NULL`** 而不是 CASCADE:用户手动删了 onboarding 中创建的 workspace,不应丢失整条 onboarding 记录。保留痕迹作为 analytics 信号,同时支持 3.7 表中"回退到 Step 2" 的自愈逻辑
|
||||
- **`onboarded_clears_step` 约束**:保证不会出现"已完成但还在某 step"的脏状态,发现非法组合直接 DB 层拒绝
|
||||
- **Partial index `WHERE onboarded_at IS NULL`**:绝大多数用户最终会完成,索引只关注未完成 cohort,省空间且 query 更快
|
||||
- **不存步骤时间戳历史**:步骤转化漏斗走 PostHog 事件系统(项目里 agent/j/db4fefb5 分支已经在做 analytics 基建);state 表负责流程控制,事件系统负责分析。分工清晰,不混
|
||||
|
||||
### 4.2 API 设计
|
||||
|
||||
**读**:
|
||||
```
|
||||
GET /api/me/onboarding
|
||||
→ 200 OK { current_step, questionnaire, workspace_id, ... }
|
||||
→ 404 if never started (客户端 treat as "start fresh")
|
||||
```
|
||||
|
||||
**写(每步结束时)**:
|
||||
```
|
||||
PATCH /api/me/onboarding
|
||||
Body: {
|
||||
current_step: "workspace", // 下一步
|
||||
questionnaire: { ... }, // 只在 Step 1 提交
|
||||
workspace_id: "ws_xxx", // 只在 Step 2 提交
|
||||
// ... 对应字段
|
||||
}
|
||||
→ 200 OK { 完整 state }
|
||||
```
|
||||
|
||||
**完成**:
|
||||
```
|
||||
POST /api/me/onboarding/complete
|
||||
Body: { first_issue_id, onboarding_project_id }
|
||||
→ 200 OK { onboarded_at, current_step: null }
|
||||
```
|
||||
|
||||
**关键**:每步结束立即 PATCH server。不要在前端 batch 到最后一起提交——这是 resume 能工作的前提。
|
||||
|
||||
### 4.3 State 流转
|
||||
|
||||
```
|
||||
状态机:
|
||||
(record not exists)
|
||||
↓ 用户首次进 onboarding
|
||||
current_step: "questionnaire"
|
||||
↓ PATCH 提交问卷
|
||||
current_step: "workspace" + questionnaire
|
||||
↓ PATCH 工作区创建成功
|
||||
current_step: "runtime" + workspace_id
|
||||
↓ PATCH runtime 选择
|
||||
current_step: "agent" + runtime_id
|
||||
↓ PATCH agent 创建
|
||||
current_step: "first_issue" + agent_id
|
||||
↓ POST /complete
|
||||
current_step: null + onboarded_at, first_issue_id, onboarding_project_id
|
||||
|
||||
支路(Cloud waitlist):
|
||||
current_step: "runtime"
|
||||
↓ 用户选 cloud waitlist
|
||||
current_step: null + onboarded_at + cloud_waitlist_email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、当前代码影响面
|
||||
|
||||
### 5.1 后端(Go)
|
||||
|
||||
**新增**:
|
||||
- Migration:`server/migrations/0xx_create_user_onboarding.up.sql` + `.down.sql`
|
||||
- sqlc queries:`server/pkg/db/queries/onboarding.sql`(GetOnboarding / UpsertOnboarding / CompleteOnboarding)
|
||||
- Handler:`server/internal/handler/onboarding.go`(GET / PATCH / POST)
|
||||
- Router 挂载:`/api/me/onboarding` 路由组
|
||||
- 可能需要:`GetUserOnboarding` 也需暴露给认证回调决定重定向(或前端自取)
|
||||
|
||||
**迁移 sqlc**:`make sqlc` 重生成。
|
||||
|
||||
### 5.2 前端(TypeScript / React)
|
||||
|
||||
**新增**:
|
||||
- `packages/core/onboarding/types.ts` — `OnboardingState` 类型定义
|
||||
- `packages/core/onboarding/queries.ts` — TanStack Query options
|
||||
- `packages/core/onboarding/mutations.ts` — advance / complete mutation
|
||||
- `packages/views/onboarding/steps/step-welcome.tsx` — 产品介绍屏(首次进入时展示;回访自动跳过)
|
||||
- `packages/views/onboarding/steps/step-questionnaire.tsx` — 3 题问卷屏
|
||||
- `packages/views/onboarding/steps/step-platform-fork.tsx` — web Step 3 的分流屏
|
||||
- `packages/views/onboarding/steps/step-first-issue.tsx` — **关键**,aha moment 所在
|
||||
- 可能拆分 `packages/views/onboarding/utils/personalization.ts` — Q1/Q2/Q3 → 下游映射的纯函数(方便单测)
|
||||
|
||||
**需要改动的现有文件**:
|
||||
- `packages/views/onboarding/onboarding-flow.tsx` — 移除本地 `useState<OnboardingStep>`,改读 `useOnboardingStore`;每次 step 转换调 `advance` mutation
|
||||
- `packages/views/onboarding/steps/step-welcome.tsx` — **删除**,内容合并到新的 step-questionnaire
|
||||
- `packages/views/onboarding/steps/step-runtime.tsx` — web 分支改为渲染 `<StepPlatformFork />`
|
||||
- `packages/views/onboarding/steps/step-agent.tsx` — 模板集改为 Coding / Planning / Writing / Assistant,按 Q2×Q3 预填,新增"Advanced"折叠区让用户改 name
|
||||
- `packages/views/onboarding/steps/step-complete.tsx` — **替换**为 StepFirstIssue,或作为其前置过渡屏
|
||||
- `packages/core/paths/resolve.ts` — `useHasOnboarded` 当前已从 store 读;联调期替换为 TanStack Query against `GET /api/me/onboarding`
|
||||
- `packages/views/layout/use-dashboard-guard.ts` — guard 条件增加 `!hasOnboarded`,支持 "abandon 后回来自动回到 onboarding" 的 resume 行为
|
||||
- `apps/web/app/(auth)/onboarding/page.tsx` — 调整 shell 以支持 resume(读 state 决定进入哪一步)
|
||||
- `apps/desktop/src/renderer/src/components/window-overlay.tsx` — 同上
|
||||
- `apps/desktop/src/renderer/src/stores/window-overlay-store.ts` — 可能需要 `WindowOverlay` 类型微调
|
||||
|
||||
**不变**:
|
||||
- `packages/views/workspace/create-workspace-form.tsx` — 复用
|
||||
- `packages/views/onboarding/steps/cli-install-instructions.tsx` — 仍用,在 CLI 分支里渲染
|
||||
- 大部分 desktop 的 bundled daemon 启动逻辑 — Step 3 desktop 静默 pass 的前提
|
||||
|
||||
### 5.3 影响面估算
|
||||
|
||||
| 类别 | 数量 |
|
||||
|---|---|
|
||||
| 后端新文件 | ~4 |
|
||||
| 后端修改文件 | 1–2(router) |
|
||||
| 前端新文件 | ~6 |
|
||||
| 前端修改文件 | ~10 |
|
||||
| 测试新文件 | ~5(核心逻辑 + personalization 映射 + resume scenarios) |
|
||||
|
||||
---
|
||||
|
||||
## 六、成功指标(上线 30 天内评估)
|
||||
|
||||
参考调研结论设定:
|
||||
|
||||
| 指标 | 业界标杆 | Multica 目标 |
|
||||
|---|---|---|
|
||||
| Time-to-value | < 3 分钟 | Desktop 直达:≤ 3 min;Web→Desktop:≤ 5 min(含装机);Web→CLI:≤ 8 min |
|
||||
| Onboarding 完成率 | 60–80% | 目标 70% |
|
||||
| Day 7 留存 | 25–40% | 目标 30% |
|
||||
| Activation 率 | 40–60% | 目标 50% |
|
||||
| Web→Desktop 转化(Step 3 fork) | in-product 高于 42% 冷推上限 | 目标 50–70% |
|
||||
|
||||
**第一漏斗目标**:workspace → runtime 连接率从当前水平提升至 80%+(主要靠 web 分流推 desktop 降 CLI 门槛)。
|
||||
**第二漏斗目标**:runtime → 首个 issue 由产品主动创建,比例应接近 100%(因为 StepFirstIssue 自动完成这件事)。
|
||||
|
||||
---
|
||||
|
||||
## 七、已做的决策(不再讨论)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 前置问卷题数 | **3 题**:team_size / role / use_case | Notion 范式、调研甜蜜点;每题答案必须驱动下游内容 |
|
||||
| 问卷 Q1 "已在用哪些 agent" | **不问**,daemon 自动探测 PATH | 技术事实不该问用户;扫 PATH 比问答更准 |
|
||||
| 问卷 Q2 role | **问**,5 个具体选项 + Other | 驱动 Step 4 template 默认选择;用户画像数据回到一等位 |
|
||||
| "Other" 选项机制 | **每题都有 Other**,点击展开 80 字符文本框 | Escape hatch;Q3 use_case_other 还会嵌入 Step 5 first issue prompt |
|
||||
| 问卷必填 | **全可选**(Other 选了必填文本) | 给评估型用户零摩擦通道;0 选时 Continue 变 Skip |
|
||||
| Welcome 步骤 | **保留独立 welcome**,但改造为"产品介绍屏"(不是打招呼);只在首次进入时看到,回访 resume 自动跳过 | 多一次点击换来的是首次用户真正理解 Multica 是什么;Multica 无心智对标物,没有前置介绍就进问卷 = 用户没有 frame of reference;Welcome 不入后端 state,不影响 server schema |
|
||||
| Web Step 3 分流 | **默认推 desktop**,CLI 次选,cloud waitlist 兜底 | 96% 是个人用户,desktop 是最快路径 |
|
||||
| Cloud waitlist 放哪 | **Web Step 3 分流屏**,不作为主步骤 | 保留原方案 #3 的数据价值,但不侵占主流程 |
|
||||
| Agent 模板 | **4 个**:Coding / Planning / Writing / Assistant(砍 Master) | Multica 服务多角色 agent 编排用户;Writer 不能被 Assistant 兜底 |
|
||||
| Onboarding Project | **不算步骤**,Step 5 完成后台创建,侧边栏常驻 | Progressive disclosure 原则 |
|
||||
| Resume 策略 | **恢复到上次 step,不过期,允许回退改答案** | 未见 abandon-return 数据前不提前优化 |
|
||||
| Schema 方式 | **专门表 + JSONB 混合** | 稳定字段列化、灵活字段(问卷)JSON 化 |
|
||||
| FK 删除行为 | **ON DELETE SET NULL**,不 CASCADE | 保留 analytics 痕迹 + 自愈能力 |
|
||||
| 步骤时间戳 | **走 PostHog 事件系统**,不进 state 表 | 职责分离:state 管流程,events 管分析 |
|
||||
| 进度 handoff 机制 | **纯后端 state**,不用 token 或 deep link | 用户 auth session 已绑身份,简化架构 |
|
||||
| 开发顺序 | **前端全部搭完 → 后端实现 → 联调测试 → 上线** | 保持当前开发节奏不被后端阻塞;前端本身可以一个 step 一个 step 独立推进 |
|
||||
| State 访问抽象 | **全部走 `useOnboardingStore()` 一个 hook**,component 严禁直接碰 storage | 换后端时只动这一个文件,component 不感知——让"先前端后后端"成本低的关键 |
|
||||
|
||||
---
|
||||
|
||||
## 八、开放问题 / 不在本次范围
|
||||
|
||||
- **Cloud agent runtime 本身**:本次只实现 waitlist 邮箱捕获,不做 cloud runtime。这是下一阶段的产品决策
|
||||
- **Onboarding project sub-issue 文案的 iterate**:先上线现有文案(见 3.6),等真实用户反馈再打磨
|
||||
- **A/B test 框架**:等用户量达到业界标准(每组 ≥500)再启动,现阶段全量发
|
||||
- **个性化 Day 3 邮件**:问卷只问 3 题,剩余的用户画像数据(团队规模、角色等)可以后置到运营邮件收集,本次不实现
|
||||
- **Onboarding 完成后的 re-engagement**:如"用户 7 天没创建第 2 个 agent 时发通知",属于 retention loop,不属于 onboarding
|
||||
- **自定义 agent template**:当前 3 个硬编码模板够用,自定义模板留到后面
|
||||
|
||||
---
|
||||
|
||||
## 九、执行计划
|
||||
|
||||
### 9.1 详细执行文档
|
||||
|
||||
本提案评审通过后,拆出 `docs/plans/2026-04-21-onboarding-redesign.md`,按现有 plan 文档格式(参考 `docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md`)精确到文件 + 行号 + 代码片段。
|
||||
|
||||
### 9.2 执行阶段
|
||||
|
||||
**原则:前端全部搭完 → 后端实现 → 联调测试 → 上线。**
|
||||
|
||||
目的是让当前开发节奏不被后端阻塞——前端可以一个 step 一个 step 独立迭代,每完成一个 step 都能在浏览器里直接看到效果。后端在前端定稿之后一次性实现,联调阶段统一解决跨端 resume 等场景。
|
||||
|
||||
**前端阶段**(按顺序推进,每个 step 独立可交付):
|
||||
|
||||
1. **建立 `useOnboardingStore()` 骨架**(已完成)——位于 `packages/core/onboarding/`。dev 期间是内存 Zustand store(刷新重置,方便迭代),联调阶段换成 TanStack Query + PATCH mutation。严禁 component 绕过
|
||||
2. **Step 1(welcome + 问卷拆两屏)**:新建 `step-welcome.tsx`(产品介绍,首次进入时展示)+ `step-questionnaire.tsx`(3 题);抽出 `<OptionCard>` / `<OtherOptionCard>` 复用组件
|
||||
3. **Step 2(workspace)**:基本保留,接入 `useOnboardingStore()`
|
||||
4. **Step 3(runtime)**:在 web 分支里新建 `step-platform-fork.tsx`;desktop 分支保留静默自动;CLI 分支加预期管理和 60s fallback
|
||||
5. **Step 4(agent)**:模板集从 3 扩成 4(加 Writing),按 Q2×Q3 预填 template + provider(provider 来自 daemon 探测),移除手填 name 的强制性
|
||||
6. **Step 5(first issue)**:新建 `step-first-issue.tsx`,这是 aha moment 发生的地方;`use_case=other` 时把 `use_case_other` 嵌入 prompt
|
||||
7. **Flow orchestrator 改造**:`onboarding-flow.tsx` 改由 `useOnboardingStore()` 驱动,不再用本地 useState 管 step 切换
|
||||
8. **Web + Desktop shell 适配**:读 store 决定进入哪一步,支持单浏览器内的 resume
|
||||
|
||||
**后端阶段**:
|
||||
|
||||
9. Migration + sqlc queries + handler + router(API shape 见 4.2)
|
||||
10. 按 4.1 schema 实现 `user_onboarding` 表 + partial index + 约束
|
||||
|
||||
**联调阶段**:
|
||||
|
||||
11. `useOnboardingState()` 实现从 localStorage 切换为 TanStack Query + PATCH mutation——**component 0 改动**,这是 hook 抽象的回报
|
||||
12. 跨端 / 多 session resume 全场景验证(3.7 表)
|
||||
13. E2E 覆盖 4 类用户路径 + 分流屏三条支路 + resume 一条
|
||||
|
||||
建议独立 worktree 开发(参考 `superpowers:using-git-worktrees`),避免污染主 checkout。
|
||||
|
||||
### 9.3 测试阶段
|
||||
|
||||
**本地自测**(按用户类型逐一跑):
|
||||
- A 类:solo + Claude Code + coding → 最短路径 3 分钟
|
||||
- B 类:team + Claude Code + coding/planning → 完成后侧边栏 "Invite teammates" 置顶
|
||||
- C 类:无 agent + 评估 → web 分流选 cloud waitlist
|
||||
- D 类:solo + writing → Assistant 模板 + 对应 first issue 文案
|
||||
|
||||
**Resume 场景**(按 3.7 表逐一验证):
|
||||
- Web 中途关浏览器 → 重开恢复
|
||||
- Web → desktop 跨端 handoff
|
||||
- Web 选下载未装 → 回 web 的"waiting"屏
|
||||
- 已完成用户重登录 → 跳过 onboarding
|
||||
|
||||
**E2E** 测试必须覆盖:
|
||||
- 完整 happy path(至少 desktop A 类)
|
||||
- Resume 一条
|
||||
- 分流屏三条路径各一条
|
||||
|
||||
**上线指标监控**:PostHog 看板跟踪第六节定义的 5 个 KPI,上线后每周 review 一次,2 周内若主指标偏离 20%+ 需排查。
|
||||
|
||||
---
|
||||
|
||||
## 十、调研参考
|
||||
|
||||
### 核心理论与激活
|
||||
- [Chameleon — How to find your product's "Aha" moment](https://www.chameleon.io/blog/successful-user-onboarding)
|
||||
- [Amplitude — The "Aha" Moment: A Guide](https://amplitude.com/blog/aha-moment)
|
||||
- [Growth Letter — Slack's $3B Growth Loop](https://www.growth-letter.com/p/slacks-3-billion-growth-strategy)
|
||||
- [June.so — Activation Playbook](https://www.june.so/blog/activation-playbook)
|
||||
|
||||
### 开发者工具特有数据
|
||||
- [Daily.dev — Developer Onboarding Optimization](https://business.daily.dev/resources/developer-onboarding-optimization-from-first-click-to-paying-customer/)
|
||||
- [Startup Design Journal — Hidden Micro-Friction Killing Conversion](https://startupdesignjournal.com/p/the-hidden-micro-friction-thats-killing)
|
||||
|
||||
### 问卷 / 表单 drop-off
|
||||
- [involve.me — 6→3 题 +11% case](https://www.involve.me/blog/case-study-how-we-use-an-onboarding-survey-in-a-saas-product)
|
||||
- [SaaSFactor — Why Users Drop Off During Onboarding](https://www.saasfactor.co/blogs/why-users-drop-off-during-onboarding-and-how-to-fix-it)
|
||||
- [GrowthMentor — Friction Case Study](https://www.growthmentor.com/blog/user-onboarding-friction/)
|
||||
- [Formbricks — Essential Onboarding Survey Questions](https://formbricks.com/blog/onboarding-survey-questions)
|
||||
|
||||
### Progressive Disclosure
|
||||
- [LogRocket — Progressive Disclosure](https://blog.logrocket.com/ux-design/progressive-disclosure-ux-types-use-cases/)
|
||||
- [Pendo — Onboarding, Progressive Disclosure, Memory](https://www.pendo.io/pendo-blog/onboarding-progressive-disclosure/)
|
||||
- [Interaction Design Foundation — Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
|
||||
|
||||
### Notion / Linear 案例
|
||||
- [Candu — How Notion Crafts Personalized Onboarding](https://www.candu.ai/blog/how-notion-crafts-a-personalized-onboarding-experience-6-lessons-to-guide-new-users)
|
||||
- [Appcues Goodux — Notion's Lightweight Onboarding](https://goodux.appcues.com/blog/notions-lightweight-onboarding)
|
||||
- [DesignerUp — 200 Onboarding Flows Studied](https://designerup.co/blog/i-studied-the-ux-ui-of-over-200-onboarding-flows-heres-everything-i-learned/)
|
||||
|
||||
### Schema / 持久化
|
||||
- [Shekhar Gulati — When to use JSON data type](https://shekhargulati.com/2022/01/08/when-to-use-json-data-type-in-database-schema-design/)
|
||||
- [TigerData — Wide vs Narrow Postgres Tables](https://www.tigerdata.com/learn/designing-your-database-schema-wide-vs-narrow-postgres-tables)
|
||||
- [DbSchema — PostgreSQL JSONB Operators](https://dbschema.com/blog/postgresql/jsonb-in-postgresql/)
|
||||
- [Pravin Tripathi — Start and Resume Journey for Onboarding](https://medium.com/@pravinyo/approaches-for-start-and-resume-journey-for-user-onboarding-to-platform-part-i-e077c73b4cd7)
|
||||
|
||||
### A/B 测试 & 分段
|
||||
- [Appcues — A/B Testing Onboarding Flows](https://www.appcues.com/blog/flow-variation-a-b-testing)
|
||||
- [M Accelerator — A/B Testing Onboarding Guide](https://maccelerator.la/en/blog/entrepreneurship/ultimate-guide-to-ab-testing-onboarding-flows/)
|
||||
- [CXL — Segment A/B Test Results](https://cxl.com/blog/segment-ab-test-results/)
|
||||
|
||||
### 2025 综合最佳实践
|
||||
- [Aakash Gupta — 10 Customer Onboarding Best Practices for PMs 2025](https://www.aakashg.com/customer-onboarding-best-practices/)
|
||||
- [ProductLed — SaaS Onboarding Best Practices 2025](https://productled.com/blog/5-best-practices-for-better-saas-user-onboarding)
|
||||
- [Branch — Desktop-to-App Conversions](https://www.branch.io/resources/blog/optimizing-desktop-web-to-app-conversions/)
|
||||
983
docs/product-overview.md
Normal file
983
docs/product-overview.md
Normal file
@@ -0,0 +1,983 @@
|
||||
# Multica 产品全景文档
|
||||
|
||||
> **文档说明**
|
||||
>
|
||||
> 这份文档的目的是:**让任何没有写过代码的新同事,在 30 分钟内完全理解 Multica 这个产品到底有哪些功能、每个功能在整体中处于什么位置、一个功能和另一个功能如何协同**。
|
||||
>
|
||||
> 它的受众包括:
|
||||
>
|
||||
> - **新加入的工程师 / 产品 / 设计 / 运营**——用它做 onboarding 的第一份材料
|
||||
> - **产品介绍工作**——需要对外讲解 Multica 时的事实基础
|
||||
> - **文案工作者**——写交互文案、营销文案、帮助文档时,需要知道某个词(比如 "Skill"、"Runtime"、"Autopilot")在产品体系里代表什么
|
||||
> - **任何需要在修改某个局部前,先理解它与整体关系的人**
|
||||
>
|
||||
> 它**不是**:开发者文档、架构决策记录(ADR)、或者销售话术。它是**功能事实的汇总**——每一条描述都能在代码、schema 或 API 里找到对应。
|
||||
>
|
||||
> 文档基于对整个 monorepo(server、apps、packages、migrations、daemon、CLI)的系统性调研生成,数据截止日期 2026-04-21。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [Multica 是什么](#1-multica-是什么)
|
||||
2. [核心概念词典](#2-核心概念词典)
|
||||
3. [功能全景(按模块)](#3-功能全景按模块)
|
||||
- 3.1 [Workspace 工作区](#31-workspace-工作区)
|
||||
- 3.2 [Issue 议题管理](#32-issue-议题管理)
|
||||
- 3.3 [Project 项目](#33-project-项目)
|
||||
- 3.4 [Agent 智能体](#34-agent-智能体)
|
||||
- 3.5 [Runtime 运行时 & Daemon 守护进程](#35-runtime-运行时--daemon-守护进程)
|
||||
- 3.6 [Skill 技能](#36-skill-技能)
|
||||
- 3.7 [Autopilot 自动驾驶](#37-autopilot-自动驾驶)
|
||||
- 3.8 [Chat 对话](#38-chat-对话)
|
||||
- 3.9 [Inbox 收件箱与通知](#39-inbox-收件箱与通知)
|
||||
- 3.10 [成员、邀请与权限](#310-成员邀请与权限)
|
||||
- 3.11 [搜索与命令面板](#311-搜索与命令面板)
|
||||
- 3.12 [认证、登录与 Onboarding](#312-认证登录与-onboarding)
|
||||
- 3.13 [设置与个人资料](#313-设置与个人资料)
|
||||
- 3.14 [CLI 命令行工具](#314-cli-命令行工具)
|
||||
4. [系统架构全景](#4-系统架构全景)
|
||||
5. [产品地图(全部路由)](#5-产品地图全部路由)
|
||||
6. [跨平台差异:Web vs 桌面](#6-跨平台差异web-vs-桌面)
|
||||
7. [附录:关键数据表速查](#7-附录关键数据表速查)
|
||||
|
||||
---
|
||||
|
||||
## 1. Multica 是什么
|
||||
|
||||
### 一句话定位
|
||||
|
||||
**Multica 把编码智能体变成真正的团队成员。**
|
||||
|
||||
像给同事分配任务一样,把一个 issue 指派给一个 agent,它会自己认领、写代码、汇报进度、更新状态——不需要你一直守着。
|
||||
|
||||
### 解决的问题
|
||||
|
||||
传统方式用 AI coding agent 的痛点:
|
||||
|
||||
- 每次都要复制粘贴 prompt
|
||||
- 必须盯着终端,看它跑不跑得完
|
||||
- 没有跨任务的记忆,每次都从零开始
|
||||
- 多个 agent 同时工作时,没有一个"看板"能看到全局
|
||||
|
||||
Multica 做的事:
|
||||
|
||||
- Agent 和人**共用同一个任务看板**(issue board)
|
||||
- Agent **有 profile**,会出现在 assignee 下拉里、会在评论区发言、会自己创建 issue
|
||||
- 同一个 (agent, issue) 的多轮对话**自动恢复会话**——上一次的上下文、工作目录都保留
|
||||
- **Skill 系统**让历史上解决过的问题沉淀成可复用的能力
|
||||
- **Autopilot** 让 agent 按定时规则自动开工(比如每天早上 9 点做 bug triage)
|
||||
|
||||
### 定位一句话版本
|
||||
|
||||
> Multica 不是一个 AI 工具,而是一个**人 + AI 协作的任务管理平台**。agent 是一等公民,和人在同一个工作流里。
|
||||
|
||||
### 部署形态
|
||||
|
||||
- **云版本(Multica Cloud)**:官方托管服务,agent 通过你本地跑的 daemon 执行
|
||||
- **自托管(Self-Host)**:完整后端可以部署在自己的服务器
|
||||
- **客户端**:Next.js web 版 + Electron 桌面版(两端体验基本一致,桌面独有:多标签、原生托盘、自动更新)
|
||||
|
||||
### 支持的 Coding Agent
|
||||
|
||||
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
|
||||
|
||||
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent
|
||||
|
||||
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心概念词典
|
||||
|
||||
**理解这些名词是理解产品的前提。每个概念的定义都严格对应数据库表。**
|
||||
|
||||
| 概念 | 定义 | 映射的数据表 |
|
||||
|------|------|-------------|
|
||||
| **User 用户** | 一个人类账号,可以登录,属于多个 workspace | `user` |
|
||||
| **Workspace 工作区** | 一切资源的容器。issue、agent、project、skill 全部隔离在 workspace 里。就是 Linear/Notion 里的 workspace/team 概念 | `workspace` |
|
||||
| **Member 成员** | 用户在某个 workspace 里的身份。一个用户在不同 workspace 可以有不同角色(owner/admin/member) | `member` |
|
||||
| **Agent 智能体** | 可被指派任务的 AI 工作者。有 profile(名字、头像、说明)、会指定 runtime 和 provider、可以配自定义 prompt 和技能 | `agent` |
|
||||
| **Runtime 运行时** | Agent 实际跑在哪里的**执行环境**。可以是用户本地机器(通过 daemon)或云端实例。**一个 runtime = 一台可以跑 agent 的机器** | `agent_runtime` |
|
||||
| **Daemon 守护进程** | 用户本地运行的后台程序,自动发现已安装的 coding CLI 并注册为 runtime,然后不停轮询 server 认领任务 | (进程,不是表) |
|
||||
| **Issue 议题** | 一个工作单元——任务、bug、feature。最核心的产品对象。可以分配给人或 agent | `issue` |
|
||||
| **Comment 评论** | Issue 下的讨论回复。人和 agent 都能发。在评论里 `@某个 agent` 会自动触发这个 agent 的新任务 | `comment` |
|
||||
| **Task 任务** | Agent 执行一次 issue 所产生的一次运行。本质是"一次 agent 跑起来的会话"。队列化执行 | `agent_task_queue` |
|
||||
| **Skill 技能** | 工作区级别的可复用说明文档。作用是给 agent 提供"怎么做某件事"的上下文。Agent 开跑时会把挂载的 skill 内容注入到工作目录让 CLI 能读到 | `skill`, `skill_file`, `agent_skill` |
|
||||
| **Project 项目** | 议题的高层归属,类似"里程碑"或"版本"。issue 可以归属到 project | `project` |
|
||||
| **Autopilot 自动驾驶** | 定时或被触发的自动化规则。按 cron 或 webhook 触发,自动创建 issue 并分配给 agent | `autopilot`, `autopilot_trigger`, `autopilot_run` |
|
||||
| **Chat 对话** | 用户和 agent 的持久化多轮对话。不依附于 issue | `chat_session`, `chat_message` |
|
||||
| **Inbox 收件箱** | 个人通知中心。被 @、被分配、订阅的 issue 有更新都会进这里 | `inbox_item` |
|
||||
| **Subscriber 订阅者** | 谁关注某个 issue。被分配、被 @、评论过都会自动订阅。订阅者会收到 inbox 通知 | `issue_subscriber` |
|
||||
| **Activity 活动 / Timeline 时间线** | 所有关键动作的审计记录。issue 详情页的"时间线"就是这个表的数据 | `activity_log` |
|
||||
| **Pin 固定** | 个人侧边栏快捷方式,把常用的 issue/project 置顶 | `pinned_item` |
|
||||
| **Reaction 反应** | Issue 或评论上的 emoji 反应,跟 GitHub/Slack 一样 | `issue_reaction`, `comment_reaction` |
|
||||
| **Attachment 附件** | Issue 或评论的文件上传,支持 S3/CloudFront 或本地存储 | `attachment` |
|
||||
| **Personal Access Token (PAT)** | 用户级 API token,CLI 和自动化用。`mul_` 前缀 | `personal_access_token` |
|
||||
| **Daemon Token** | 单 workspace 单 daemon 的 token。`mdt_` 前缀,比 PAT 权限范围更小 | `daemon_token` |
|
||||
| **Session Resumption 会话恢复** | 同一对 (agent, issue) 的下一次任务会自动复用上次 Claude Code 的 `session_id` 和工作目录——历史对话、文件状态都保留 | `agent_task_queue.session_id`, `.work_dir` |
|
||||
| **MCP (Model Context Protocol)** | Anthropic 提出的协议,让 agent 通过标准接口调用外部工具。每个 agent 可配自己的 MCP server 列表 | `agent.mcp_config` (JSONB) |
|
||||
| **Workspace Context 工作区上下文** | 工作区级别的 agent 系统提示词。所有该工作区的 agent 都会感知到它 | `workspace.context` |
|
||||
| **Polymorphic Actor 多态行动者** | 设计范式:几乎所有"谁做了什么"的字段都是 `actor_type` (`member`/`agent`) + `actor_id`。这就是为什么 agent 能像人一样创建 issue、发评论、被订阅 | 贯穿所有表 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能全景(按模块)
|
||||
|
||||
### 3.1 Workspace 工作区
|
||||
|
||||
> **角色**:一切的容器。Multica 的多租户边界。
|
||||
|
||||
#### 功能
|
||||
|
||||
- **多工作区**:一个用户可以属于多个 workspace,每个 workspace 完全隔离(issue、agent、skill、成员都独立)。
|
||||
- **创建工作区**:只需要一个名字;自动生成 slug(URL 中使用的短 ID)。
|
||||
- **切换工作区**:侧边栏下拉;桌面端每个工作区有独立的标签组。
|
||||
- **离开工作区**:非 owner 成员可自行离开。
|
||||
- **删除工作区**:只有 owner 可以,硬删除+级联。
|
||||
- **Workspace 设置**:名称、slug、描述、**Workspace Context**(给该工作区所有 agent 的统一系统提示)、**仓库列表**(workspace 允许 agent 访问的 Git 仓库 URL 白名单)。
|
||||
- **Workspace 头像 / issue 前缀**:每个工作区可以有自己的 issue 编号前缀(如 `ACME-42`)。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Workspace 不是一个功能,而是**所有功能的坐标系**。URL 的形态永远是 `/{workspace-slug}/...`,API 请求永远带 `X-Workspace-Slug` 头。一个 issue、一个 agent、一个 skill,脱离了 workspace 就没有意义。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`workspace`, `member`, `workspace_invitation`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Issue 议题管理
|
||||
|
||||
> **角色**:Multica 的核心工作对象。
|
||||
|
||||
Issue 对应的概念在 Linear 叫 Issue、在 Jira 叫 Ticket、在 GitHub 叫 Issue——就是一个任务单元。Multica 的特色在于**issue 可以分配给 agent,和分配给人完全对等**。
|
||||
|
||||
#### 核心字段
|
||||
|
||||
- 标题、描述(Tiptap 富文本)、状态、优先级
|
||||
- 编号(自动递增,带 workspace 前缀)
|
||||
- **Assignee(可以是 member 或 agent)**
|
||||
- **Creator(可以是 member 或 agent)**——agent 也能创建 issue
|
||||
- Parent issue(用来做子任务)
|
||||
- Project(归属的项目)
|
||||
- Due date(截止日期)
|
||||
- Labels(多对多标签)
|
||||
- Dependencies(依赖/阻塞关系)
|
||||
- Acceptance criteria(验收标准,JSONB)
|
||||
- Origin(如果是 autopilot 创建的,会记录来源 autopilot run)
|
||||
|
||||
#### 视图
|
||||
|
||||
- **List 列表视图**:表格形式,可按 status/priority/assignee/creator/project 过滤、按名称/优先级/截止日/手动位置排序;支持开放和已完成分页。
|
||||
- **Board 看板视图**:Kanban,按状态分列;支持拖拽(拖动会自动切到"手动排序"模式)。
|
||||
- **My Issues 我的议题**:专属视图,三个 scope:分配给我 / 我创建的 / 我的 agent 负责的。
|
||||
|
||||
#### 交互
|
||||
|
||||
- **快速创建**:侧边栏单行快速创建、或弹窗富文本创建(支持草稿本地持久化)
|
||||
- **批量操作**:多选后批量改 status/priority/assignee/删除
|
||||
- **子 issue**:父 issue 显示子任务完成比例圆环
|
||||
- **订阅(subscribe)**:默认 creator、assignee、被 @ 的人会自动订阅
|
||||
- **Reaction**:issue 和评论都能加 emoji 反应
|
||||
- **Pin 固定**:把 issue 置顶到侧边栏快捷栏
|
||||
- **复制链接 / 快捷键跳转(Cmd+K)**
|
||||
- **Timeline 时间线**:所有关键动作(状态变更、指派变更、评论)按时间顺序展示,混合 `activity_log` + `comment` 两类记录
|
||||
|
||||
#### 评论与讨论
|
||||
|
||||
- Tiptap 富文本编辑器,支持 `@` 提到 member 或 agent
|
||||
- 嵌套回复(一层)
|
||||
- emoji 反应
|
||||
- **@agent 触发任务**:在评论里提到某个 agent,会自动生成一个新的 agent task,让它来回复/处理
|
||||
|
||||
#### 附件
|
||||
|
||||
- 拖拽上传或按钮上传
|
||||
- 图片内联预览
|
||||
- 存储后端:S3/CloudFront 或本地磁盘(自托管)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Issue 是**所有工作流的载体**:
|
||||
- Agent 通过"被分配到 issue"获得任务
|
||||
- Autopilot 通过"创建 issue"来触发 agent
|
||||
- 评论通过"@agent" 追加任务
|
||||
- Inbox 通知围绕 issue 生成
|
||||
|
||||
#### 对应表
|
||||
|
||||
`issue`, `comment`, `issue_label`, `issue_to_label`, `issue_dependency`, `issue_subscriber`, `issue_reaction`, `comment_reaction`, `attachment`, `activity_log`, `pinned_item`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Project 项目
|
||||
|
||||
> **角色**:多个 issue 的高层容器,类似 Linear 的 Project、Jira 的 Epic。
|
||||
|
||||
#### 功能
|
||||
|
||||
- 标题、描述、图标(emoji 或标识符)
|
||||
- 状态:`planned` / `in_progress` / `paused` / `completed` / `cancelled`
|
||||
- 优先级:urgent / high / medium / low / none
|
||||
- **Lead 负责人**:可以是 member 或 agent(跟 issue 的 assignee 一样是多态)
|
||||
- 详情页展示项目内的所有 issue
|
||||
- 支持搜索项目
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于任何 project,但如果属于,会在列表页的筛选、侧边栏导航、面包屑里集中展示。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`project`
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Agent 智能体
|
||||
|
||||
> **角色**:AI 工作者。Multica 最独特的对象。
|
||||
|
||||
一个 Agent 不是一个"AI 模型",而是一个**带配置的工作者身份**。它有名字、头像、个人描述、说明书(系统提示词)、绑定的运行时、挂载的技能。在 UI 上它和人一样会出现在 assignee 下拉、评论作者、订阅者列表里。
|
||||
|
||||
#### 配置字段
|
||||
|
||||
- **基本信息**:名字、描述、头像(自动生成)
|
||||
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个
|
||||
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
|
||||
- **Instructions 说明书**:agent 的系统提示词("你是一个资深工程师...")
|
||||
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`CLAUDE_CODE_USE_BEDROCK`)
|
||||
- **Custom Args**:附加给 CLI 的启动参数(如 `--model`, `--thinking`)
|
||||
- **MCP Config**:Model Context Protocol 服务器列表(让 agent 有额外工具能力)
|
||||
- **Max Concurrent Tasks**:同时最多跑几个任务
|
||||
- **Skills**:关联多个 skill(见 3.6)
|
||||
- **Visibility**:`workspace`(工作区可见)或 `private`(仅创建者可见)
|
||||
|
||||
#### 状态
|
||||
|
||||
- `idle` / `working` / `blocked` / `error` / `offline`——由 runtime heartbeat 决定
|
||||
- 可以被 archive(软删除)
|
||||
|
||||
#### 交互
|
||||
|
||||
- 在 **Settings → Agents** 页面创建、编辑、归档
|
||||
- 在 issue 的 assignee 下拉里选择
|
||||
- 在评论里 `@agent` 触发
|
||||
- 在 chat 面板里直接聊
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent 干活"展开:
|
||||
- Issue 通过分配触发 agent
|
||||
- Skill 通过挂载赋能 agent
|
||||
- Runtime 提供 agent 的运行环境
|
||||
- Autopilot 调度 agent 自动开工
|
||||
- Chat 提供 agent 的对话界面
|
||||
|
||||
#### 对应表
|
||||
|
||||
`agent`, `agent_skill`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Runtime 运行时 & Daemon 守护进程
|
||||
|
||||
> **角色**:Agent 真正跑起来的物理/虚拟机器。
|
||||
|
||||
这是 Multica **分布式执行架构**的核心设计:**agent 不在 server 上运行,而在用户自己的机器上运行**。Server 只做任务调度、状态同步、数据存储。
|
||||
|
||||
#### Daemon 是什么
|
||||
|
||||
`multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它:
|
||||
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`)
|
||||
2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime)
|
||||
3. 每 3 秒 **轮询** 一次 server,有任务就认领
|
||||
4. 每 15 秒 **心跳**(keepalive),报告自己还活着
|
||||
5. 认领任务后,在本机的隔离工作目录里**启动 agent CLI**,把 agent 的输出流**实时推回 server**
|
||||
6. 任务完成后上报结果、token 用量、session id 和工作目录(用于下次恢复)
|
||||
|
||||
#### Runtime 展示
|
||||
|
||||
在 **Settings → Runtimes** 页面可以看到:
|
||||
|
||||
- 每个 runtime 的名字、提供方(图标)、owner(谁的机器)、状态指示(在线/离线)、last seen 时间
|
||||
- Ping 诊断:手动戳一下看响应
|
||||
- Usage 用量:近期的 token 消耗统计
|
||||
- Activity:任务活动情况
|
||||
- CLI 安装指引(自托管模式下)
|
||||
- 桌面端独有:**本地 daemon 卡片**,显示本机 daemon 状态、可一键重启
|
||||
|
||||
#### Runtime 的生命周期
|
||||
|
||||
- **注册**:daemon 启动时 POST `/api/daemon/register` 得到 runtime ID
|
||||
- **在线**:15 秒一次心跳
|
||||
- **离线**:如果 server 45 秒没收到心跳,把 runtime 标记为离线(server 后台 sweeper 每 30 秒巡检)
|
||||
- **孤儿任务回收**:超过 5 分钟还在 dispatched 或超过 2.5 小时还在 running 的任务,sweeper 会把它标记为失败
|
||||
- **长期离线 GC**:7 天没心跳且没活跃 agent 的 runtime 会被回收
|
||||
|
||||
#### CLI 与 Daemon 的关系
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `multica setup` | 一键配置:填 URL + 登录 + 启动 daemon |
|
||||
| `multica login` | 浏览器打开 OAuth 登录,保存 90 天 PAT 到 `~/.multica/config.json` |
|
||||
| `multica login --token <pat>` | 无头登录(SSH/CI) |
|
||||
| `multica daemon start` | 后台启动 daemon(写 PID 到 `~/.multica/daemon.pid`,日志到 `~/.multica/daemon.log`) |
|
||||
| `multica daemon stop` | 发 SIGTERM,优雅关闭(等待进行中的任务完成,超时 30s) |
|
||||
| `multica daemon status` | 打印 daemon 状态、探测到的 agent、watch 中的 workspace |
|
||||
| `multica daemon logs -f` | 实时跟随日志 |
|
||||
| `multica daemon start --profile <name>` | 启动独立配置的 daemon(用于多环境,比如同时连 staging 和生产) |
|
||||
|
||||
#### 安全边界
|
||||
|
||||
- 每个任务一个**独立工作目录** `~/multica_workspaces/{ws}/{task_short_id}/workdir/`
|
||||
- 环境变量**过滤**:阻止 agent 覆盖 daemon 的认证变量(`MULTICA_TOKEN` 等)
|
||||
- 仓库访问**白名单**:agent 只能 checkout workspace 配置的仓库
|
||||
- Codex 有**版本相关的 sandbox 策略**
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Runtime 是让"给 agent 分配任务"这件事**能真正发生**的基础设施。没有 runtime,所有 agent 就是空壳。用户第一次 onboarding 时必须至少有一个 runtime 在线,否则 agent 没法干活。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`agent_runtime`, `daemon_token`, `daemon_pairing_session`(弃用中), `daemon_connection`(弃用中), `runtime_usage`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Skill 技能
|
||||
|
||||
> **角色**:让 agent "学会"某种工作方式的可复用说明文档。
|
||||
|
||||
Skill 是一组 Markdown 文档 + 配套文件。它**不是代码**,**不是 prompt 模板**,而是**给 agent CLI 读的说明**。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
```
|
||||
skill
|
||||
├─ name: "react-patterns"
|
||||
├─ description: "Common React patterns and best practices"
|
||||
├─ content: "## Overview\n..." # 主要说明文档
|
||||
└─ files:
|
||||
├─ examples/hooks.md
|
||||
└─ examples/useState.jsx
|
||||
```
|
||||
|
||||
#### 它怎么工作
|
||||
|
||||
1. **创建**:在 **Settings → Skills** 页面创建或从 URL 导入(如 clawhub.ai、skills.sh)
|
||||
2. **挂载**:给某个 agent 勾选要用的 skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/agent/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
- 其他 → `.agent_context/skills/{name}/SKILL.md`
|
||||
4. **使用**:agent CLI 自己按照 provider 约定发现并读取这些文件
|
||||
|
||||
> 💡 **Skill 是静态的**——不是 AI 生成的,也不会随执行变化。它是人写的经验文档。未来可能扩展成"AI 从历史任务中沉淀技能",但当前版本不是。
|
||||
|
||||
#### CLI 对应命令
|
||||
|
||||
```bash
|
||||
multica skill list
|
||||
multica skill get <id>
|
||||
multica skill create --title ...
|
||||
multica skill import --url https://...
|
||||
multica skill files upsert <skill-id> --path ...
|
||||
```
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Skill 是 Multica 区别于"每次都要写长 prompt"的关键机制。它让团队的专业知识**沉淀成可复用的组件**,绑在 agent 上就生效——就像给员工写的 SOP/playbook。
|
||||
|
||||
从架构角度:skill 不参与执行逻辑,只参与**上下文注入**。它在整个任务生命周期里只出现一次——在 daemon 启动 CLI 之前的环境准备阶段。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`skill`, `skill_file`, `agent_skill`
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Autopilot 自动驾驶
|
||||
|
||||
> **角色**:让 agent 在没人触发的时候也能自己开工的调度器。
|
||||
|
||||
Autopilot 解决的问题:很多工作是**周期性**的——每天早上的 bug triage、每周的依赖审计、每月的安全扫描。人手动触发太烦,Autopilot 是规则化自动触发。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
```
|
||||
autopilot
|
||||
├─ title, description
|
||||
├─ assignee: <agent_id> # 指定哪个 agent 跑
|
||||
├─ execution_mode: create_issue | run_only
|
||||
├─ issue_title_template: "Daily triage - {{date}}"
|
||||
├─ concurrency_policy: skip | queue | replace
|
||||
└─ triggers (多个):
|
||||
├─ kind: schedule | webhook | api
|
||||
├─ cron_expression
|
||||
├─ timezone
|
||||
└─ webhook_token
|
||||
```
|
||||
|
||||
#### 两种执行模式
|
||||
|
||||
- **`create_issue`(默认)**:触发时先创建一个新 issue(标题用 `issue_title_template` 渲染),再把 issue 分配给 agent,走正常 agent 任务流程
|
||||
- **`run_only`**:直接创建 task,不关联 issue(适合"只执行不留下 ticket"的场景,比如每小时检查某状态)
|
||||
|
||||
#### 三种触发方式
|
||||
|
||||
- **Schedule(cron)**:server 后台每 30 秒扫一次 `autopilot_trigger`,到点的触发出去
|
||||
- **Webhook**:给出一个带 `webhook_token` 的 URL,外部 POST 即可触发
|
||||
- **API / Manual**:UI 上点"立即运行"按钮,或用 CLI `multica autopilot trigger <id>`
|
||||
|
||||
#### 并发策略
|
||||
|
||||
- `skip`:同一个 autopilot 上一次还没跑完,跳过这次(去重)
|
||||
- `queue`:排队等上一次跑完
|
||||
- `replace`:中止上一次,换成这次
|
||||
|
||||
#### 运行记录
|
||||
|
||||
每次触发都在 `autopilot_run` 里留一条记录:`pending → issue_created → running → completed/failed/skipped`。在 UI 的 autopilot 详情页可以看全部历史。
|
||||
|
||||
#### 内置模板
|
||||
|
||||
产品提供一些现成的 autopilot 模板,一键创建:
|
||||
|
||||
- Daily news digest(每天 9:00)
|
||||
- PR review reminder(工作日 10:00)
|
||||
- Bug triage(工作日 9:00)
|
||||
- Weekly progress report(每周 17:00)
|
||||
- Dependency audit(每周 10:00)
|
||||
- Security scan(每周 02:00)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Autopilot 让 Multica 从"你分配 → agent 做"升级到"agent 自己发起工作"。配合 `run_only` 模式,甚至可以在没有 issue 的前提下跑定时任务。Issue 上的 `origin_type=autopilot` + `origin_id` 字段留下了"这个 issue 是哪个 autopilot run 创建的"的追溯链。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`autopilot`, `autopilot_trigger`, `autopilot_run`
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Chat 对话
|
||||
|
||||
> **角色**:用户和 agent 的持久多轮对话界面,不依附于 issue。
|
||||
|
||||
有时候你不想为了和 agent 说一句话就开一个 issue。Chat 就是为这种"轻量对话"准备的——像 ChatGPT 的对话界面,但是你在和你工作区的某个 agent 对话。
|
||||
|
||||
#### 功能
|
||||
|
||||
- **创建会话**:选一个 agent 开始
|
||||
- **消息列表**:支持 Markdown 渲染、代码块高亮
|
||||
- **发送消息**:消息会被 queue 成一个 task,agent 执行后把响应作为消息写回
|
||||
- **流式响应**:通过 WebSocket 实时推送
|
||||
- **未读跟踪**:`unread_since` 字段记录第一条未读消息的时间戳
|
||||
- **归档**:把旧会话移出活跃列表
|
||||
- **Session 复用**:同一个 chat session 下的多轮消息会复用底层 CLI 的 `session_id`(Claude Code 能保留对话上下文)
|
||||
|
||||
#### 和 Issue 评论的区别
|
||||
|
||||
| | Chat | Issue 评论 |
|
||||
|---|---|---|
|
||||
| 上下文载体 | 独立 session(chat_session) | 某个 issue |
|
||||
| 是否公开 | 个人和 agent 对话(私有) | 工作区所有成员可见 |
|
||||
| 触发 agent | 每条 user 消息都触发 | 需要 `@agent` |
|
||||
| 用途 | 探索、提问、一次性任务 | 和 issue 强绑定的工作推进 |
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Chat 填补了"不够正式到需要开 issue、但又需要持久化"的对话空白。同时也是体验上更像常规聊天软件的入口。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`chat_session`, `chat_message`;底层执行仍走 `agent_task_queue`(`chat_session_id` 字段区分)
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Inbox 收件箱与通知
|
||||
|
||||
> **角色**:每个人的个人通知中心。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
`inbox_item` 是推给特定"recipient"的条目:
|
||||
|
||||
- recipient_type = `member` 或 `agent`(agent 也能有 inbox!)
|
||||
- type(e.g. `issue_assigned`, `comment_mention`, `task_completed`, `invitation_created`)
|
||||
- severity(`action_required` / `attention` / `info`)
|
||||
- 关联的 issue(如果有)
|
||||
- read / archived 状态
|
||||
|
||||
#### 通知触发场景
|
||||
|
||||
- Issue 被分配给你
|
||||
- 被 @ 提到
|
||||
- 订阅的 issue 状态变化
|
||||
- 订阅的 issue 有新评论
|
||||
- 工作区邀请
|
||||
- 你的 agent 任务完成/失败
|
||||
|
||||
#### 订阅机制(自动)
|
||||
|
||||
Server 的 subscriber listener 自动把以下人加入 `issue_subscriber`:
|
||||
|
||||
- issue creator
|
||||
- 当前 assignee(变更会同步更新)
|
||||
- 评论里被 @ 的人
|
||||
- 手动订阅的人
|
||||
|
||||
#### UI
|
||||
|
||||
- **Inbox 页面**:两栏布局,左边列表 + 右边 issue 详情
|
||||
- **批量操作**:全部标记已读 / 仅归档已读 / 归档已完成 issue 的通知
|
||||
- **徽标**:侧边栏导航上显示未读数
|
||||
- **WebSocket 推送**:新 inbox 条目实时到达(`inbox:new` 事件只发给目标用户)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Inbox 是"主动注意力系统",让用户不必一直盯着看板也知道哪些事要自己处理。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`inbox_item`, `issue_subscriber`
|
||||
|
||||
---
|
||||
|
||||
### 3.10 成员、邀请与权限
|
||||
|
||||
#### 角色体系
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| **Owner** | 全部;唯一能删除工作区的角色 |
|
||||
| **Admin** | 管理成员、管理设置;不能删工作区,不能移除其他 admin |
|
||||
| **Member** | 创建 issue、评论、自我分配、使用 agent |
|
||||
|
||||
#### 邀请流程
|
||||
|
||||
- Admin 在 **Settings → Members** 输入邮箱邀请
|
||||
- Server 生成 `workspace_invitation` 记录(7 天过期)
|
||||
- 发送邮件(Resend 集成,未配置时打到 stderr)
|
||||
- 被邀请人收到邀请:如果已有账号,会出现在个人 Inbox;如果没账号,邮件里有注册链接
|
||||
- 接受 / 拒绝 / 过期
|
||||
|
||||
#### UI
|
||||
|
||||
- 成员列表:头像、邮箱、角色徽章、操作菜单(改角色、移除)
|
||||
- 待处理邀请列表:可 resend、revoke
|
||||
- Invite 接受页面(`/invite/[id]`):展示工作区信息、接受/拒绝按钮
|
||||
|
||||
#### 邀请接受的桌面特殊处理
|
||||
|
||||
桌面端的 `multica://invite/{id}` 深链接**不是走路由**,而是触发 `WindowOverlay`——共享视图组件 `InvitePage` 装在原生窗口覆盖层里,保证拖拽移动窗口等原生体验。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
成员管理是**一切协作的前提**。但在 Multica 里它有一个独特之处:成员系统也管 agent。之所以要有 `assignee_type` 区分 member 和 agent,就是为了让两者在同一套 API 里表达"谁可以被分配"。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`member`, `workspace_invitation`
|
||||
|
||||
---
|
||||
|
||||
### 3.11 搜索与命令面板
|
||||
|
||||
#### 命令面板(Cmd+K)
|
||||
|
||||
全局搜索入口,覆盖:
|
||||
|
||||
- **Issues**(按标题、编号匹配)
|
||||
- **Projects**(按名称匹配)
|
||||
- **Workspaces**(按名称匹配,用于快速切换)
|
||||
- **Navigation**(跳转到设置、runtimes、skills 等)
|
||||
- **Actions**(新建 issue、新建 project、切换主题)
|
||||
- **Recent Issues**(最近访问过的,自动记录)
|
||||
|
||||
#### 列表过滤
|
||||
|
||||
Issue 列表、project 列表、inbox 等都有本地 filter chips 和 search input。
|
||||
|
||||
#### 全文搜索
|
||||
|
||||
`GET /api/issues/search` 支持对 issue 的标题、描述、评论内容做全文搜索,返回命中片段。
|
||||
|
||||
> **当前没有基于向量的语义搜索**——产品宣传是 AI-native,但没有用 pgvector。Schema 里也没启用向量扩展。未来可能扩展。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Cmd+K 是 keyboard-first 用户(Linear-style)的主要导航方式,比点击侧边栏更快。
|
||||
|
||||
---
|
||||
|
||||
### 3.12 认证、登录与 Onboarding
|
||||
|
||||
#### 登录方式
|
||||
|
||||
- **邮箱验证码(Magic Link 风格)**:输入邮箱 → 收 6 位验证码 → 输入验证码登录
|
||||
- **Google OAuth**:一键 Google 登录
|
||||
- **PAT(CLI)**:用户在 Settings → API Tokens 里生成的 token,CLI/脚本场景
|
||||
|
||||
#### Onboarding 流程(正在重设计中)
|
||||
|
||||
位于 `packages/views/onboarding/` 和 `apps/web/app/(auth)/onboarding/`。
|
||||
|
||||
经典 5 步:
|
||||
|
||||
1. **Welcome** — 欢迎页
|
||||
2. **Workspace** — 创建工作区(或跳过,如果已有)
|
||||
3. **Runtime** — 展示可用的 runtime 和 CLI 安装指引
|
||||
4. **Agent** — 创建第一个 agent(需要有 runtime)
|
||||
5. **Complete** — 展示创建好的 workspace 和 agent,跳转到 dashboard
|
||||
|
||||
#### 邀请接受(Zero-workspace)
|
||||
|
||||
如果新用户是被邀请进来的(还没有自己的 workspace),接受邀请后直接进入该工作区,跳过 onboarding。
|
||||
|
||||
#### 认证后的跳转规则
|
||||
|
||||
- 已登录且有至少一个 workspace:跳到 `/{slug}/issues`
|
||||
- 已登录但没有 workspace:进入 `/workspaces/new` 或 onboarding
|
||||
- 未登录:跳到 `/login`
|
||||
|
||||
#### Signup 限流
|
||||
|
||||
Server 支持:
|
||||
- `ALLOW_SIGNUP=false` 关闭注册
|
||||
- `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 白名单
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Onboarding 是新用户能不能成功把 agent 跑起来的关键漏斗。任何一步没完成(尤其是 runtime 没连上),后续功能都是空壳。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`user`, `verification_code`, `personal_access_token`
|
||||
|
||||
---
|
||||
|
||||
### 3.13 设置与个人资料
|
||||
|
||||
#### My Account 标签
|
||||
|
||||
- **Profile**:名字、头像(不可上传,系统生成)、邮箱(只读)
|
||||
- **Appearance**:主题(light / dark / system)
|
||||
- **API Tokens**:创建/查看/撤销 PAT;创建时一次性展示完整 token
|
||||
- **Daemon**(桌面独有):本机 daemon 状态、重启、开机自启开关
|
||||
- **Updates**(桌面独有):当前版本、检查更新、自动更新开关
|
||||
|
||||
#### Workspace 标签
|
||||
|
||||
- **General**:名字、描述、**Workspace Context**(agent 系统级提示)
|
||||
- **Members**:见 3.10
|
||||
- **Repositories**:GitHub 集成,连接仓库列表,agent 白名单
|
||||
- **Agents / Runtimes / Skills / Autopilots**:各自独立页面(实际上这些在侧边栏直接有入口,settings 里也有对应管理 tab)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Settings 是所有"配置即工作"动作的汇总:agent 的 prompt、workspace 的 context、仓库白名单、skill 的内容——都在这里。**对运营和文案来说最重要的一句话**:用户在 Multica 的 settings 页面做的配置,每一项都会影响 agent 实际执行时读到的上下文。
|
||||
|
||||
---
|
||||
|
||||
### 3.14 CLI 命令行工具
|
||||
|
||||
`multica` 不只是启动 daemon 的工具,也是完整的命令行操作层。很多用户喜欢在终端里推进工作而不是开 UI。
|
||||
|
||||
#### 工作区 / 议题
|
||||
|
||||
```bash
|
||||
multica workspace list | get | watch | unwatch
|
||||
multica issue list | get | create | update | assign | status
|
||||
multica issue comment list | add | delete
|
||||
multica issue runs <id> # 查看任务执行历史
|
||||
multica issue run-messages <task-id> # 查看某次执行的消息
|
||||
```
|
||||
|
||||
#### Agent / Skill / Autopilot / Project / Repo
|
||||
|
||||
```bash
|
||||
multica agent list | get | create | update | archive
|
||||
multica skill list | get | create | update | delete | import | files upsert
|
||||
multica autopilot list | get | create | update | trigger
|
||||
multica autopilot trigger-add --cron "0 9 * * 1-5"
|
||||
multica project list | get | create | update
|
||||
multica repo list | add | update | delete
|
||||
```
|
||||
|
||||
#### Runtime
|
||||
|
||||
```bash
|
||||
multica runtime list | get | ping | delete
|
||||
```
|
||||
|
||||
#### 配置 / 更新
|
||||
|
||||
```bash
|
||||
multica config show | set server_url ...
|
||||
multica auth status | logout
|
||||
multica version | update
|
||||
```
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
CLI 是 Multica 对开发者友好度的体现。对于 agent 自己来说,也同等重要——**agent 在执行任务时能调用 `multica` 命令读写 issue、评论、查文档**,这正是 CLI 在 "agent 作为一等公民"架构里的作用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 系统架构全景
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────────┐
|
||||
│ Next.js Web App │ │ Electron Desktop │ │ multica CLI │
|
||||
│ apps/web │ │ apps/desktop │ │ server/cmd/ │
|
||||
└──────────┬──────────┘ └──────────┬─────────┘ └────────┬─────────┘
|
||||
│ HTTP + WebSocket │ │ HTTP
|
||||
│ │ │
|
||||
└──────────────┬────────────────┴───────────────┬───────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Go Backend (server/) │
|
||||
│ • Chi HTTP router • gorilla/websocket hub │
|
||||
│ • sqlc generated queries │
|
||||
│ • In-process event bus │
|
||||
│ • Background workers (sweeper / scheduler) │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ PostgreSQL 17 │
|
||||
│ + pgcrypto │
|
||||
│ (28 tables) │
|
||||
└──────────────────────┘
|
||||
|
||||
▲
|
||||
│ HTTPS poll + heartbeat
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Local Daemon (用户机器上运行) │
|
||||
│ • 每 3s 认领任务 • 每 15s 心跳 │
|
||||
│ • 探测并启动 agent CLI 子进程 │
|
||||
│ • 为任务准备隔离工作目录 │
|
||||
└───────────────┬─────────────────────────────────┘
|
||||
│ spawns
|
||||
┌───────────────┼─────────────────────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
Claude Code Codex OpenCode …其他 CLI
|
||||
(子进程) (子进程) (子进程)
|
||||
```
|
||||
|
||||
### 分层职责
|
||||
|
||||
| 层 | 负责什么 | 不负责什么 |
|
||||
|---|---|---|
|
||||
| **Web / Desktop 客户端** | UI、本地客户端状态(Zustand)、服务器状态缓存(TanStack Query)、WebSocket 订阅 | 业务规则、AI 调用 |
|
||||
| **Server** | 持久化、权限、任务编排、事件广播、Autopilot 调度、Runtime 健康监测 | 不直接执行 agent、不调 LLM |
|
||||
| **Daemon** | 探测并启动本地 CLI、管理任务工作目录、流式上报消息、session 恢复 | 不做业务决策、只认 server 给它的任务 |
|
||||
| **Agent CLI(Claude Code 等)** | 实际调用 LLM、执行工具调用、写文件、跑测试 | 不感知 Multica 的数据模型(所有上下文通过 `multica` CLI 命令读回) |
|
||||
|
||||
### 实时层(WebSocket)
|
||||
|
||||
Server 启动一个 WebSocket hub:
|
||||
|
||||
- **鉴权**:URL 参数里的 JWT 或 PAT + workspace_slug
|
||||
- **房间模型**:按 workspace 分房间,一个 workspace 的事件只广播给该房间的连接
|
||||
- **个人定向推送**:`inbox:new`, `invitation:created` 等个人事件用 `SendToUser`
|
||||
- **心跳**:server 每 54 秒 ping,客户端 60 秒内必须 pong
|
||||
|
||||
**全部事件类型(供文案参考,共约 60+ 个)**:
|
||||
- `issue:created` / `issue:updated` / `issue:deleted`
|
||||
- `comment:created` / `comment:updated` / `comment:deleted` / `reaction:added` / `issue_reaction:added`
|
||||
- `agent:created` / `agent:status` / `agent:archived`
|
||||
- `task:dispatch` / `task:progress` / `task:message` / `task:completed` / `task:failed` / `task:cancelled`
|
||||
- `inbox:new` / `inbox:read` / `inbox:archived` / `inbox:batch-*`
|
||||
- `workspace:updated` / `workspace:deleted` / `member:added` / `member:updated` / `member:removed`
|
||||
- `invitation:created` / `invitation:accepted` / `invitation:declined` / `invitation:revoked`
|
||||
- `chat:message` / `chat:done` / `chat:session_read`
|
||||
- `skill:created` / `skill:updated` / `skill:deleted`
|
||||
- `project:created` / `project:updated` / `project:deleted`
|
||||
- `autopilot:created` / `autopilot:updated` / `autopilot:run_start` / `autopilot:run_done`
|
||||
- `subscriber:added` / `activity:created`
|
||||
- `daemon:heartbeat` / `daemon:register`
|
||||
|
||||
客户端收到事件后的模式:要么直接 patch 本地缓存(issue / comment / task 这类需要即时更新的),要么触发对应 query 的失效重拉(less-critical 数据)。
|
||||
|
||||
### AI / LLM 在哪里
|
||||
|
||||
**Multica 本身不直接调 LLM API**。所有 LLM 调用都在 agent CLI 子进程里发生(Claude Code 调 Anthropic API、Codex 调 OpenAI API 等)。
|
||||
|
||||
Server 和 daemon 做的事情是:
|
||||
|
||||
1. 准备 prompt(见 `server/internal/daemon/prompt.go`)
|
||||
2. 准备环境变量(agent.custom_env 注入)
|
||||
3. 准备工作目录(注入 CLAUDE.md / AGENTS.md / skills / issue context)
|
||||
4. 启动 CLI 子进程
|
||||
5. 流式读 CLI 的 stdout,把消息分类并转发
|
||||
|
||||
**所以看不到大段的 prompt 工程代码**——prompt 只有几个模板(task prompt、chat prompt、comment-triggered prompt),核心内容是 agent instructions + issue context + skill files,真正的 LLM 对话由 CLI 自己管理。
|
||||
|
||||
### 后台任务
|
||||
|
||||
Server 启动三个 goroutine:
|
||||
|
||||
1. **Runtime Sweeper**(每 30s):标记离线 runtime、回收孤儿任务、GC 长期离线 runtime
|
||||
2. **Autopilot Scheduler**(每 30s):扫 cron 触发器,到点就 dispatch
|
||||
3. **DB Stats Logger**:周期性打印 pgxpool 连接池状态
|
||||
|
||||
---
|
||||
|
||||
## 5. 产品地图(全部路由)
|
||||
|
||||
### 公共 / 认证
|
||||
|
||||
- `/` — 首页
|
||||
- `/login` — 登录
|
||||
- `/auth/callback` — OAuth 回调
|
||||
- `/workspaces/new` — 创建工作区
|
||||
- `/invite/[id]` — 接受邀请
|
||||
- `/onboarding` — 首次引导
|
||||
|
||||
### 工作区内(`/{slug}/...`)
|
||||
|
||||
- `/issues` — Issue 列表(board / list 视图)
|
||||
- `/issues/[id]` — Issue 详情
|
||||
- `/my-issues` — 我的 issue(三 scope)
|
||||
- `/projects` — 项目列表
|
||||
- `/projects/[id]` — 项目详情
|
||||
- `/autopilots` — Autopilot 列表
|
||||
- `/autopilots/[id]` — Autopilot 详情
|
||||
- `/agents` — Agent 列表
|
||||
- `/runtimes` — Runtime 列表
|
||||
- `/skills` — Skill 库
|
||||
- `/inbox` — 收件箱
|
||||
- `/settings` — 设置(包含多个 tab:profile / appearance / tokens / workspace / members / repos / daemon / updates)
|
||||
|
||||
### 桌面端特有(不是路由,是 WindowOverlay)
|
||||
|
||||
- **Create workspace overlay**
|
||||
- **Invite accept overlay**(来自 `multica://invite/{id}` 深链接)
|
||||
- **Onboarding overlay**(首次或零工作区时)
|
||||
|
||||
---
|
||||
|
||||
## 6. 跨平台差异:Web vs 桌面
|
||||
|
||||
### 共享(绝大部分功能)
|
||||
|
||||
所有业务页面(issues / projects / autopilots / agents / runtimes / skills / inbox / settings / chat / login / onboarding)的实际 UI 都在 `packages/views/` 里,web 和桌面共用同一套组件。
|
||||
|
||||
### Web 特有
|
||||
|
||||
- 地址栏 + 浏览器前进后退
|
||||
- 服务端渲染(SSR)
|
||||
- `/login` 的 OAuth 回调处理 localhost 端口(方便 CLI 登录)
|
||||
|
||||
### 桌面特有
|
||||
|
||||
- **多标签**:每个 workspace 独立标签组,可以拖拽重排
|
||||
- **WindowOverlay**:邀请接受、创建工作区、onboarding 不走路由,而是原生窗口层
|
||||
- **Daemon 集成**:设置里能直接重启本机 daemon、看状态
|
||||
- **本地 daemon runtime 卡片**:在 Runtimes 页面自动显示本机 daemon
|
||||
- **自动更新**:`Settings → Updates` 检查/下载/安装新版本
|
||||
- **Immersive mode**:全屏模式,隐藏侧边栏
|
||||
- **深链接**:`multica://auth/callback?token=...` 和 `multica://invite/{id}`
|
||||
- **拖动区**:macOS 的红绿灯 + 顶部 48px 拖拽条(`h-12`)用来移动窗口
|
||||
- **Workspace 单例守护**:`setCurrentWorkspace()` 管理当前活跃工作区的全局身份
|
||||
|
||||
### 为什么两端要做差异
|
||||
|
||||
Web 有 URL 栏——错误状态(比如"你没有访问这个 workspace 的权限")作为一个可分享的 URL 页面是有意义的。桌面没有 URL 栏——同样的状态只会把用户困住,所以桌面选择**静默自愈**:把失效的 tab 从 store 里移除即可。这个差异直接影响多个细节:
|
||||
|
||||
- Web 有 `NoAccessPage`,桌面没有
|
||||
- Web 有 `/workspaces/new` 页面,桌面把它做成 overlay
|
||||
- Web 的 deep link 直接路由,桌面的深链接转 WindowOverlay
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录:关键数据表速查
|
||||
|
||||
共 **28 张表**,覆盖 10 个产品域。以下按域列出最重要的字段,供文案/产品查询"某个功能背后到底存了什么"。
|
||||
|
||||
### 身份 / 认证
|
||||
|
||||
- `user` — 基础账号(id, email, name, avatar_url)
|
||||
- `verification_code` — 邮箱验证码(code, expires_at, attempts)
|
||||
- `personal_access_token` — 用户 API token(token_hash, token_prefix, revoked)
|
||||
|
||||
### 工作区 / 成员
|
||||
|
||||
- `workspace` — 容器(name, slug, description, context, settings, repos, issue_prefix, issue_counter)
|
||||
- `member` — 成员身份(role: owner/admin/member)
|
||||
- `workspace_invitation` — 邀请(invitee_email, status: pending/accepted/declined/expired)
|
||||
|
||||
### Agent / Runtime / Skill
|
||||
|
||||
- `agent` — Agent 主表(instructions, custom_env, custom_args, mcp_config, runtime_mode, visibility, status)
|
||||
- `agent_runtime` — 运行时(daemon_id, provider, status: online/offline, last_seen_at)
|
||||
- `agent_skill` — agent 挂载 skill 的 n-n 关联
|
||||
- `skill` — 技能主文档(name, description, content)
|
||||
- `skill_file` — 技能附带文件(path, content)
|
||||
- `daemon_token` — 守护进程级 token
|
||||
- `daemon_connection` / `daemon_pairing_session` — 早期设计(弃用中)
|
||||
|
||||
### Issue / 协作
|
||||
|
||||
- `issue` — 议题(status, priority, assignee_type+assignee_id, creator_type+creator_id, parent_issue_id, project_id, origin_type, origin_id, acceptance_criteria, due_date, position)
|
||||
- `issue_label` / `issue_to_label` — 标签
|
||||
- `issue_dependency` — 依赖关系(blocks / blocked_by / related)
|
||||
- `issue_subscriber` — 订阅者(reason: creator/assignee/commenter/mentioned/manual)
|
||||
- `issue_reaction` / `comment_reaction` — emoji 反应
|
||||
- `comment` — 评论(type: comment/status_change/progress_update/system, parent_id for threading)
|
||||
- `attachment` — 附件
|
||||
|
||||
### 任务执行
|
||||
|
||||
- `agent_task_queue` — 任务主表(status: queued/dispatched/running/completed/failed/cancelled, context, result, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id)
|
||||
- `task_message` — 每次执行的消息流水(seq, type, tool, input, output)
|
||||
- `task_usage` — Token 用量(input/output/cache_read/cache_write tokens)
|
||||
|
||||
### 对话
|
||||
|
||||
- `chat_session` — 聊天会话(unread_since, session_id, work_dir)
|
||||
- `chat_message` — 消息(role: user/assistant)
|
||||
|
||||
### 项目与组织
|
||||
|
||||
- `project` — 项目(status, priority, lead_type+lead_id, icon)
|
||||
- `pinned_item` — 侧边栏置顶(item_type, item_id, position)
|
||||
|
||||
### 自动化
|
||||
|
||||
- `autopilot` — 规则(assignee_id, execution_mode: create_issue/run_only, issue_title_template, concurrency_policy)
|
||||
- `autopilot_trigger` — 触发器(kind: schedule/webhook/api, cron_expression, timezone, next_run_at, webhook_token)
|
||||
- `autopilot_run` — 执行记录(status: pending/issue_created/running/skipped/completed/failed)
|
||||
|
||||
### 通知与审计
|
||||
|
||||
- `inbox_item` — 收件箱条目(recipient_type, type, severity, read, archived)
|
||||
- `activity_log` — 审计日志(actor_type: member/agent/system, action, details)
|
||||
- `runtime_usage` — 运行时按日聚合 token 用量(给计费/容量规划用)
|
||||
|
||||
---
|
||||
|
||||
## 尾声
|
||||
|
||||
Multica 的设计可以归结为一句话:**把"人在一个看板上协作"这件事,扩展到了"人 + AI agent 在同一个看板上协作"**。
|
||||
|
||||
所有功能都是围绕这个核心展开:
|
||||
- 为了让 agent 能像人一样被分配任务 → polymorphic actor(`assignee_type`)
|
||||
- 为了让 agent 能自己开工 → Autopilot
|
||||
- 为了让 agent 的工作方式能沉淀复用 → Skill
|
||||
- 为了让 agent 执行在用户控制的环境里 → Runtime + Daemon
|
||||
- 为了让人不被通知淹没 → Inbox + 自动订阅
|
||||
- 为了让一次会话有连续性 → Session Resumption
|
||||
|
||||
当你读到某段文案、某个 UI 模块、某张表时,请把它放回这个"人 + AI 协作"的坐标系里去理解它的位置。
|
||||
208
packages/core/analytics/index.ts
Normal file
208
packages/core/analytics/index.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Frontend analytics glue. Thin wrapper over posthog-js.
|
||||
//
|
||||
// The source-of-truth event catalog is `docs/analytics.md`. This module only
|
||||
// handles the two things the backend can't do itself: attribution capture on
|
||||
// first anonymous pageview, and person-identity merge on login. Every funnel
|
||||
// event (signup, workspace_created, runtime_registered, issue_executed,
|
||||
// invite_sent, invite_accepted) is emitted server-side — see
|
||||
// `server/internal/analytics`.
|
||||
//
|
||||
// Configuration comes from the backend's `/api/config` response (populated
|
||||
// from POSTHOG_API_KEY on the server), NOT from NEXT_PUBLIC_* envs. That
|
||||
// keeps self-hosted Docker images from leaking our project key — their
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
|
||||
// Per-value cap keeps a long utm_content from blowing the budget. We drop
|
||||
// the entire cookie if the JSON still exceeds the overall limit — partial
|
||||
// JSON is worse than no attribution because PostHog can't parse it.
|
||||
const SIGNUP_SOURCE_VALUE_MAX_LEN = 96;
|
||||
const SIGNUP_SOURCE_MAX_LEN = 512;
|
||||
const UTM_KEYS = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
] as const;
|
||||
|
||||
let initialized = false;
|
||||
// auth-initializer fetches /api/config and /api/me in parallel — on a
|
||||
// slow-config path, identify() can fire before initAnalytics(). Buffer the
|
||||
// most recent pending identify (only one matters, since it's per-session)
|
||||
// and flush it inside initAnalytics.
|
||||
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
|
||||
// Likewise pageviews: the initial "/" pageview is the anchor of the
|
||||
// acquisition funnel, and the Next.js router fires it on mount before the
|
||||
// config fetch resolves. We keep the first pending pageview so that step
|
||||
// doesn't silently drop.
|
||||
let pendingPageview: string | undefined | null = null;
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
key: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize posthog-js if a key is present. Safe to call multiple times;
|
||||
* subsequent calls with the same config are no-ops.
|
||||
*
|
||||
* Returns `true` when analytics is actually running; `false` when disabled
|
||||
* (no key, SSR, or already initialized with a conflicting key — which we
|
||||
* treat as "use the existing instance").
|
||||
*/
|
||||
export function initAnalytics(config: AnalyticsConfig | null | undefined): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!config?.key) return false;
|
||||
if (initialized) return true;
|
||||
|
||||
posthog.init(config.key, {
|
||||
api_host: config.host || "https://us.i.posthog.com",
|
||||
// person_profiles=identified_only keeps anonymous drive-by traffic off
|
||||
// the billed events until they actually identify, which aligns with how
|
||||
// our funnel is set up: signup is the first real funnel step.
|
||||
person_profiles: "identified_only",
|
||||
// Turn off every on-by-default auto-capture surface. Our funnel is
|
||||
// narrow and explicit (the events in docs/analytics.md + a manual
|
||||
// $pageview). Autocapture floods the Activity view with anonymous
|
||||
// "clicked button" / "clicked link" noise, burns the billed event
|
||||
// budget, and risks capturing user-typed content in input values.
|
||||
// Turn things back on deliberately if we ever want them.
|
||||
capture_pageview: false,
|
||||
autocapture: false,
|
||||
capture_heatmaps: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_exceptions: false,
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
initialized = true;
|
||||
|
||||
// Flush any identify() that arrived before init resolved.
|
||||
if (pendingIdentify) {
|
||||
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
|
||||
pendingIdentify = null;
|
||||
}
|
||||
// And any first pageview we captured while config was loading.
|
||||
if (pendingPageview !== null) {
|
||||
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
|
||||
pendingPageview = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the current anonymous session into the logged-in person. Must be
|
||||
* called exactly once per auth transition (login / session-resume). Pulling
|
||||
* attribution properties into person_properties on identify is how we keep
|
||||
* UTM / referrer on the user profile without re-emitting them per event.
|
||||
*
|
||||
* Calls before initAnalytics() are buffered — auth-initializer fetches
|
||||
* config and user in parallel, so identify can arrive first.
|
||||
*/
|
||||
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
|
||||
if (!initialized) {
|
||||
pendingIdentify = { userId, props: userProperties };
|
||||
return;
|
||||
}
|
||||
posthog.identify(userId, userProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the client-side identity on logout so the next login merges cleanly
|
||||
* and doesn't bleed the previous user's events into a new session.
|
||||
*/
|
||||
export function resetAnalytics(): void {
|
||||
pendingIdentify = null;
|
||||
pendingPageview = null;
|
||||
if (!initialized) return;
|
||||
posthog.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a page view. Call once per client-side navigation. We disable
|
||||
* posthog's automatic pageview tracking in init() so this module owns the
|
||||
* event shape — that makes it trivial to add properties (e.g. workspace
|
||||
* slug) without fighting the SDK.
|
||||
*
|
||||
* Calls before initAnalytics() buffer the most-recent path so the first
|
||||
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
|
||||
* pageviews overwrite the buffer; after init flushes, every navigation
|
||||
* captures synchronously as expected.
|
||||
*/
|
||||
export function capturePageview(path?: string): void {
|
||||
if (!initialized) {
|
||||
pendingPageview = path ?? "";
|
||||
return;
|
||||
}
|
||||
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* On the very first anonymous pageview in a browser session, read UTM +
|
||||
* referrer and stash them in a cookie that the backend reads during signup.
|
||||
*
|
||||
* Never use raw `document.referrer` as attribution — it can leak OAuth
|
||||
* callback URLs with `code` / `state` in the query string. We keep only the
|
||||
* referrer's origin (scheme + host), which is what a funnel actually needs.
|
||||
*
|
||||
* This cookie is what `signup_source` in the backend's signup event reads
|
||||
* from; both fields are intentionally opaque JSON so the schema can evolve
|
||||
* without a backend deploy.
|
||||
*/
|
||||
export function captureSignupSource(): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
if (readCookie(SIGNUP_SOURCE_COOKIE)) return;
|
||||
|
||||
const source: Record<string, string> = {};
|
||||
const cap = (v: string) =>
|
||||
v.length > SIGNUP_SOURCE_VALUE_MAX_LEN ? v.slice(0, SIGNUP_SOURCE_VALUE_MAX_LEN) : v;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const key of UTM_KEYS) {
|
||||
const v = params.get(key);
|
||||
if (v) source[key] = cap(v);
|
||||
}
|
||||
} catch {
|
||||
// URL APIs unavailable — skip silently.
|
||||
}
|
||||
|
||||
const refOrigin = safeReferrerOrigin(document.referrer);
|
||||
if (refOrigin) source.referrer_origin = cap(refOrigin);
|
||||
|
||||
if (Object.keys(source).length === 0) return;
|
||||
|
||||
const payload = JSON.stringify(source);
|
||||
// Drop rather than mid-JSON truncate — a half-string would fail to parse
|
||||
// on the backend and the attribution would be worse than missing.
|
||||
if (payload.length > SIGNUP_SOURCE_MAX_LEN) return;
|
||||
|
||||
// 30-day expiry covers the typical signup consideration window. Lax is
|
||||
// the right default — the cookie is only consumed by same-origin auth.
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(payload)}; path=/; max-age=${maxAge}; samesite=lax`;
|
||||
}
|
||||
|
||||
function safeReferrerOrigin(referrer: string): string {
|
||||
if (!referrer) return "";
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
if (url.origin === window.location.origin) return "";
|
||||
return url.origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const parts = document.cookie ? document.cookie.split("; ") : [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
@@ -78,6 +79,52 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
// The client sends both branches of sub-issues and an unbound welcome
|
||||
// issue template (title + description, no `agent_id`). The SERVER picks
|
||||
// the branch by inspecting the workspace's agent list inside the
|
||||
// import transaction. This removes the client as a trusted decider —
|
||||
// even if the client has a stale agent cache or lies, the server uses
|
||||
// the DB as source of truth.
|
||||
|
||||
export interface ImportStarterIssuePayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
||||
* as assignee when true. No member_id is threaded through. */
|
||||
assign_to_self: boolean;
|
||||
}
|
||||
|
||||
export interface ImportStarterWelcomeIssueTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Defaults to "high" on server when empty. */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface ImportStarterContentPayload {
|
||||
workspace_id: string;
|
||||
project: { title: string; description: string; icon: string };
|
||||
/** Always sent. Server creates it only when an agent exists in the
|
||||
* workspace; ignored otherwise. Agent id is picked by the server. */
|
||||
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
||||
/** Used when the workspace has at least one agent. */
|
||||
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
||||
/** Used when the workspace has zero agents. */
|
||||
self_serve_sub_issues: ImportStarterIssuePayload[];
|
||||
}
|
||||
|
||||
export interface ImportStarterContentResponse {
|
||||
user: User;
|
||||
project_id: string;
|
||||
/** Non-null when server took the agent-guided branch. */
|
||||
welcome_issue_id: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
@@ -219,6 +266,54 @@ export class ApiClient {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
async markOnboardingComplete(): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", { method: "POST" });
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async patchOnboarding(payload: {
|
||||
questionnaire?: Record<string, unknown>;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the Getting Started project + optional welcome issue + sub-issues
|
||||
* in a single server-side transaction. Gated by an atomic
|
||||
* starter_content_state: NULL → 'imported' claim — a second call returns
|
||||
* 409 (already decided) and creates nothing new.
|
||||
*
|
||||
* The content templates live in TypeScript (see
|
||||
* @multica/views/onboarding/utils/starter-content-templates) and are
|
||||
* rendered from the user's questionnaire answers before being sent.
|
||||
*/
|
||||
async importStarterContent(
|
||||
payload: ImportStarterContentPayload,
|
||||
): Promise<ImportStarterContentResponse> {
|
||||
return this.fetch("/api/me/starter-content/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async dismissStarterContent(): Promise<User> {
|
||||
return this.fetch("/api/me/starter-content/dismiss", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||
return this.fetch("/api/me", {
|
||||
method: "PATCH",
|
||||
@@ -237,6 +332,7 @@ export class ApiClient {
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
@@ -470,6 +566,17 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
|
||||
}
|
||||
|
||||
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
|
||||
}
|
||||
|
||||
async getListModelsResult(
|
||||
runtimeId: string,
|
||||
requestId: string,
|
||||
): Promise<RuntimeModelListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
@@ -530,7 +637,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{ cdn_domain: string }> {
|
||||
async getConfig(): Promise<{
|
||||
cdn_domain: string;
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
}> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
ImportStarterContentResponse,
|
||||
ImportStarterIssuePayload,
|
||||
ImportStarterWelcomeIssueTemplate,
|
||||
} from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { createAuthStore } from "./store";
|
||||
export type { AuthStoreOptions, AuthState } from "./store";
|
||||
export { sanitizeNextUrl } from "./utils";
|
||||
|
||||
import type { createAuthStore as CreateAuthStoreFn } from "./store";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, StorageAdapter } from "../types";
|
||||
import { identify as identifyAnalytics, resetAnalytics } from "../analytics";
|
||||
import { ApiError, type ApiClient } from "../api/client";
|
||||
import { setCurrentWorkspace } from "../platform/workspace-storage";
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface AuthState {
|
||||
loginWithToken: (token: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
refreshMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createAuthStore(options: AuthStoreOptions) {
|
||||
@@ -84,6 +86,7 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
@@ -95,6 +98,7 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
@@ -104,6 +108,7 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
const user = await api.getMe();
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user, isLoading: false });
|
||||
return user;
|
||||
},
|
||||
@@ -116,6 +121,7 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
storage.removeItem("multica_token");
|
||||
api.setToken(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
resetAnalytics();
|
||||
onLogout?.();
|
||||
set({ user: null });
|
||||
},
|
||||
@@ -123,5 +129,10 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
|
||||
refreshMe: async () => {
|
||||
const user = await api.getMe();
|
||||
set({ user });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
45
packages/core/auth/utils.test.ts
Normal file
45
packages/core/auth/utils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeNextUrl } from "./utils";
|
||||
|
||||
describe("sanitizeNextUrl", () => {
|
||||
it("accepts single-slash relative paths", () => {
|
||||
expect(sanitizeNextUrl("/issues")).toBe("/issues");
|
||||
expect(sanitizeNextUrl("/invite/123")).toBe("/invite/123");
|
||||
expect(sanitizeNextUrl("/issues?tab=assigned#top")).toBe(
|
||||
"/issues?tab=assigned#top",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for null or empty input", () => {
|
||||
expect(sanitizeNextUrl(null)).toBeNull();
|
||||
expect(sanitizeNextUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects absolute URLs", () => {
|
||||
expect(sanitizeNextUrl("https://evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("http://evil.example/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects javascript: and other non-http schemes", () => {
|
||||
// Caught by the leading-slash rule, but named here so future edits
|
||||
// to the regex don't silently drop protection against this vector.
|
||||
expect(sanitizeNextUrl("javascript:alert(1)")).toBeNull();
|
||||
expect(sanitizeNextUrl("data:text/html,<script>")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs", () => {
|
||||
expect(sanitizeNextUrl("//evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("//evil.example/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths containing backslashes", () => {
|
||||
expect(sanitizeNextUrl("/\\evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("\\\\evil.example")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths containing control characters", () => {
|
||||
expect(sanitizeNextUrl("/safe\u0000bad")).toBeNull();
|
||||
expect(sanitizeNextUrl("/safe\tbad")).toBeNull();
|
||||
expect(sanitizeNextUrl("/safe\r\nbad")).toBeNull();
|
||||
});
|
||||
});
|
||||
20
packages/core/auth/utils.ts
Normal file
20
packages/core/auth/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Validate a post-login redirect URL and return it only if safe to follow.
|
||||
*
|
||||
* Only single-slash relative paths (e.g. `/invite/abc`) are accepted. Returns
|
||||
* `null` for unsafe or empty input — call sites decide the fallback so this
|
||||
* helper never overloads a specific path with "user did not pass next".
|
||||
*
|
||||
* Rejects:
|
||||
* - `null` / empty string
|
||||
* - absolute URLs (`https://evil.com`, `javascript:alert(1)`, …)
|
||||
* - protocol-relative URLs (`//evil.com`)
|
||||
* - paths containing backslashes (Windows-style or `/\\host`)
|
||||
* - paths containing ASCII control characters (`\x00`–`\x1f`)
|
||||
*/
|
||||
export function sanitizeNextUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
||||
if (/[\x00-\x1f\\]/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
74
packages/core/inbox/ws-updaters.test.ts
Normal file
74
packages/core/inbox/ws-updaters.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
|
||||
import { inboxKeys } from "./queries";
|
||||
import type { InboxItem } from "../types";
|
||||
|
||||
const wsId = "ws-1";
|
||||
|
||||
function makeItem(
|
||||
id: string,
|
||||
issueId: string | null,
|
||||
overrides: Partial<InboxItem> = {},
|
||||
): InboxItem {
|
||||
return {
|
||||
id,
|
||||
workspace_id: wsId,
|
||||
recipient_type: "member",
|
||||
recipient_id: "user-1",
|
||||
actor_type: null,
|
||||
actor_id: null,
|
||||
type: "mentioned",
|
||||
severity: "info",
|
||||
issue_id: issueId,
|
||||
title: `item ${id}`,
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("onInboxIssueDeleted", () => {
|
||||
it("removes all inbox items referencing the deleted issue", () => {
|
||||
const qc = new QueryClient();
|
||||
const items = [
|
||||
makeItem("i1", "issue-a"),
|
||||
makeItem("i2", "issue-a"),
|
||||
makeItem("i3", "issue-b"),
|
||||
makeItem("i4", null),
|
||||
];
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
|
||||
|
||||
onInboxIssueDeleted(qc, wsId, "issue-a");
|
||||
|
||||
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
expect(after?.map((i) => i.id)).toEqual(["i3", "i4"]);
|
||||
});
|
||||
|
||||
it("is a no-op when the inbox cache is empty", () => {
|
||||
const qc = new QueryClient();
|
||||
expect(() => onInboxIssueDeleted(qc, wsId, "issue-a")).not.toThrow();
|
||||
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInboxIssueStatusChanged", () => {
|
||||
it("updates issue_status only for items referencing the issue", () => {
|
||||
const qc = new QueryClient();
|
||||
const items = [
|
||||
makeItem("i1", "issue-a", { issue_status: "todo" }),
|
||||
makeItem("i2", "issue-b", { issue_status: "todo" }),
|
||||
];
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
|
||||
|
||||
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
|
||||
|
||||
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
expect(after?.find((i) => i.id === "i1")?.issue_status).toBe("done");
|
||||
expect(after?.find((i) => i.id === "i2")?.issue_status).toBe("todo");
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,19 @@ export function onInboxIssueStatusChanged(
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
|
||||
// is deleted, all inbox items that referenced it are gone server-side, so drop
|
||||
// them from the cache too.
|
||||
export function onInboxIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.filter((i) => i.issue_id !== issueId),
|
||||
);
|
||||
}
|
||||
|
||||
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
}
|
||||
|
||||
100
packages/core/issues/cache-helpers.ts
Normal file
100
packages/core/issues/cache-helpers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
Issue,
|
||||
IssueStatus,
|
||||
IssueStatusBucket,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
import { PAGINATED_STATUSES } from "./queries";
|
||||
|
||||
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
|
||||
|
||||
export function getBucket(
|
||||
resp: ListIssuesCache,
|
||||
status: IssueStatus,
|
||||
): IssueStatusBucket {
|
||||
return resp.byStatus[status] ?? EMPTY_BUCKET;
|
||||
}
|
||||
|
||||
export function setBucket(
|
||||
resp: ListIssuesCache,
|
||||
status: IssueStatus,
|
||||
bucket: IssueStatusBucket,
|
||||
): ListIssuesCache {
|
||||
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
|
||||
}
|
||||
|
||||
/** Locate which status bucket holds `id`, if any. */
|
||||
export function findIssueLocation(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
): { status: IssueStatus; issue: Issue } | null {
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = resp.byStatus[status];
|
||||
const found = bucket?.issues.find((i) => i.id === id);
|
||||
if (found) return { status, issue: found };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Add an issue to its status bucket (no-op if already present). */
|
||||
export function addIssueToBuckets(
|
||||
resp: ListIssuesCache,
|
||||
issue: Issue,
|
||||
): ListIssuesCache {
|
||||
const bucket = getBucket(resp, issue.status);
|
||||
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
|
||||
return setBucket(resp, issue.status, {
|
||||
issues: [...bucket.issues, issue],
|
||||
total: bucket.total + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an issue from whichever bucket contains it. */
|
||||
export function removeIssueFromBuckets(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
): ListIssuesCache {
|
||||
const loc = findIssueLocation(resp, id);
|
||||
if (!loc) return resp;
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
return setBucket(resp, loc.status, {
|
||||
issues: bucket.issues.filter((i) => i.id !== id),
|
||||
total: Math.max(0, bucket.total - 1),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
|
||||
* current bucket, the issue moves to the new bucket and both buckets' totals
|
||||
* are adjusted.
|
||||
*/
|
||||
export function patchIssueInBuckets(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
patch: Partial<Issue>,
|
||||
): ListIssuesCache {
|
||||
const loc = findIssueLocation(resp, id);
|
||||
if (!loc) return resp;
|
||||
const merged: Issue = { ...loc.issue, ...patch };
|
||||
const nextStatus = patch.status ?? loc.status;
|
||||
|
||||
if (nextStatus === loc.status) {
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
return setBucket(resp, loc.status, {
|
||||
...bucket,
|
||||
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
||||
});
|
||||
}
|
||||
|
||||
const fromBucket = getBucket(resp, loc.status);
|
||||
const toBucket = getBucket(resp, nextStatus);
|
||||
let next = setBucket(resp, loc.status, {
|
||||
issues: fromBucket.issues.filter((i) => i.id !== id),
|
||||
total: Math.max(0, fromBucket.total - 1),
|
||||
});
|
||||
next = setBucket(next, nextStatus, {
|
||||
issues: [...toBucket.issues, merged],
|
||||
total: toBucket.total + 1,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
getBucket,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
setBucket,
|
||||
} from "./cache-helpers";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { Issue, IssueReaction } from "../types";
|
||||
import type { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
|
||||
|
||||
@@ -29,10 +41,18 @@ export type ToggleIssueReactionVars = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Done issue pagination
|
||||
// Per-status pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
|
||||
/**
|
||||
* Paginate one status column into the cache. Works for both the workspace
|
||||
* issue list and per-scope My Issues lists (pass `myIssues` to target the
|
||||
* latter).
|
||||
*/
|
||||
export function useLoadMoreByStatus(
|
||||
status: IssueStatus,
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -40,39 +60,38 @@ export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssu
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
|
||||
const doneLoaded = cache
|
||||
? cache.issues.filter((i) => i.status === "done").length
|
||||
: 0;
|
||||
const doneTotal = cache?.doneTotal ?? 0;
|
||||
const hasMore = doneLoaded < doneTotal;
|
||||
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
|
||||
const bucket = cache?.byStatus[status];
|
||||
const loaded = bucket?.issues.length ?? 0;
|
||||
const total = bucket?.total ?? 0;
|
||||
const hasMore = loaded < total;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: doneLoaded,
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
...myIssues?.filter,
|
||||
});
|
||||
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
|
||||
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
const existingIds = new Set(old.issues.map((i) => i.id));
|
||||
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, ...newIssues],
|
||||
doneTotal: res.total,
|
||||
};
|
||||
const prev = getBucket(old, status);
|
||||
const existingIds = new Set(prev.issues.map((i) => i.id));
|
||||
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return setBucket(old, status, {
|
||||
issues: [...prev.issues, ...appended],
|
||||
total: res.total,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
|
||||
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, doneTotal };
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -85,15 +104,8 @@ export function useCreateIssue() {
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
||||
onSuccess: (newIssue) => {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old && !old.issues.some((i) => i.id === newIssue.id)
|
||||
? {
|
||||
...old,
|
||||
issues: [...old.issues, newIssue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
|
||||
}
|
||||
: old,
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? addIssueToBuckets(old, newIssue) : old,
|
||||
);
|
||||
// Surface the just-created issue in cmd+k's Recent list without
|
||||
// requiring the user to open it first.
|
||||
@@ -122,7 +134,7 @@ export function useUpdateIssue() {
|
||||
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||
// before the optimistic update lands.
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
@@ -130,21 +142,14 @@ export function useUpdateIssue() {
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
prevDetail?.parent_issue_id ??
|
||||
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
|
||||
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === id ? { ...i, ...data } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, id, data) : old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, ...data } : old,
|
||||
@@ -198,18 +203,11 @@ export function useDeleteIssue() {
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = prevList?.issues.find((i) => i.id === id);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const d = old.issues.find((i) => i.id === id);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== id),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, id) : old,
|
||||
);
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList, parentIssueId: deleted?.parent_issue_id };
|
||||
},
|
||||
@@ -239,17 +237,13 @@ export function useBatchUpdateIssues() {
|
||||
}) => api.batchUpdateIssues(ids, updates),
|
||||
onMutate: async ({ ids, updates }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
ids.includes(i.id) ? { ...i, ...updates } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
return next;
|
||||
});
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
@@ -268,24 +262,19 @@ export function useBatchDeleteIssues() {
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const idSet = new Set(ids);
|
||||
const parentIssueIds = new Set(
|
||||
prevList?.issues
|
||||
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
|
||||
.map((i) => i.parent_issue_id!) ?? [],
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const parentIssueIds = new Set<string>();
|
||||
if (prevList) {
|
||||
for (const id of ids) {
|
||||
const loc = findIssueLocation(prevList, id);
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const doneDeleted = old.issues.filter(
|
||||
(i) => idSet.has(i.id) && i.status === "done",
|
||||
).length;
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => !idSet.has(i.id)),
|
||||
total: old.total - ids.length,
|
||||
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
|
||||
};
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
});
|
||||
return { prevList, parentIssueIds };
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { ListIssuesParams } from "../types";
|
||||
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
|
||||
import { BOARD_STATUSES } from "./config";
|
||||
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
@@ -23,33 +24,55 @@ export const issueKeys = {
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
};
|
||||
|
||||
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
|
||||
export type MyIssuesFilter = Pick<
|
||||
ListIssuesParams,
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
>;
|
||||
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
/** Page size per status column. */
|
||||
export const ISSUE_PAGE_SIZE = 50;
|
||||
|
||||
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
|
||||
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
|
||||
|
||||
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
|
||||
export function flattenIssueBuckets(data: ListIssuesCache) {
|
||||
const out = [];
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = data.byStatus[status];
|
||||
if (bucket) out.push(...bucket.issues);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
||||
const responses = await Promise.all(
|
||||
PAGINATED_STATUSES.map((status) =>
|
||||
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
|
||||
),
|
||||
);
|
||||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||||
PAGINATED_STATUSES.forEach((status, i) => {
|
||||
const res = responses[i]!;
|
||||
byStatus[status] = { issues: res.issues, total: res.total };
|
||||
});
|
||||
return { byStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
|
||||
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
|
||||
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
|
||||
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
|
||||
* by status, each with `{ issues, total }`), and `select` flattens it to
|
||||
* `Issue[]` for consumers. Mutations and ws-updaters must use
|
||||
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
|
||||
*
|
||||
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
|
||||
* to paginate additional done items into the cache.
|
||||
* Fetches the first page of each paginated status in parallel. Use
|
||||
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
|
||||
*/
|
||||
export function issueListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.list(wsId),
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true }),
|
||||
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
queryFn: () => fetchFirstPages(),
|
||||
select: flattenIssueBuckets,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,23 +87,8 @@ export function myIssueListOptions(
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.myList(wsId, scope, filter),
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true, ...filter }),
|
||||
api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
queryFn: () => fetchFirstPages(filter),
|
||||
select: flattenIssueBuckets,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface CardProperties {
|
||||
description: boolean;
|
||||
assignee: boolean;
|
||||
dueDate: boolean;
|
||||
project: boolean;
|
||||
childProgress: boolean;
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
@@ -38,6 +40,8 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "assignee", label: "Assignee" },
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
{ key: "project", label: "Project" },
|
||||
{ key: "childProgress", label: "Sub-issue progress" },
|
||||
];
|
||||
|
||||
export interface IssueViewState {
|
||||
@@ -86,6 +90,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
description: true,
|
||||
assignee: true,
|
||||
dueDate: true,
|
||||
project: true,
|
||||
childProgress: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import type { Issue } from "../types";
|
||||
import type { ListIssuesResponse } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
export function onIssueCreated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Issue,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, issue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? addIssueToBuckets(old, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
@@ -32,36 +32,20 @@ export function onIssueUpdated(
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
// shown on the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const oldParentId =
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
// The NEW parent comes from the WS payload when parent_issue_id changed
|
||||
const newParentId = issue.parent_issue_id ?? null;
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const prev = old.issues.find((i) => i.id === issue.id);
|
||||
const wasDone = prev?.status === "done";
|
||||
const isDone = issue.status === "done";
|
||||
// Only adjust doneTotal when status field is present and actually changed
|
||||
let doneDelta = 0;
|
||||
if (issue.status !== undefined) {
|
||||
if (!wasDone && isDone) doneDelta = 1;
|
||||
else if (wasDone && !isDone) doneDelta = -1;
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === issue.id ? { ...i, ...issue } : i,
|
||||
),
|
||||
doneTotal: (old.doneTotal ?? 0) + doneDelta,
|
||||
};
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, issue.id, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
@@ -94,19 +78,12 @@ export function onIssueDeleted(
|
||||
issueId: string,
|
||||
) {
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = listData?.issues.find((i) => i.id === issueId);
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const del = old.issues.find((i) => i.id === issueId);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== issueId),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
|
||||
14
packages/core/onboarding/index.ts
Normal file
14
packages/core/onboarding/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
OnboardingStep,
|
||||
QuestionnaireAnswers,
|
||||
TeamSize,
|
||||
Role,
|
||||
UseCase,
|
||||
} from "./types";
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
|
||||
117
packages/core/onboarding/recommend-template.test.ts
Normal file
117
packages/core/onboarding/recommend-template.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { recommendTemplate } from "./recommend-template";
|
||||
import type { Role, UseCase } from "./types";
|
||||
|
||||
const ALL_USE_CASES: UseCase[] = [
|
||||
"coding",
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate", () => {
|
||||
describe("identity fallbacks — role alone decides", () => {
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=other (use_case=%s) → assistant",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "other", use_case })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=founder (use_case=%s) → assistant",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "founder", use_case })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=writer (use_case=%s) → writing",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe(
|
||||
"writing",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("developer × use_case tiebreaker", () => {
|
||||
it("developer × planning → planning", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: "planning" }),
|
||||
).toBe("planning");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"coding",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("developer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "developer", use_case })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
|
||||
it("developer × null use_case → coding (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: null }),
|
||||
).toBe("coding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("product_lead × use_case tiebreaker", () => {
|
||||
it("product_lead × coding → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: "coding" }),
|
||||
).toBe("coding");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("product_lead × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
|
||||
it("product_lead × null use_case → planning (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: null }),
|
||||
).toBe("planning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unanswered questionnaire", () => {
|
||||
it("null role → assistant regardless of use_case", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exhaustive role coverage", () => {
|
||||
const roles: Role[] = [
|
||||
"developer",
|
||||
"product_lead",
|
||||
"writer",
|
||||
"founder",
|
||||
"other",
|
||||
];
|
||||
it.each(roles)("role=%s returns a valid template id", (role) => {
|
||||
const result = recommendTemplate({ role, use_case: null });
|
||||
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
packages/core/onboarding/recommend-template.ts
Normal file
41
packages/core/onboarding/recommend-template.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { QuestionnaireAnswers } from "./types";
|
||||
|
||||
/**
|
||||
* Identifier for the four agent templates offered during onboarding Step 4.
|
||||
* Keep in sync with the template registry inside StepAgent in
|
||||
* `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
*/
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
/**
|
||||
* Pick a recommended agent template for a user based on their
|
||||
* questionnaire answers. Role is treated as the primary signal (who the
|
||||
* user is); use_case is only a tiebreaker for roles that legitimately
|
||||
* split between templates (developer / product_lead).
|
||||
*
|
||||
* `role = other` and `role = founder` both fall back to the generic
|
||||
* Assistant: "other" means the user declined to claim a role, and
|
||||
* "founder" means they wear every hat, so a single specialized agent is
|
||||
* a poor default.
|
||||
*
|
||||
* Pure / deterministic — safe to call on every render.
|
||||
*/
|
||||
export function recommendTemplate(
|
||||
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
|
||||
): AgentTemplateId {
|
||||
const { role, use_case } = answers;
|
||||
|
||||
if (role === "other" || role === "founder") return "assistant";
|
||||
if (role === "writer") return "writing";
|
||||
|
||||
if (role === "developer") {
|
||||
return use_case === "planning" ? "planning" : "coding";
|
||||
}
|
||||
|
||||
if (role === "product_lead") {
|
||||
return use_case === "coding" ? "coding" : "planning";
|
||||
}
|
||||
|
||||
// Unknown / null role — user hasn't answered Q2 yet.
|
||||
return "assistant";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user