mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
30 Commits
fix/custom
...
fix/codex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815a84bb55 | ||
|
|
b5c6a9b8f0 | ||
|
|
7395b51aee | ||
|
|
ce52374d5d | ||
|
|
441554a520 | ||
|
|
93cf95f799 | ||
|
|
fe358feff0 | ||
|
|
a71aa6c544 | ||
|
|
1b30ad0ba6 | ||
|
|
b30fd98605 | ||
|
|
75d12c26c5 | ||
|
|
9b94914bc8 | ||
|
|
59ace95a1e | ||
|
|
3c46c5baa3 | ||
|
|
c38af55a8e | ||
|
|
df920e8641 | ||
|
|
0427fd8cc7 | ||
|
|
d930bcaa18 | ||
|
|
5a44c255fe | ||
|
|
8a55473bb8 | ||
|
|
ce94c80f5a | ||
|
|
176f1bfdbb | ||
|
|
a81a6b1578 | ||
|
|
0e8a7b1734 | ||
|
|
621526b38d | ||
|
|
244434bcfa | ||
|
|
970b7fd1d3 | ||
|
|
f76e3fb8f4 | ||
|
|
b6d30c0e00 | ||
|
|
129a8b927f |
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -3,6 +3,17 @@ description: Report a bug — something that's broken, crashes, or behaves incor
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
11
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -3,6 +3,17 @@ description: Suggest a new feature or improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
194
CONTRIBUTING.md
194
CONTRIBUTING.md
@@ -9,6 +9,7 @@ It covers:
|
||||
- isolated worktree development
|
||||
- the shared PostgreSQL model
|
||||
- testing and verification
|
||||
- full-stack isolated testing (backend + frontend + daemon from source)
|
||||
- troubleshooting and destructive reset options
|
||||
|
||||
## Development Model
|
||||
@@ -308,6 +309,199 @@ make daemon
|
||||
The daemon authenticates using the CLI's stored token (`multica login`).
|
||||
It registers runtimes for all watched workspaces from the CLI config.
|
||||
|
||||
## Full-Stack Isolated Testing
|
||||
|
||||
This section covers running the complete stack (backend, frontend, daemon) from
|
||||
source in a fully isolated environment. Useful for testing end-to-end changes
|
||||
that span multiple components, or for automated CI/AI workflows that need zero
|
||||
human intervention.
|
||||
|
||||
### Why Not Just `make daemon`?
|
||||
|
||||
`make daemon` uses the system-installed CLI's stored token and connects to
|
||||
whatever server is configured in `~/.multica/config.json`. That's fine for
|
||||
day-to-day development against a shared server, but for fully isolated testing
|
||||
you need:
|
||||
|
||||
- a local backend and frontend (from source)
|
||||
- a local daemon (from source) with its own profile
|
||||
- automated authentication (no browser login)
|
||||
- no interference with your production CLI config
|
||||
|
||||
### Dynamic Profile Naming
|
||||
|
||||
Each worktree must use a unique daemon profile to avoid collisions when
|
||||
multiple features run in parallel.
|
||||
|
||||
The profile name is derived from the worktree directory using the same
|
||||
slug + hash pattern as `scripts/init-worktree-env.sh`:
|
||||
|
||||
```bash
|
||||
WORKTREE_DIR="$(basename "$PWD")"
|
||||
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
||||
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
|
||||
OFFSET=$((HASH % 1000))
|
||||
PROFILE="dev-${SLUG}-${OFFSET}"
|
||||
```
|
||||
|
||||
Example: worktree at `../multica-feat-auth` produces profile
|
||||
`dev-multica_feat_auth-347`, matching that worktree's port and database
|
||||
allocation.
|
||||
|
||||
### Start the Isolated Environment
|
||||
|
||||
Run all steps from the worktree root (where the Makefile is).
|
||||
|
||||
#### 1. Start backend, frontend, and database
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Wait for the backend to be healthy:
|
||||
|
||||
```bash
|
||||
PORT=$(grep '^PORT=' .env.worktree 2>/dev/null || grep '^PORT=' .env | head -1 | cut -d= -f2)
|
||||
PORT=${PORT:-8080}
|
||||
SERVER="http://localhost:${PORT}"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "$SERVER/health" > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "dev@localhost"}'
|
||||
|
||||
JWT=$(curl -s -X POST "$SERVER/auth/verify-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "dev@localhost", "code": "888888"}' | jq -r '.token')
|
||||
|
||||
PAT=$(curl -s -X POST "$SERVER/api/tokens" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "auto-dev", "expires_in_days": 365}' | jq -r '.token')
|
||||
```
|
||||
|
||||
#### 3. Create a workspace
|
||||
|
||||
```bash
|
||||
WS=$(curl -s -X POST "$SERVER/api/workspaces" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Dev", "slug": "dev"}' | jq -r '.id')
|
||||
```
|
||||
|
||||
#### 4. Compute profile name and write CLI config
|
||||
|
||||
```bash
|
||||
# Compute profile (see Dynamic Profile Naming above)
|
||||
WORKTREE_DIR="$(basename "$PWD")"
|
||||
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
||||
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
|
||||
OFFSET=$((HASH % 1000))
|
||||
PROFILE="dev-${SLUG}-${OFFSET}"
|
||||
|
||||
FRONTEND_PORT=$(grep '^FRONTEND_PORT=' .env.worktree 2>/dev/null || grep '^FRONTEND_PORT=' .env | head -1 | cut -d= -f2)
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-3000}
|
||||
|
||||
CONFIG_DIR="$HOME/.multica/profiles/$PROFILE"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
cat > "$CONFIG_DIR/config.json" << EOF
|
||||
{
|
||||
"server_url": "$SERVER",
|
||||
"app_url": "http://localhost:${FRONTEND_PORT}",
|
||||
"token": "$PAT",
|
||||
"workspace_id": "$WS",
|
||||
"watched_workspaces": [{"id": "$WS", "name": "Dev"}]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 5. Start the daemon from source
|
||||
|
||||
```bash
|
||||
make cli ARGS="daemon start --profile $PROFILE"
|
||||
```
|
||||
|
||||
The daemon runs from the current worktree's Go source, connecting to the
|
||||
local backend. Agent-executed `multica` commands automatically use the same
|
||||
binary (the daemon prepends its own directory to `PATH`).
|
||||
|
||||
### Stop the Isolated Environment
|
||||
|
||||
```bash
|
||||
# Compute profile (same formula)
|
||||
PROFILE="dev-$(printf '%s' "$(basename "$PWD")" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')-$(( $(printf '%s' "$PWD" | cksum | awk '{print $1}') % 1000 ))"
|
||||
|
||||
# 1. Stop daemon
|
||||
make cli ARGS="daemon stop --profile $PROFILE"
|
||||
|
||||
# 2. Stop backend + frontend
|
||||
make stop # main checkout
|
||||
make stop-worktree # worktree checkout
|
||||
|
||||
# 3. (Optional) Stop shared PostgreSQL
|
||||
make db-down
|
||||
|
||||
# 4. (Optional) Clean build artifacts
|
||||
make clean
|
||||
|
||||
# 5. (Optional) Remove profile config
|
||||
rm -rf "$HOME/.multica/profiles/$PROFILE"
|
||||
```
|
||||
|
||||
### Desktop App Local Testing
|
||||
|
||||
To test the Electron desktop app against a local backend:
|
||||
|
||||
```bash
|
||||
# After backend is running (make dev)
|
||||
pnpm dev:desktop
|
||||
```
|
||||
|
||||
This automatically:
|
||||
|
||||
1. Compiles the `multica` CLI from `server/cmd/multica` into
|
||||
`apps/desktop/resources/bin/multica`
|
||||
2. Creates an isolated profile named `desktop-localhost-<PORT>`
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:<backend-port>
|
||||
VITE_WS_URL=ws://localhost:<backend-port>/ws
|
||||
```
|
||||
|
||||
### Isolation Guarantee
|
||||
|
||||
Nothing in this flow touches the system-installed `multica` or the default
|
||||
`~/.multica/config.json`:
|
||||
|
||||
| Resource | System / Production | Local Dev (per-worktree) |
|
||||
|---|---|---|
|
||||
| Config | `~/.multica/config.json` | `~/.multica/profiles/dev-<slug>-<hash>/config.json` |
|
||||
| Daemon PID | `~/.multica/daemon.pid` | `~/.multica/profiles/dev-<slug>-<hash>/daemon.pid` |
|
||||
| Health port | `19514` | `19514 + 1 + (name_hash % 1000)` |
|
||||
| Workspaces dir | `~/multica_workspaces/` | `~/multica_workspaces_dev-<slug>-<hash>/` |
|
||||
| Database | remote / production | local Docker: `multica_<slug>_<hash>` |
|
||||
| Desktop profile | `desktop-api.multica.ai` | `desktop-localhost-<port>` |
|
||||
|
||||
Multiple worktrees can run simultaneously without conflict.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Env File
|
||||
|
||||
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
2
Makefile
2
Makefile
@@ -104,6 +104,8 @@ start:
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
@echo "Running migrations..."
|
||||
cd server && go run ./cmd/migrate up
|
||||
@echo "Starting backend and frontend..."
|
||||
@trap 'kill 0' EXIT; \
|
||||
(cd server && go run ./cmd/server) & \
|
||||
|
||||
BIN
apps/desktop/build/icon.icns
Normal file
BIN
apps/desktop/build/icon.icns
Normal file
Binary file not shown.
BIN
apps/desktop/build/icon.ico
Normal file
BIN
apps/desktop/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
apps/desktop/build/icon.png
Normal file
BIN
apps/desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -29,6 +29,7 @@
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -38,6 +39,8 @@
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
@@ -45,6 +48,7 @@
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 735 KiB |
@@ -5,13 +5,21 @@
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
|
||||
// and copied into resources/bin/), then invokes electron-builder with
|
||||
// `-c.extraMetadata.version=<derived>` so the override applies at build
|
||||
// time without mutating the tracked package.json.
|
||||
// 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.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
// artifacts from a prior partial build) ships an app with missing
|
||||
// renderer code and white-screens on launch.
|
||||
//
|
||||
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
|
||||
// unchanged (e.g. `--mac --arm64`).
|
||||
// unchanged (e.g. `--mac --arm64`). For an unsigned local smoke-test
|
||||
// build, set `CSC_IDENTITY_AUTO_DISCOVERY=false` so electron-builder falls
|
||||
// back to an ad-hoc signature instead of requiring a Developer ID cert.
|
||||
//
|
||||
// The `normalizeGitVersion` helper is exported so tests can cover the
|
||||
// version-derivation logic without shelling out.
|
||||
@@ -64,7 +72,26 @@ function main() {
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
// Step 2: 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.
|
||||
const viteResult = spawnSync("electron-vite", ["build"], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
if (viteResult.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-vite:",
|
||||
viteResult.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (viteResult.status !== 0) {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -74,12 +101,12 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: assemble electron-builder args.
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = process.argv.slice(2);
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// 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
|
||||
@@ -95,7 +122,7 @@ function main() {
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// 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, {
|
||||
|
||||
@@ -598,11 +598,12 @@ function profileArgs(active: ActiveProfile): string[] {
|
||||
|
||||
// Env passed to every CLI child so the daemon process knows it was spawned
|
||||
// by the Desktop app. The server uses this to mark runtimes as managed and
|
||||
// hide CLI self-update UI.
|
||||
const DESKTOP_SPAWN_ENV = {
|
||||
...process.env,
|
||||
MULTICA_LAUNCHED_BY: "desktop",
|
||||
};
|
||||
// hide CLI self-update UI. Computed lazily so it picks up the PATH fix
|
||||
// applied by fix-path in main/index.ts — as a top-level const it would
|
||||
// snapshot process.env at import time, before that block runs.
|
||||
function desktopSpawnEnv(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, MULTICA_LAUNCHED_BY: "desktop" };
|
||||
}
|
||||
|
||||
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
@@ -624,7 +625,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
|
||||
{ timeout: 20_000, env: desktopSpawnEnv() },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } 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";
|
||||
|
||||
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
|
||||
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
|
||||
// Run the user's login shell once to recover the real PATH so the bundled
|
||||
// multica CLI can find agent binaries like claude/codex/opencode. Must run
|
||||
// before any child_process.spawn / execFile call in the main process —
|
||||
// ES module imports are hoisted, so this block executes before createWindow
|
||||
// or any daemon-manager spawn.
|
||||
if (process.platform !== "win32") {
|
||||
fixPath();
|
||||
// Fallback: prepend common install locations in case fix-path came up
|
||||
// short (broken shell rc, non-interactive $SHELL, missing entries). Safe
|
||||
// to duplicate — PATH lookups short-circuit on first match.
|
||||
const fallbackPaths = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
join(homedir(), ".local/bin"),
|
||||
];
|
||||
process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`;
|
||||
}
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
@@ -13,6 +14,15 @@ import { UpdateNotification } from "./components/update-notification";
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
// before the workspace list is hydrated and briefly see `!workspace`.
|
||||
// This local flag keeps the loading screen up until the whole chain
|
||||
// finishes, so the shell's "needs onboarding?" check gets a definitive
|
||||
// workspace state on first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
@@ -20,20 +30,28 @@ function AppContent() {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
|
||||
// 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).
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onAuthToken(async (token) => {
|
||||
setBootstrapping(true);
|
||||
try {
|
||||
const loggedIn = await useAuthStore.getState().loginWithToken(token);
|
||||
await window.daemonAPI.syncToken(token, loggedIn.id);
|
||||
await useAuthStore.getState().loginWithToken(token);
|
||||
// Seed React Query cache with the workspace list so the index-route
|
||||
// redirect (routes.tsx `IndexRedirect`) can resolve the initial
|
||||
// destination without a second fetch. Workspace side-effects
|
||||
// (setCurrentWorkspace, persist namespace) are synced later by
|
||||
// WorkspaceRouteLayout when the URL resolves.
|
||||
const wsList = await api.listWorkspaces();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
} catch {
|
||||
// Token invalid or expired — user stays on login page
|
||||
} finally {
|
||||
setBootstrapping(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [qc]);
|
||||
|
||||
// Sync token and start the daemon whenever the user logs in.
|
||||
useEffect(() => {
|
||||
@@ -51,7 +69,7 @@ function AppContent() {
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || bootstrapping) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
@@ -6,14 +6,18 @@ import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StepWorkspace } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { OnboardingGate } from "./onboarding-gate";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
|
||||
@@ -51,17 +55,28 @@ function SidebarTopBar() {
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is collapsed, we pad the left side so tabs don't land under the macOS
|
||||
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
|
||||
// is not occupying main-flow width — either user-collapsed (offcanvas) or
|
||||
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
|
||||
// left side so tabs don't land under the macOS traffic lights (which live at
|
||||
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
|
||||
// the sidebar can be brought back without keyboard shortcut.
|
||||
function MainTopBar() {
|
||||
const { state } = useSidebar();
|
||||
const sidebarCollapsed = state === "collapsed";
|
||||
const { state, isMobile } = useSidebar();
|
||||
const sidebarHidden = state === "collapsed" || isMobile;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
|
||||
className={cn(
|
||||
"h-12 shrink-0 flex items-center gap-2",
|
||||
sidebarHidden && "pl-20",
|
||||
)}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
{sidebarHidden && (
|
||||
<SidebarTrigger
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
<TabBar />
|
||||
</header>
|
||||
);
|
||||
@@ -86,34 +101,47 @@ export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
|
||||
// Reactive read of current workspace slug from the platform singleton.
|
||||
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
|
||||
// router) sets it. Once set, the sidebar and other shell-level components
|
||||
// can resolve workspace-scoped paths via useWorkspacePaths().
|
||||
const slug = useSyncExternalStore(subscribeToCurrentSlug, getCurrentSlug, () => null);
|
||||
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<DashboardGuard
|
||||
loginPath="/login"
|
||||
loadingFallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
<OnboardingGate
|
||||
onboarding={(onComplete) => (
|
||||
<div className="flex min-h-screen items-center justify-center overflow-auto bg-background px-6 py-12">
|
||||
<StepWorkspace onNext={onComplete} />
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
{/* WorkspaceSlugProvider accepts null — components that need slug
|
||||
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
|
||||
(throws). TabContent MUST always render so the tab router can
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
<ModalRegistry />
|
||||
<SearchCommand />
|
||||
</DashboardGuard>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
</WorkspaceSlugProvider>
|
||||
</OnboardingGate>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { OnboardingGate } from "./onboarding-gate";
|
||||
|
||||
// Prevent actual API calls — the tests seed data via setQueryData.
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
function createTestQueryClient(
|
||||
workspaces: Array<{ id: string; slug: string }> = [],
|
||||
) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Seed the workspace list so the gate can read it synchronously.
|
||||
qc.setQueryData(workspaceKeys.list(), workspaces);
|
||||
return qc;
|
||||
}
|
||||
|
||||
function renderGate(
|
||||
qc: QueryClient,
|
||||
onboarding?: (onComplete: () => void) => React.ReactNode,
|
||||
) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<OnboardingGate
|
||||
onboarding={
|
||||
onboarding ??
|
||||
((onComplete) => (
|
||||
<button type="button" data-testid="finish" onClick={onComplete}>
|
||||
wizard
|
||||
</button>
|
||||
))
|
||||
}
|
||||
>
|
||||
<div data-testid="main">main shell</div>
|
||||
</OnboardingGate>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OnboardingGate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders children when workspaces exist in cache", () => {
|
||||
const qc = createTestQueryClient([{ id: "ws-1", slug: "my-team" }]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByTestId("main")).toBeInTheDocument();
|
||||
expect(screen.queryByText("wizard")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders onboarding when workspace list is empty", () => {
|
||||
const qc = createTestQueryClient([]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByText("wizard")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the wizard mounted even after workspaces appear in cache mid-flow", () => {
|
||||
const qc = createTestQueryClient([]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByText("wizard")).toBeInTheDocument();
|
||||
|
||||
// Simulate the onboarding wizard creating a workspace mid-flow.
|
||||
act(() => {
|
||||
qc.setQueryData(workspaceKeys.list(), [
|
||||
{ id: "ws-new", slug: "new-team" },
|
||||
]);
|
||||
});
|
||||
|
||||
// Wizard should still be visible — only onComplete dismisses it.
|
||||
expect(screen.getByText("wizard")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("transitions to children after the wizard calls onComplete", () => {
|
||||
const qc = createTestQueryClient([]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByTestId("finish")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
screen.getByTestId("finish").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("main")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("finish")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
40
apps/desktop/src/renderer/src/components/onboarding-gate.tsx
Normal file
40
apps/desktop/src/renderer/src/components/onboarding-gate.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
|
||||
/**
|
||||
* Renders `onboarding` as a full-screen takeover when the user has no
|
||||
* workspaces, otherwise renders `children`.
|
||||
*
|
||||
* Reads the workspace list directly from React Query — this works regardless
|
||||
* of whether a WorkspaceSlugProvider is mounted, unlike useCurrentWorkspace()
|
||||
* which depends on slug context from the router tree.
|
||||
*
|
||||
* The onboarding decision is frozen at first mount via the lazy useState
|
||||
* initializer: this way the onboarding wizard controls its own exit by
|
||||
* calling the `onComplete` callback, instead of being unmounted the moment
|
||||
* the workspace list updates mid-flow (e.g. after the user creates their
|
||||
* first workspace in step 1 but still has steps 2-3 to complete).
|
||||
*
|
||||
* The frozen decision only triggers when the initial query has settled AND
|
||||
* the list is empty. While the list is loading, children are rendered
|
||||
* (the shell shows its own loading state).
|
||||
*/
|
||||
export function OnboardingGate({
|
||||
onboarding,
|
||||
children,
|
||||
}: {
|
||||
onboarding: (onComplete: () => void) => ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { data: workspaces, isFetched } = useQuery(workspaceListOptions());
|
||||
const hasWorkspaces = !isFetched || (workspaces?.length ?? 0) > 0;
|
||||
|
||||
const [initialNeedsOnboarding] = useState(() => !hasWorkspaces);
|
||||
const [onboardingDone, setOnboardingDone] = useState(false);
|
||||
|
||||
if (initialNeedsOnboarding && !onboardingDone) {
|
||||
return <>{onboarding(() => setOnboardingDone(true))}</>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } 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 {
|
||||
setCurrentWorkspace,
|
||||
rehydrateAllWorkspaceStores,
|
||||
} from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
*
|
||||
* Reads :workspaceSlug from react-router params, resolves it to a Workspace
|
||||
* object via the React Query list cache, and syncs the URL-derived workspace
|
||||
* into the platform singleton (slug + UUID). Children (DashboardGuard +
|
||||
* dashboard layout) handle auth check, loading, and workspace-not-found.
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
const { data: workspace, isFetched: listFetched } = useQuery({
|
||||
...workspaceBySlugOptions(workspaceSlug ?? ""),
|
||||
enabled: !!user && !!workspaceSlug,
|
||||
});
|
||||
|
||||
// Render-phase sync (same pattern as web layout).
|
||||
const syncedSlugRef = useRef<string | null>(null);
|
||||
if (workspace && workspaceSlug && syncedSlugRef.current !== workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
// Double-write legacy localStorage key for rollback compatibility — see
|
||||
// apps/web/app/[workspaceSlug]/layout.tsx for the full rationale.
|
||||
try {
|
||||
localStorage.setItem("multica_workspace_id", workspace.id);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
syncedSlugRef.current = workspaceSlug;
|
||||
}
|
||||
|
||||
// Slug doesn't resolve → onboarding. Skip when user is null.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (listFetched && !workspace) navigate(paths.onboarding(), { replace: true });
|
||||
}, [user, listFetched, workspace, navigate]);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
@@ -23,9 +21,9 @@ export function DesktopLoginPage() {
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell.
|
||||
// Initial workspace navigation happens in routes.tsx via IndexRedirect.
|
||||
}}
|
||||
onGoogleLogin={handleGoogleLogin}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -22,8 +23,11 @@ import { SettingsPage } from "@multica/views/settings";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
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 { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
@@ -57,7 +61,37 @@ function PageShell() {
|
||||
|
||||
function OnboardingRoute() {
|
||||
const nav = useNavigation();
|
||||
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
|
||||
return (
|
||||
<OnboardingWizard
|
||||
onComplete={(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 onboarding, 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 onboarding 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.onboarding()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
@@ -67,51 +101,24 @@ function InviteRoute() {
|
||||
return <InvitePage invitationId={id} />;
|
||||
}
|
||||
|
||||
/** Route definitions shared by all tabs (no layout wrapper). */
|
||||
/**
|
||||
* 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 — onboarding and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
handle: { title: "Issue" },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
element: <ProjectsPage />,
|
||||
handle: { title: "Projects" },
|
||||
},
|
||||
{
|
||||
path: "projects/:id",
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "autopilots",
|
||||
element: <AutopilotsPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "autopilots/:id",
|
||||
element: <AutopilotDetailPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
handle: { title: "My Issues" },
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
// 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 onboarding if the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "onboarding",
|
||||
element: <OnboardingRoute />,
|
||||
@@ -123,20 +130,66 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
handle: { title: "Issue" },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
element: <ProjectsPage />,
|
||||
handle: { title: "Projects" },
|
||||
},
|
||||
{
|
||||
path: "projects/:id",
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "autopilots",
|
||||
element: <AutopilotsPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "autopilots/:id",
|
||||
element: <AutopilotDetailPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
handle: { title: "My Issues" },
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,6 +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 } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -45,29 +46,50 @@ interface TabStore {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
"/inbox": "Inbox",
|
||||
"/my-issues": "CircleUser",
|
||||
"/issues": "ListTodo",
|
||||
"/projects": "FolderKanban",
|
||||
"/agents": "Bot",
|
||||
"/runtimes": "Monitor",
|
||||
"/skills": "BookOpenText",
|
||||
"/settings": "Settings",
|
||||
inbox: "Inbox",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
autopilots: "ListTodo",
|
||||
agents: "Bot",
|
||||
runtimes: "Monitor",
|
||||
skills: "BookOpenText",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
|
||||
/**
|
||||
* Resolve a route icon from a pathname. Title is NOT determined here — it
|
||||
* comes from document.title.
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (onboarding/invite/auth/login): `/{route}/...` → use segment index 0
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
*/
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
return ROUTE_ICONS[pathname]
|
||||
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
|
||||
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
|
||||
?? "ListTodo";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegment = isGlobalPath(pathname)
|
||||
? (segments[0] ?? "")
|
||||
: (segments[1] ?? "");
|
||||
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_PATH = "/issues";
|
||||
/**
|
||||
* 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();
|
||||
@@ -177,12 +199,28 @@ export const useTabStore = create<TabStore>()(
|
||||
| undefined;
|
||||
if (!persisted?.tabs?.length) return currentState;
|
||||
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => ({
|
||||
...tab,
|
||||
router: createTabRouter(tab.path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
}));
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => {
|
||||
// Migration: pre-refactor tab paths like "/issues/abc" lack a
|
||||
// workspace slug prefix. These would 404 in the new router.
|
||||
// Reset to "/" so IndexRedirect picks the right workspace.
|
||||
let path = tab.path;
|
||||
if (path !== "/" && !isGlobalPath(path)) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const firstSegment = segments[0] ?? "";
|
||||
// If the first segment IS a known route name (e.g. "issues",
|
||||
// "projects"), it's an old-format path missing the slug prefix.
|
||||
if (ROUTE_ICONS[firstSegment]) {
|
||||
path = "/";
|
||||
}
|
||||
}
|
||||
return {
|
||||
...tab,
|
||||
path,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
|
||||
// Validate activeTabId — fall back to first tab if stale
|
||||
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
|
||||
|
||||
1
apps/desktop/test/setup.ts
Normal file
1
apps/desktop/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -4,7 +4,8 @@
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
"src/preload/*.d.ts",
|
||||
"test/setup.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
@@ -14,7 +15,9 @@ export default function InviteAcceptPage() {
|
||||
// Redirect to login if not authenticated, with a redirect back to this page.
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace(`/login?next=/invite/${params.id}`);
|
||||
router.replace(
|
||||
`${paths.login()}?next=${encodeURIComponent(paths.invite(params.id))}`,
|
||||
);
|
||||
}
|
||||
}, [isLoading, user, router, params.id]);
|
||||
|
||||
|
||||
@@ -11,13 +11,10 @@ function createWrapper() {
|
||||
);
|
||||
}
|
||||
|
||||
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
|
||||
() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
mockHydrateWorkspace: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -47,16 +44,6 @@ vi.mock("@/features/auth/auth-cookie", () => ({
|
||||
setLoggedInCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace store — shared LoginPage uses getState().hydrateWorkspace
|
||||
vi.mock("@multica/core/workspace", () => {
|
||||
const wsState = { hydrateWorkspace: mockHydrateWorkspace };
|
||||
const useWorkspaceStore = Object.assign(
|
||||
(selector: (s: typeof wsState) => unknown) => selector(wsState),
|
||||
{ getState: () => wsState },
|
||||
);
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// Mock api
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
@@ -11,6 +14,7 @@ const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -18,30 +22,46 @@ function LoginPageContent() {
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const platform = searchParams.get("platform");
|
||||
const nextUrl = searchParams.get("next") || "/issues";
|
||||
// `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");
|
||||
|
||||
// Already authenticated — redirect to dashboard (skip if CLI callback)
|
||||
// Already authenticated — honor ?next= or fall back to first workspace /
|
||||
// onboarding. Skip this entire path when the user arrived to authorize the CLI.
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !cliCallbackRaw) {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
|
||||
|
||||
const lastWorkspaceId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("multica_workspace_id")
|
||||
: null;
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
const ws = useWorkspaceStore.getState().workspace;
|
||||
router.push(ws ? nextUrl : "/onboarding");
|
||||
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.onboarding(),
|
||||
);
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
|
||||
nextUrl ? `next:${nextUrl}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
@@ -63,7 +83,6 @@ function LoginPageContent() {
|
||||
? { url: cliCallbackRaw, state: cliState }
|
||||
: undefined
|
||||
}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
@@ -12,12 +13,14 @@ export default function OnboardingPage() {
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace("/login");
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return (
|
||||
<OnboardingWizard onComplete={() => router.push("/issues")} />
|
||||
<OnboardingWizard
|
||||
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
import { RedirectIfAuthenticated } from "@/features/landing/components/redirect-if-authenticated";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -19,5 +20,10 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return <MulticaLanding />;
|
||||
return (
|
||||
<>
|
||||
<RedirectIfAuthenticated />
|
||||
<MulticaLanding />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
onboardingPath="/onboarding"
|
||||
loginPath="/login"
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
79
apps/web/app/[workspaceSlug]/layout.tsx
Normal file
79
apps/web/app/[workspaceSlug]/layout.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
setCurrentWorkspace,
|
||||
rehydrateAllWorkspaceStores,
|
||||
} from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
|
||||
export default function WorkspaceLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
const { workspaceSlug } = use(params);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
const router = useRouter();
|
||||
|
||||
// Resolve workspace by slug from the React Query list cache.
|
||||
// Enabled only when user is authenticated — otherwise the list query isn't seeded.
|
||||
const { data: workspace, isFetched: listFetched } = useQuery({
|
||||
...workspaceBySlugOptions(workspaceSlug),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Render-phase sync: set the current workspace slug + UUID into the
|
||||
// platform singleton BEFORE children render. This ensures the first
|
||||
// child query's X-Workspace-Slug header is already correct.
|
||||
// The ref guard prevents re-running on every render.
|
||||
const syncedSlugRef = useRef<string | null>(null);
|
||||
if (workspace && syncedSlugRef.current !== workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
syncedSlugRef.current = workspaceSlug;
|
||||
}
|
||||
|
||||
// Cookie write (last_workspace_slug) — proxy reads it on next page load.
|
||||
// ALSO write legacy localStorage["multica_workspace_id"] for forward/back
|
||||
// compatibility: if this version ever gets reverted to the pre-refactor
|
||||
// build, the legacy code reads that localStorage key to know which
|
||||
// workspace to attach to API requests. Without double-writing, a rollback
|
||||
// would leave returning users with empty data (API calls would have no
|
||||
// X-Workspace-ID header). Forward compatible — new code ignores this key.
|
||||
useEffect(() => {
|
||||
if (!workspace || typeof document === "undefined") return;
|
||||
const oneYear = 60 * 60 * 24 * 365;
|
||||
const secure = location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `last_workspace_slug=${encodeURIComponent(workspaceSlug)}; path=/; max-age=${oneYear}; SameSite=Lax${secure}`;
|
||||
try {
|
||||
localStorage.setItem("multica_workspace_id", workspace.id);
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted contexts; non-critical.
|
||||
}
|
||||
}, [workspace, workspaceSlug]);
|
||||
|
||||
// Slug doesn't match any workspace the user has access to → onboarding.
|
||||
// Wait for the list query to settle so we don't bounce on first render.
|
||||
// Skip when user is null — DashboardGuard handles the /login redirect.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (listFetched && !workspace) router.replace(paths.onboarding());
|
||||
}, [user, listFetched, workspace, router]);
|
||||
|
||||
// Auth still loading → render nothing (let DashboardGuard show its loader).
|
||||
if (isAuthLoading) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
{children}
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ 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 { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
@@ -22,7 +22,6 @@ function CallbackContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const [error, setError] = useState("");
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
|
||||
@@ -64,17 +63,21 @@ function CallbackContent() {
|
||||
.then(async () => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
const ws = await hydrateWorkspace(wsList, lastWsId);
|
||||
// Honor the ?next= redirect if present (e.g. /invite/{id})
|
||||
const defaultDest = ws ? "/issues" : "/onboarding";
|
||||
// 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 /onboarding if the user has none.
|
||||
const [first] = wsList;
|
||||
const defaultDest = first
|
||||
? paths.workspace(first.slug).issues()
|
||||
: paths.onboarding();
|
||||
router.push(nextUrl || defaultDest);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
}
|
||||
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
|
||||
}, [searchParams, loginWithGoogle, router, qc]);
|
||||
|
||||
if (desktopToken) {
|
||||
return (
|
||||
@@ -111,7 +114,7 @@ function CallbackContent() {
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<a href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
<a href={paths.login()} className="text-primary underline-offset-4 hover:underline">
|
||||
Back to login
|
||||
</a>
|
||||
</CardContent>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function HowItWorksSection() {
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -48,7 +48,7 @@ export function LandingFooter() {
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={user ? "/issues" : "/login"}
|
||||
href={user ? "/" : "/login"}
|
||||
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
|
||||
>
|
||||
{user ? t.header.dashboard : t.footer.cta}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function LandingHeader({
|
||||
{t.header.github}
|
||||
</Link>
|
||||
<Link
|
||||
href={user ? "/issues" : "/login"}
|
||||
href={user ? "/" : "/login"}
|
||||
className={headerButtonClassName("solid", variant)}
|
||||
>
|
||||
{user ? t.header.dashboard : t.header.login}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function LandingHero() {
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Client-side fallback redirect for authenticated visitors on the landing page.
|
||||
*
|
||||
* The primary path for logged-in users hitting `/` is a server-side redirect
|
||||
* in the Next.js proxy/middleware, driven by the `last_workspace_slug` cookie.
|
||||
* That cookie is set by the workspace layout on every visit. But on *first
|
||||
* 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 onboarding if they have none).
|
||||
*
|
||||
* Renders nothing. Uses `router.replace` so the landing page never enters
|
||||
* browser history for authenticated users.
|
||||
*/
|
||||
export function RedirectIfAuthenticated() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
const { data: list } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || !list) return;
|
||||
const [first] = list;
|
||||
if (!first) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
router.replace(paths.workspace(first.slug).issues());
|
||||
}, [isLoading, user, list, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -277,6 +277,31 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-04-15",
|
||||
title: "Desktop App, Autopilot & Invitations",
|
||||
changes: [],
|
||||
features: [
|
||||
"Desktop app for macOS — native Electron app with tab system, built-in daemon management, immersive mode, and auto-update",
|
||||
"Autopilot — scheduled and triggered automations for AI agents",
|
||||
"Workspace invitations with email notifications and dedicated accept page",
|
||||
"Custom CLI arguments per agent for advanced runtime configuration",
|
||||
"Chat redesign with unread tracking and improved session management",
|
||||
"Create Agent dialog shows runtime owner with Mine/All filter",
|
||||
],
|
||||
improvements: [
|
||||
"Inter font with CJK fallback and automatic CJK+Latin spacing",
|
||||
"Sidebar user menu redesigned as full-row popover",
|
||||
"WebSocket ping/pong heartbeat to detect dead connections",
|
||||
"Members can now create agents and manage their own skills",
|
||||
],
|
||||
fixes: [
|
||||
"Agent now triggered on reply in threads where it already participated",
|
||||
"Self-hosting: local uploads persist in Docker, WebSocket URL auto-derived for LAN access",
|
||||
"Stale cmd+k recent issues resolved",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
|
||||
@@ -277,6 +277,31 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-04-15",
|
||||
title: "桌面应用、Autopilot 与邀请",
|
||||
changes: [],
|
||||
features: [
|
||||
"macOS 桌面应用——原生 Electron 应用,支持标签页系统、内置 Daemon 管理、沉浸模式和自动更新",
|
||||
"Autopilot——Agent 定时和触发式自动化任务",
|
||||
"工作区邀请,支持邮件通知和专用接受页面",
|
||||
"Agent 自定义 CLI 参数,支持高级运行时配置",
|
||||
"聊天界面重设计,新增未读追踪和会话管理优化",
|
||||
"创建 Agent 对话框显示运行时所有者和 Mine/All 筛选",
|
||||
],
|
||||
improvements: [
|
||||
"Inter 字体 + CJK 回退,中英文自动间距",
|
||||
"侧边栏用户菜单改为整行弹出面板",
|
||||
"WebSocket ping/pong 心跳检测断线连接",
|
||||
"普通成员现在可以创建 Agent 和管理自己的 Skills",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 在已参与的线程收到回复时正确触发",
|
||||
"自部署:Docker 本地上传文件持久化,WebSocket URL 自动适配局域网",
|
||||
"Cmd+K 最近 Issue 列表状态过期",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
|
||||
@@ -1,10 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
// Old workspace-scoped route segments that existed before the URL refactor
|
||||
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
|
||||
// needs to be rewritten to /{slug}/{route}/... so old bookmarks, deep links,
|
||||
// and post-revert-and-reapply users don't hit 404.
|
||||
const LEGACY_ROUTE_SEGMENTS = new Set([
|
||||
"issues",
|
||||
"projects",
|
||||
"agents",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"autopilots",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
]);
|
||||
|
||||
// Next.js 16 renamed `middleware` → `proxy`. The runtime API is identical.
|
||||
export function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const hasSession = req.cookies.has("multica_logged_in");
|
||||
const lastSlug = req.cookies.get("last_workspace_slug")?.value;
|
||||
|
||||
// --- Legacy URL redirect: /issues/... → /{slug}/issues/... ---
|
||||
// Old bookmarks and clients that hit us before the slug migration would
|
||||
// otherwise 404 since the route moved under [workspaceSlug].
|
||||
const firstSegment = pathname.split("/")[1] ?? "";
|
||||
if (LEGACY_ROUTE_SEGMENTS.has(firstSegment)) {
|
||||
const url = req.nextUrl.clone();
|
||||
|
||||
if (!hasSession) {
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (lastSlug) {
|
||||
// Preserve deep-link path + query: /issues/abc → /{lastSlug}/issues/abc
|
||||
url.pathname = `/${lastSlug}${pathname}`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// Logged-in but no cookie yet (first login since slug migration, or
|
||||
// cookie cleared). Bounce to root; the root-path logic below picks a
|
||||
// workspace and writes the cookie, then future hits short-circuit here.
|
||||
url.pathname = "/";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// --- Root path: redirect logged-in users to their last workspace ---
|
||||
if (pathname === "/") {
|
||||
if (!hasSession) return NextResponse.next();
|
||||
|
||||
if (lastSlug) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${lastSlug}/issues`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// No last_workspace_slug cookie → let landing page pick the first workspace
|
||||
// client-side (features/landing/components/redirect-if-authenticated.tsx).
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export function proxy(_request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/"],
|
||||
matcher: [
|
||||
"/",
|
||||
"/issues/:path*",
|
||||
"/projects/:path*",
|
||||
"/agents/:path*",
|
||||
"/inbox/:path*",
|
||||
"/my-issues/:path*",
|
||||
"/autopilots/:path*",
|
||||
"/runtimes/:path*",
|
||||
"/skills/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -78,7 +78,6 @@ export const mockAuthValue: Record<string, any> = {
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
updateWorkspace: vi.fn(),
|
||||
updateCurrentUser: vi.fn(),
|
||||
getMemberName: (userId: string) => {
|
||||
|
||||
@@ -36,6 +36,8 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
||||
PORT: "8080"
|
||||
@@ -72,3 +74,4 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
backend_uploads:
|
||||
|
||||
1819
docs/plans/2026-04-15-workspace-slug-url-refactor.md
Normal file
1819
docs/plans/2026-04-15-workspace-slug-url-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
109
docs/workspace-url-refactor-proposal.md
Normal file
109
docs/workspace-url-refactor-proposal.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Workspace URL 化重构 — 项目汇报
|
||||
|
||||
**日期**:2026-04-15
|
||||
**作者**:Naiyuan
|
||||
**状态**:调研完成,待评审
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里**不含任何 workspace 信息**。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
这个设计已经在产品里直接表现为 3 个已知问题:
|
||||
|
||||
1. **分享链接不可靠**(MUL-43):`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
|
||||
2. **手机端无法切 workspace**(MUL-509):切换只靠 sidebar UI,手机端不展开 sidebar 就没有切换入口
|
||||
3. **多 tab 互相覆盖**:`multica_workspace_id` 是全局 localStorage key,两个 tab 打开不同 workspace 会互相污染
|
||||
|
||||
除了这 3 个显性 bug,架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
|
||||
|
||||
行业惯例(Linear / Notion / Vercel / GitHub)都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
|
||||
|
||||
## 二、调研结论
|
||||
|
||||
### 好消息:基础设施已经就位
|
||||
|
||||
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
|
||||
- 后端已有 `GetWorkspaceBySlug` 查询
|
||||
- 前端 `Workspace` 类型已包含 `slug` 字段
|
||||
- Web 端认证已经切换为 HttpOnly cookie 模式,Next.js middleware 可读到登录态
|
||||
|
||||
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
|
||||
|
||||
### 坏消息:范围比最初估计大
|
||||
|
||||
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
|
||||
|
||||
1. **URL 路由重组**:web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`;desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
|
||||
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions(切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
|
||||
3. **所有路径引用替换**:`push("/issues")` 改为 path builder(`paths.issues()`),影响 ~25 个组件文件
|
||||
4. **Mutation 副作用重构**:`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
|
||||
5. **桌面端 tab 系统适配**:tab 路径天然包含 workspace,切 workspace = 开新 tab 或导航,不再有全局切换动作
|
||||
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug),需要更新
|
||||
7. **后端保留词校验**:slug 不能和前端顶级路由冲突(`login`、`onboarding`、`invite`、`api`、`settings` 等),后端创建时校验
|
||||
8. **内部 markdown 链接兼容**:issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
|
||||
|
||||
### 不需要改的(边界已确认)
|
||||
|
||||
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
|
||||
- `mention://type/id` 协议 — 只存 UUID,workspace-agnostic
|
||||
- CLI 登录 URL — `/login` 也是 pre-workspace,不需要 slug
|
||||
- 后端 API 路径 — 保持 `/api/workspaces/{id}`,slug 仅用于前端 URL
|
||||
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
**核心原则**:URL 是 workspace 上下文的唯一 source of truth,其他状态都是派生态。
|
||||
|
||||
**URL 形状**:`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
|
||||
|
||||
**切换 workspace = 导航**:sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
|
||||
|
||||
**预估影响面**:~30-35 个文件,其中约 20 个是机械替换(hardcoded 路径 → path builder),真正需要思考的核心逻辑改动集中在 5-6 个文件。
|
||||
|
||||
**一个 PR 合并**:中间状态不可运行(URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
|
||||
|
||||
## 四、执行与测试计划
|
||||
|
||||
### 执行阶段
|
||||
|
||||
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
|
||||
2. **下一步**:在独立 worktree 上开发,AI 辅助写代码,过程中人工 review
|
||||
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E)
|
||||
|
||||
### 测试阶段
|
||||
|
||||
1. **本地自测**:
|
||||
- 已知功能路径(创建 / 浏览 / 搜索 issue,切换 workspace,接受邀请,分享链接)
|
||||
- 已知 bug 场景(MUL-43 / MUL-509 / MUL-727 / MUL-820)逐一验证已修复
|
||||
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
|
||||
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
|
||||
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程)
|
||||
- URL 使用感受(分享、收藏、刷新)
|
||||
3. **灰度 / 生产**:测试环境稳定后推生产
|
||||
|
||||
### 风险提示
|
||||
|
||||
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404(产品还没正式 ship、用户量可忽略,所以不做兼容性重定向)
|
||||
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
|
||||
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate(可能性极低,因 slug 限制较严)
|
||||
|
||||
## 五、附注
|
||||
|
||||
这次重构会**顺带修掉**以下已登记 issue,不需要单独开 PR:
|
||||
|
||||
| Issue | 修复方式 |
|
||||
|---|---|
|
||||
| MUL-43(切换 workspace 报错 / 分享链接失效) | URL 带 slug,根本解决 |
|
||||
| MUL-509(手机端无法切 workspace) | 切换变导航,手机能点链接就能切 |
|
||||
| MUL-723(workspace 不在 URL) | 核心目标 |
|
||||
| MUL-727(创建 workspace 闪 /issues) | 删除 mutation 里的 switchWorkspace 副作用 |
|
||||
| MUL-728(删除 workspace 后留在 /settings) | 删除成功后 navigate 到下一个 workspace |
|
||||
| MUL-820(sidebar Join 不切 workspace) | Join 改成跳转到 `/invite/{id}` 走统一路径 |
|
||||
|
||||
不在本次范围内的:Issue #951(WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
|
||||
|
||||
---
|
||||
|
||||
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。
|
||||
@@ -24,10 +24,12 @@ test.describe("Authentication", () => {
|
||||
await page.goto("/login");
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
});
|
||||
|
||||
await page.goto("/issues");
|
||||
// Visit a workspace-scoped route; DashboardGuard should redirect to /login.
|
||||
// The slug here need not exist — the guard runs before workspace resolution
|
||||
// for unauthenticated users.
|
||||
await page.goto("/e2e-workspace/issues");
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ test.describe("Comments", () => {
|
||||
});
|
||||
|
||||
test("can add a comment on an issue", async ({ page }) => {
|
||||
// Wait for issues to load and click first one
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
// Wait for issues to load and click first one. `*=` matches both legacy
|
||||
// `/issues/{id}` and URL-refactored `/{slug}/issues/{id}` hrefs.
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
@@ -42,7 +43,7 @@ test.describe("Comments", () => {
|
||||
});
|
||||
|
||||
test("comment submit button is disabled when empty", async ({ page }) => {
|
||||
const issueLink = page.locator('a[href^="/issues/"]').first();
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
import "./env";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
// `||` (not `??`) so an empty `NEXT_PUBLIC_API_URL=` in .env still falls
|
||||
// back to localhost. dotenv sets unset-vs-empty both as "" — treating them
|
||||
// the same matches user intent.
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
|
||||
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||
|
||||
interface TestWorkspace {
|
||||
@@ -18,6 +21,7 @@ interface TestWorkspace {
|
||||
|
||||
export class TestApiClient {
|
||||
private token: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
@@ -86,11 +90,16 @@ export class TestApiClient {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
setWorkspaceSlug(slug: string) {
|
||||
this.workspaceSlug = slug;
|
||||
}
|
||||
|
||||
async ensureWorkspace(name = "E2E Workspace", slug = "e2e-workspace") {
|
||||
const workspaces = await this.getWorkspaces();
|
||||
const workspace = workspaces.find((item) => item.slug === slug) ?? workspaces[0];
|
||||
if (workspace) {
|
||||
this.workspaceId = workspace.id;
|
||||
this.workspaceSlug = workspace.slug;
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@@ -150,7 +159,8 @@ export class TestApiClient {
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
if (this.workspaceSlug) headers["X-Workspace-Slug"] = this.workspaceSlug;
|
||||
else if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,25 @@ const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
|
||||
* Log in as the default E2E user and ensure the workspace exists first.
|
||||
* Authenticates via API (send-code → DB read → verify-code), then injects
|
||||
* the token into localStorage so the browser session is authenticated.
|
||||
*
|
||||
* Returns the E2E workspace slug so callers can build workspace-scoped URLs.
|
||||
*/
|
||||
export async function loginAsDefault(page: Page) {
|
||||
export async function loginAsDefault(page: Page): Promise<string> {
|
||||
const api = new TestApiClient();
|
||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
|
||||
const workspace = await api.ensureWorkspace(
|
||||
"E2E Workspace",
|
||||
DEFAULT_E2E_WORKSPACE,
|
||||
);
|
||||
|
||||
const token = api.getToken();
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto("/issues");
|
||||
await page.goto(`/${workspace.slug}/issues`);
|
||||
await page.waitForURL("**/issues", { timeout: 10000 });
|
||||
return workspace.slug;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,8 +65,10 @@ test.describe("Issues", () => {
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
|
||||
// Navigate to the issue detail
|
||||
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
|
||||
// Navigate to the issue detail. Use a suffix match so the selector works
|
||||
// whether the href is legacy `/issues/{id}` or URL-refactored
|
||||
// `/{slug}/issues/{id}`.
|
||||
const issueLink = page.locator(`a[href$="/issues/${issue.id}"]`);
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import type {
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
@@ -92,7 +93,6 @@ export class ApiError extends Error {
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private logger: Logger;
|
||||
private options: ApiClientOptions;
|
||||
|
||||
@@ -110,10 +110,6 @@ export class ApiClient {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
setWorkspaceId(id: string | null) {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
private readCsrfToken(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie
|
||||
@@ -125,7 +121,8 @@ export class ApiClient {
|
||||
private authHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
const slug = getCurrentSlug();
|
||||
if (slug) headers["X-Workspace-Slug"] = slug;
|
||||
const csrf = this.readCsrfToken();
|
||||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||||
return headers;
|
||||
@@ -133,7 +130,10 @@ export class ApiClient {
|
||||
|
||||
private handleUnauthorized() {
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
// Workspace id is owned by the URL-driven workspace-storage singleton
|
||||
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
|
||||
// to /login which leaves the workspace route, and the next workspace
|
||||
// entry will overwrite the id. No clear needed here.
|
||||
this.options.onUnauthorized?.();
|
||||
}
|
||||
|
||||
@@ -231,8 +231,7 @@ export class ApiClient {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
@@ -263,9 +262,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
||||
const search = new URLSearchParams();
|
||||
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
||||
return this.fetch(`/api/issues?${search}`, {
|
||||
return this.fetch("/api/issues", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -396,8 +393,7 @@ export class ApiClient {
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
if (params?.include_archived) search.set("include_archived", "true");
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
@@ -430,8 +426,7 @@ export class ApiClient {
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
if (params?.owner) search.set("owner", params.owner);
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
@@ -788,9 +783,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
const search = new URLSearchParams();
|
||||
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
||||
return this.fetch(`/api/projects?${search}`, {
|
||||
return this.fetch("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private cookieAuth = false;
|
||||
private handlers = new Map<WSEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -22,9 +22,9 @@ export class WSClient {
|
||||
this.cookieAuth = options?.cookieAuth ?? false;
|
||||
}
|
||||
|
||||
setAuth(token: string | null, workspaceId: string) {
|
||||
setAuth(token: string | null, workspaceSlug: string) {
|
||||
this.token = token;
|
||||
this.workspaceId = workspaceId;
|
||||
this.workspaceSlug = workspaceSlug;
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -33,8 +33,8 @@ export class WSClient {
|
||||
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
|
||||
// is sent automatically with the upgrade request. In token mode the token
|
||||
// is delivered as the first WebSocket message after the connection opens.
|
||||
if (this.workspaceId)
|
||||
url.searchParams.set("workspace_id", this.workspaceId);
|
||||
if (this.workspaceSlug)
|
||||
url.searchParams.set("workspace_slug", this.workspaceSlug);
|
||||
|
||||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, StorageAdapter } from "../types";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { setCurrentWorkspace } from "../platform/workspace-storage";
|
||||
|
||||
export interface AuthStoreOptions {
|
||||
api: ApiClient;
|
||||
@@ -58,7 +59,7 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
storage.removeItem("multica_token");
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
@@ -107,7 +108,7 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
}
|
||||
storage.removeItem("multica_token");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
onLogout?.();
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
@@ -3,10 +3,10 @@ import { api } from "../api";
|
||||
|
||||
// NOTE on workspace scoping:
|
||||
// `wsId` is used only as part of queryKey for cache isolation per workspace.
|
||||
// The actual workspace context comes from ApiClient's X-Workspace-ID header,
|
||||
// which is set by useWorkspaceStore.switchWorkspace(). Callers must ensure the
|
||||
// header is in sync with the wsId they pass here — otherwise cache writes will
|
||||
// be misattributed during a workspace switch race window.
|
||||
// The actual workspace context comes from ApiClient's X-Workspace-Slug header,
|
||||
// which is set by the URL-driven [workspaceSlug] layout. Callers must ensure
|
||||
// the header is in sync with the wsId they pass here — otherwise cache writes
|
||||
// will be misattributed during a workspace switch race window.
|
||||
|
||||
export const chatKeys = {
|
||||
all: (wsId: string) => ["chat", wsId] as const,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("chat.store");
|
||||
@@ -90,8 +90,8 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
const { storage } = options;
|
||||
|
||||
const wsKey = (base: string) => {
|
||||
const wsId = getCurrentWorkspaceId();
|
||||
return wsId ? `${base}:${wsId}` : base;
|
||||
const slug = getCurrentSlug();
|
||||
return slug ? `${base}:${slug}` : base;
|
||||
};
|
||||
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const WorkspaceIdContext = createContext<string | null>(null);
|
||||
|
||||
export function WorkspaceIdProvider({
|
||||
wsId,
|
||||
children,
|
||||
}: {
|
||||
wsId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceIdContext.Provider value={wsId}>
|
||||
{children}
|
||||
</WorkspaceIdContext.Provider>
|
||||
);
|
||||
}
|
||||
import { useCurrentWorkspace } from "./paths/hooks";
|
||||
|
||||
/**
|
||||
* Returns the current workspace UUID. Throws if called outside a workspace route.
|
||||
*
|
||||
* Implementation: derives from useCurrentWorkspace() (URL slug + React Query list).
|
||||
* No longer backed by a React Context — the WorkspaceIdProvider has been removed
|
||||
* as part of the slug-first refactor. The throw semantics are preserved so existing
|
||||
* callers that depend on non-null don't need guard code.
|
||||
*/
|
||||
export function useWorkspaceId(): string {
|
||||
const wsId = useContext(WorkspaceIdContext);
|
||||
if (!wsId) throw new Error("useWorkspaceId: no workspace selected — wrap in WorkspaceIdProvider");
|
||||
return wsId;
|
||||
const ws = useCurrentWorkspace();
|
||||
if (!ws) throw new Error("useWorkspaceId: no workspace selected — ensure component renders inside a workspace route");
|
||||
return ws.id;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { useWorkspaceId, WorkspaceIdProvider } from "./hooks";
|
||||
export { useWorkspaceId } from "./hooks";
|
||||
export { createQueryClient } from "./query-client";
|
||||
export { QueryProvider } from "./provider";
|
||||
|
||||
@@ -17,8 +17,7 @@ export {
|
||||
createIssueViewStore,
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
registerViewStoreForWorkspaceSync,
|
||||
initFilterWorkspaceSync,
|
||||
useClearFiltersOnWorkspaceChange,
|
||||
SORT_OPTIONS,
|
||||
CARD_PROPERTY_OPTIONS,
|
||||
type ViewMode,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { create } from "zustand";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
@@ -215,43 +216,23 @@ export const useIssueViewStore = create<IssueViewState>()(
|
||||
|
||||
registerForWorkspaceRehydration(() => useIssueViewStore.persist.rehydrate());
|
||||
|
||||
// Clear filters on all registered view stores when workspace switches.
|
||||
const _syncedStores = new Set<StoreApi<IssueViewState>>();
|
||||
let _workspaceSyncInitialized = false;
|
||||
|
||||
/**
|
||||
* Register a view store to clear filters on workspace switch.
|
||||
* Clears the given view store's filters whenever the workspace id changes.
|
||||
*
|
||||
* @param store - The view store to register.
|
||||
* @param subscribeToWorkspace - Optional: a function that subscribes to workspace
|
||||
* changes and calls the callback with the new workspace ID. The app layer should
|
||||
* provide this to avoid a circular dependency on the workspace store.
|
||||
* Example: `(cb) => useWorkspaceStore.subscribe(s => cb(s.workspace?.id))`
|
||||
* URL-driven: wsId arrives from `useWorkspaceId()` (Context fed by the
|
||||
* `[workspaceSlug]` route). We track the previous id via ref so the first
|
||||
* render doesn't wipe persisted filters — clearing only fires on transitions
|
||||
* from one defined workspace to another.
|
||||
*/
|
||||
export function registerViewStoreForWorkspaceSync(
|
||||
store: StoreApi<IssueViewState>,
|
||||
subscribeToWorkspace?: (callback: (workspaceId: string | undefined) => void) => void,
|
||||
export function useClearFiltersOnWorkspaceChange(
|
||||
store: StoreApi<IssueViewState> | { getState: () => IssueViewState },
|
||||
wsId: string | undefined,
|
||||
) {
|
||||
_syncedStores.add(store);
|
||||
if (_workspaceSyncInitialized) return;
|
||||
_workspaceSyncInitialized = true;
|
||||
|
||||
if (subscribeToWorkspace) {
|
||||
let prevId: string | undefined;
|
||||
subscribeToWorkspace((id) => {
|
||||
if (prevId && id !== prevId) {
|
||||
for (const s of _syncedStores) s.getState().clearFilters();
|
||||
}
|
||||
prevId = id;
|
||||
});
|
||||
}
|
||||
// TODO: If no subscribeToWorkspace is provided, the workspace sync is a no-op.
|
||||
// The app layer (apps/web) should call this with the workspace store subscription
|
||||
// to wire up filter clearing on workspace switch.
|
||||
const prevIdRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (prevIdRef.current && wsId && wsId !== prevIdRef.current) {
|
||||
store.getState().clearFilters();
|
||||
}
|
||||
prevIdRef.current = wsId;
|
||||
}, [wsId, store]);
|
||||
}
|
||||
|
||||
/** Backward-compatible alias — registers the global singleton for workspace sync. */
|
||||
export const initFilterWorkspaceSync = (
|
||||
subscribeToWorkspace?: (callback: (workspaceId: string | undefined) => void) => void,
|
||||
) =>
|
||||
registerViewStoreForWorkspaceSync(useIssueViewStore, subscribeToWorkspace);
|
||||
|
||||
36
packages/core/navigation/store.test.ts
Normal file
36
packages/core/navigation/store.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// EXCLUDED_PREFIXES is private to store.ts but checked here via behavior.
|
||||
// We assert that every global path prefix is also excluded from lastPath
|
||||
// persistence — otherwise lastPath could contain /login etc, and on next
|
||||
// app load we'd "restore" a user to the login page.
|
||||
describe("useNavigationStore.lastPath excludes global paths", () => {
|
||||
it("does not persist /login, /onboarding, /invite/, /auth/, /logout, /signup", async () => {
|
||||
const { useNavigationStore } = await import("./store");
|
||||
const globalPrefixes = [
|
||||
"/login",
|
||||
"/logout",
|
||||
"/signup",
|
||||
"/onboarding",
|
||||
"/invite/abc",
|
||||
"/auth/callback",
|
||||
];
|
||||
|
||||
for (const path of globalPrefixes) {
|
||||
// Reset to a known sentinel so we can detect any write.
|
||||
useNavigationStore.setState({ lastPath: "/sentinel" });
|
||||
useNavigationStore.getState().onPathChange(path);
|
||||
expect(
|
||||
useNavigationStore.getState().lastPath,
|
||||
`${path} should not be persisted as lastPath (would restore user to a global route)`,
|
||||
).toBe("/sentinel");
|
||||
}
|
||||
});
|
||||
|
||||
it("does persist workspace-scoped paths", async () => {
|
||||
const { useNavigationStore } = await import("./store");
|
||||
useNavigationStore.setState({ lastPath: null });
|
||||
useNavigationStore.getState().onPathChange("/acme/issues");
|
||||
expect(useNavigationStore.getState().lastPath).toBe("/acme/issues");
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,35 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createPersistStorage } from "../platform/persist-storage";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/", "/invite/"];
|
||||
// Paths that should not be persisted as "last visited":
|
||||
// - Auth flows (/login, /signup, /logout)
|
||||
// - Pre-workspace routes (/onboarding, /auth/, /invite/)
|
||||
// - Pair flow (/pair/)
|
||||
const EXCLUDED_PREFIXES = [
|
||||
"/login",
|
||||
"/signup",
|
||||
"/logout",
|
||||
"/onboarding",
|
||||
"/auth/",
|
||||
"/invite/",
|
||||
"/pair/",
|
||||
];
|
||||
|
||||
interface NavigationState {
|
||||
lastPath: string;
|
||||
lastPath: string | null;
|
||||
onPathChange: (path: string) => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
|
||||
lastPath: null,
|
||||
onPathChange: (path: string) => {
|
||||
if (!EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) {
|
||||
set({ lastPath: path });
|
||||
@@ -25,8 +39,11 @@ export const useNavigationStore = create<NavigationState>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Workspace-aware: re-read lastPath when current workspace changes.
|
||||
registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"./realtime": "./realtime/index.ts",
|
||||
"./navigation": "./navigation/index.ts",
|
||||
"./modals": "./modals/index.ts",
|
||||
"./paths": "./paths/index.ts",
|
||||
"./hooks": "./hooks.tsx",
|
||||
"./hooks/*": "./hooks/*.ts",
|
||||
"./query-client": "./query-client.ts",
|
||||
|
||||
93
packages/core/paths/consistency.test.ts
Normal file
93
packages/core/paths/consistency.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { paths, isGlobalPath } from "./paths";
|
||||
import { RESERVED_SLUGS } from "./reserved-slugs";
|
||||
|
||||
// C4 — link-handler's WORKSPACE_ROUTE_SEGMENTS must match paths.workspace's
|
||||
// parameterless method names. We can't import WORKSPACE_ROUTE_SEGMENTS here
|
||||
// because link-handler is in packages/views (no inverse import allowed), so
|
||||
// we hardcode the expected list and assert paths.workspace produces the same
|
||||
// keys. If you change either, BOTH need to be updated — the test catches drift.
|
||||
describe("paths.workspace() shape", () => {
|
||||
it("exposes the expected parameterless workspace route methods", () => {
|
||||
const ws = paths.workspace("__probe__");
|
||||
const parameterlessRoutes = Object.entries(ws)
|
||||
.filter(([, fn]) => typeof fn === "function" && fn.length === 0)
|
||||
.map(([key]) => key);
|
||||
|
||||
expect(new Set(parameterlessRoutes)).toEqual(
|
||||
new Set([
|
||||
"root",
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"inbox",
|
||||
"myIssues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("each parameterless route emits /{slug}/{segment}", () => {
|
||||
const ws = paths.workspace("acme");
|
||||
// Check that none of the parameterless paths embed a leaked literal
|
||||
// and that their second URL segment matches the method name's kebab-case.
|
||||
const expectedSegments: Array<[string, string]> = [
|
||||
["issues", "issues"],
|
||||
["projects", "projects"],
|
||||
["autopilots", "autopilots"],
|
||||
["agents", "agents"],
|
||||
["inbox", "inbox"],
|
||||
["myIssues", "my-issues"],
|
||||
["runtimes", "runtimes"],
|
||||
["skills", "skills"],
|
||||
["settings", "settings"],
|
||||
];
|
||||
const wsAsAny = ws as unknown as Record<string, () => string>;
|
||||
for (const [method, segment] of expectedSegments) {
|
||||
const fn = wsAsAny[method];
|
||||
expect(typeof fn).toBe("function");
|
||||
expect(fn!()).toBe(`/acme/${segment}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// C5 — invariants between the global/reserved lists.
|
||||
describe("global path / reserved slug consistency", () => {
|
||||
// If a path is "global" (never workspace-scoped), the slug name underlying it
|
||||
// must be reserved — otherwise a user could create a workspace with that slug
|
||||
// and shadow the global route's URL space.
|
||||
//
|
||||
// GLOBAL_PREFIXES from paths.ts is private — we re-derive the list from
|
||||
// probing isGlobalPath. Order matters: keep this list in sync with paths.ts.
|
||||
const globalPrefixes = [
|
||||
"/login",
|
||||
"/logout",
|
||||
"/signup",
|
||||
"/onboarding",
|
||||
"/invite/",
|
||||
"/auth/",
|
||||
];
|
||||
|
||||
it("isGlobalPath agrees with the canonical global prefix list", () => {
|
||||
for (const prefix of globalPrefixes) {
|
||||
expect(isGlobalPath(prefix)).toBe(true);
|
||||
}
|
||||
expect(isGlobalPath("/acme/issues")).toBe(false);
|
||||
expect(isGlobalPath("/")).toBe(false);
|
||||
});
|
||||
|
||||
it("every global prefix's first path segment is a reserved slug", () => {
|
||||
for (const prefix of globalPrefixes) {
|
||||
const firstSegment = prefix.split("/").filter(Boolean)[0];
|
||||
if (!firstSegment) continue;
|
||||
expect(
|
||||
RESERVED_SLUGS.has(firstSegment),
|
||||
`'${firstSegment}' is a global path prefix but not a reserved slug — ` +
|
||||
`a workspace could be created with this slug and shadow the global route`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
70
packages/core/paths/hooks.tsx
Normal file
70
packages/core/paths/hooks.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Workspace } from "../types";
|
||||
import { workspaceListOptions } from "../workspace/queries";
|
||||
import { paths, type WorkspacePaths } from "./paths";
|
||||
|
||||
/**
|
||||
* Context for the current workspace slug (read from URL by the platform layer).
|
||||
*
|
||||
* apps/web populates this from Next.js `params.workspaceSlug` in
|
||||
* [workspaceSlug]/layout.tsx. apps/desktop populates it from react-router's
|
||||
* `useParams()` in the workspace route layout.
|
||||
*
|
||||
* packages/core/ cannot import next/navigation or react-router-dom directly,
|
||||
* so the slug arrives via this Context — mirroring how WorkspaceIdProvider
|
||||
* already works for workspace IDs.
|
||||
*/
|
||||
const WorkspaceSlugContext = createContext<string | null>(null);
|
||||
|
||||
export function WorkspaceSlugProvider({
|
||||
slug,
|
||||
children,
|
||||
}: {
|
||||
slug: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceSlugContext.Provider value={slug}>
|
||||
{children}
|
||||
</WorkspaceSlugContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Current workspace slug from URL, or null outside workspace-scoped routes. */
|
||||
export function useWorkspaceSlug(): string | null {
|
||||
return useContext(WorkspaceSlugContext);
|
||||
}
|
||||
|
||||
/** Same as useWorkspaceSlug, but throws if called outside a workspace route. */
|
||||
export function useRequiredWorkspaceSlug(): string {
|
||||
const slug = useWorkspaceSlug();
|
||||
if (!slug) {
|
||||
throw new Error(
|
||||
"useRequiredWorkspaceSlug called outside a workspace-scoped route",
|
||||
);
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently-selected workspace, derived from URL slug + React Query list.
|
||||
* Returns null if slug is missing or doesn't match any workspace in the list.
|
||||
*/
|
||||
export function useCurrentWorkspace(): Workspace | null {
|
||||
const slug = useWorkspaceSlug();
|
||||
const { data: list = [] } = useQuery(workspaceListOptions());
|
||||
if (!slug) return null;
|
||||
return list.find((w) => w.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path builder bound to the current workspace. Throws if called outside a
|
||||
* workspace route — for cross-workspace links use paths.workspace(slug) directly.
|
||||
*/
|
||||
export function useWorkspacePaths(): WorkspacePaths {
|
||||
const slug = useRequiredWorkspaceSlug();
|
||||
return paths.workspace(slug);
|
||||
}
|
||||
10
packages/core/paths/index.ts
Normal file
10
packages/core/paths/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { paths, isGlobalPath } from "./paths";
|
||||
export type { WorkspacePaths } from "./paths";
|
||||
export { RESERVED_SLUGS, isReservedSlug } from "./reserved-slugs";
|
||||
export {
|
||||
WorkspaceSlugProvider,
|
||||
useWorkspaceSlug,
|
||||
useRequiredWorkspaceSlug,
|
||||
useCurrentWorkspace,
|
||||
useWorkspacePaths,
|
||||
} from "./hooks";
|
||||
48
packages/core/paths/paths.test.ts
Normal file
48
packages/core/paths/paths.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { paths, isGlobalPath } from "./paths";
|
||||
|
||||
describe("paths.workspace(slug)", () => {
|
||||
const ws = paths.workspace("acme");
|
||||
|
||||
it("builds dashboard paths with slug prefix", () => {
|
||||
expect(ws.issues()).toBe("/acme/issues");
|
||||
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
|
||||
expect(ws.projects()).toBe("/acme/projects");
|
||||
expect(ws.projectDetail("p1")).toBe("/acme/projects/p1");
|
||||
expect(ws.autopilots()).toBe("/acme/autopilots");
|
||||
expect(ws.autopilotDetail("a1")).toBe("/acme/autopilots/a1");
|
||||
expect(ws.agents()).toBe("/acme/agents");
|
||||
expect(ws.inbox()).toBe("/acme/inbox");
|
||||
expect(ws.myIssues()).toBe("/acme/my-issues");
|
||||
expect(ws.runtimes()).toBe("/acme/runtimes");
|
||||
expect(ws.skills()).toBe("/acme/skills");
|
||||
expect(ws.settings()).toBe("/acme/settings");
|
||||
});
|
||||
|
||||
it("URL-encodes special characters in ids", () => {
|
||||
expect(ws.issueDetail("id with space")).toBe("/acme/issues/id%20with%20space");
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths (global)", () => {
|
||||
it("builds global paths without slug", () => {
|
||||
expect(paths.login()).toBe("/login");
|
||||
expect(paths.onboarding()).toBe("/onboarding");
|
||||
expect(paths.invite("inv-1")).toBe("/invite/inv-1");
|
||||
expect(paths.authCallback()).toBe("/auth/callback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGlobalPath", () => {
|
||||
it("returns true for pre-workspace routes", () => {
|
||||
expect(isGlobalPath("/login")).toBe(true);
|
||||
expect(isGlobalPath("/onboarding")).toBe(true);
|
||||
expect(isGlobalPath("/invite/abc")).toBe(true);
|
||||
expect(isGlobalPath("/auth/callback")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for workspace-scoped paths", () => {
|
||||
expect(isGlobalPath("/acme/issues")).toBe(false);
|
||||
expect(isGlobalPath("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
55
packages/core/paths/paths.ts
Normal file
55
packages/core/paths/paths.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Centralized URL path builder. All navigation in shared packages (packages/views)
|
||||
* MUST go through this module — no hardcoded string paths.
|
||||
*
|
||||
* Two kinds of paths:
|
||||
* - workspace-scoped: paths.workspace(slug).xxx() — carry workspace in URL
|
||||
* - global: paths.login(), paths.onboarding(), paths.invite(id) — pre-workspace routes
|
||||
*
|
||||
* Why pure functions + builder pattern:
|
||||
* - Changing a route shape (e.g. adding workspace slug prefix) becomes a single-file edit
|
||||
* - IDs are always URL-encoded here so callers can't forget
|
||||
* - Zero runtime deps means this module is safe in Node (tests) and browsers
|
||||
*/
|
||||
|
||||
const encode = (id: string) => encodeURIComponent(id);
|
||||
|
||||
function workspaceScoped(slug: string) {
|
||||
const ws = `/${encode(slug)}`;
|
||||
return {
|
||||
root: () => `${ws}/issues`,
|
||||
issues: () => `${ws}/issues`,
|
||||
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
|
||||
projects: () => `${ws}/projects`,
|
||||
projectDetail: (id: string) => `${ws}/projects/${encode(id)}`,
|
||||
autopilots: () => `${ws}/autopilots`,
|
||||
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
|
||||
agents: () => `${ws}/agents`,
|
||||
inbox: () => `${ws}/inbox`,
|
||||
myIssues: () => `${ws}/my-issues`,
|
||||
runtimes: () => `${ws}/runtimes`,
|
||||
skills: () => `${ws}/skills`,
|
||||
settings: () => `${ws}/settings`,
|
||||
};
|
||||
}
|
||||
|
||||
export const paths = {
|
||||
workspace: workspaceScoped,
|
||||
|
||||
// Global (pre-workspace) routes
|
||||
login: () => "/login",
|
||||
onboarding: () => "/onboarding",
|
||||
invite: (id: string) => `/invite/${encode(id)}`,
|
||||
authCallback: () => "/auth/callback",
|
||||
root: () => "/",
|
||||
};
|
||||
|
||||
export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
|
||||
|
||||
// Prefixes — not slug names — because we match against full URL paths.
|
||||
// A path is global if it equals or begins with any of these.
|
||||
const GLOBAL_PREFIXES = ["/login", "/onboarding", "/invite/", "/auth/", "/logout", "/signup"];
|
||||
|
||||
export function isGlobalPath(path: string): boolean {
|
||||
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
}
|
||||
49
packages/core/paths/reserved-slugs.ts
Normal file
49
packages/core/paths/reserved-slugs.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Slugs reserved because they collide with frontend top-level routes.
|
||||
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
|
||||
*/
|
||||
export const RESERVED_SLUGS = new Set([
|
||||
// Auth + onboarding
|
||||
"login",
|
||||
"logout",
|
||||
"signup",
|
||||
"onboarding",
|
||||
"invite",
|
||||
"auth",
|
||||
|
||||
// Reserved for future platform routes
|
||||
"api",
|
||||
"admin",
|
||||
"help",
|
||||
"about",
|
||||
"pricing",
|
||||
"changelog",
|
||||
|
||||
// Dashboard route segments. Even though Next.js's route specificity
|
||||
// would technically resolve /{slug}/{view} correctly, having a workspace
|
||||
// slug equal to a route name (e.g. slug="issues") makes URLs visually
|
||||
// ambiguous — /issues/abc reads as either "issue abc in workspace
|
||||
// 'issues'" or "issue abc in some workspace". Reserve to avoid the
|
||||
// ambiguity entirely.
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
|
||||
// Next.js / hosting internals
|
||||
"_next",
|
||||
"favicon.ico",
|
||||
"robots.txt",
|
||||
"sitemap.xml",
|
||||
"manifest.json",
|
||||
".well-known",
|
||||
]);
|
||||
|
||||
export function isReservedSlug(slug: string): boolean {
|
||||
return RESERVED_SLUGS.has(slug);
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import { useEffect, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { configStore } from "../config";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { setCurrentWorkspace } from "./workspace-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
@@ -30,7 +30,6 @@ export function AuthInitializer({
|
||||
|
||||
useEffect(() => {
|
||||
const api = getApi();
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
|
||||
// Fetch app config (CDN domain, etc.) in the background — non-blocking.
|
||||
api.getConfig().then((cfg) => {
|
||||
@@ -40,12 +39,16 @@ export function AuthInitializer({
|
||||
if (cookieAuth) {
|
||||
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
|
||||
// Call the API to check if the session is still valid.
|
||||
//
|
||||
// Seed the workspace list into React Query so the URL-driven layout can
|
||||
// resolve the slug without a second fetch. The active workspace itself
|
||||
// is derived from the URL by [workspaceSlug]/layout.tsx — no imperative
|
||||
// selection here.
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("cookie auth init failed", err);
|
||||
@@ -69,16 +72,15 @@ export function AuthInitializer({
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
// Seed React Query cache so components don't need a second fetch
|
||||
// Seed React Query cache so the URL-driven layout can resolve the
|
||||
// slug without a second fetch.
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
|
||||
import { createChatStore, registerChatStore } from "../chat";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
@@ -18,7 +17,6 @@ import type { StorageAdapter } from "../types/storage";
|
||||
// Vite HMR preserves module-level state, so these survive hot reloads.
|
||||
let initialized = false;
|
||||
let authStore: ReturnType<typeof createAuthStore>;
|
||||
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
|
||||
let chatStore: ReturnType<typeof createChatStore>;
|
||||
function initCore(
|
||||
apiBaseUrl: string,
|
||||
@@ -33,7 +31,6 @@ function initCore(
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
},
|
||||
});
|
||||
setApiInstance(api);
|
||||
@@ -43,15 +40,14 @@ function initCore(
|
||||
const token = storage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
}
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
// Workspace identity is URL-driven: the [workspaceSlug] layout resolves
|
||||
// the slug and calls setCurrentWorkspace(slug, wsId) on mount. The api
|
||||
// client reads the slug from that singleton for the X-Workspace-Slug
|
||||
// header. No boot-time hydration from storage is required.
|
||||
|
||||
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, { storage });
|
||||
registerWorkspaceStore(workspaceStore);
|
||||
|
||||
chatStore = createChatStore({ storage });
|
||||
registerChatStore(chatStore);
|
||||
|
||||
@@ -78,7 +74,6 @@ export function CoreProvider({
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={storage}
|
||||
cookieAuth={cookieAuth}
|
||||
>
|
||||
|
||||
@@ -3,5 +3,5 @@ export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspaceId, getCurrentWorkspaceId, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("clearWorkspaceStorage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_navigation:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/**
|
||||
* Keys that are namespaced per workspace (stored as `${key}:${wsId}`).
|
||||
* Keys that are namespaced per workspace (stored as `${key}:${slug}`).
|
||||
*
|
||||
* IMPORTANT: When adding a new workspace-scoped persist store or storage key,
|
||||
* add its key here so that workspace deletion and logout properly clean it up.
|
||||
@@ -16,14 +16,15 @@ const WORKSPACE_SCOPED_KEYS = [
|
||||
"multica:chat:activeSessionId",
|
||||
"multica:chat:drafts",
|
||||
"multica:chat:expanded",
|
||||
"multica_navigation",
|
||||
];
|
||||
|
||||
/** Remove all workspace-scoped storage entries for the given workspace. */
|
||||
/** Remove all workspace-scoped storage entries for the given workspace slug. */
|
||||
export function clearWorkspaceStorage(
|
||||
adapter: StorageAdapter,
|
||||
wsId: string,
|
||||
slug: string,
|
||||
) {
|
||||
for (const key of WORKSPACE_SCOPED_KEYS) {
|
||||
adapter.removeItem(`${key}:${wsId}`);
|
||||
adapter.removeItem(`${key}:${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createWorkspaceAwareStorage, setCurrentWorkspaceId } from "./workspace-storage";
|
||||
import { createWorkspaceAwareStorage, setCurrentWorkspace } from "./workspace-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
function mockAdapter(): StorageAdapter {
|
||||
@@ -12,50 +12,50 @@ function mockAdapter(): StorageAdapter {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspaceId(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("workspace-aware storage", () => {
|
||||
it("uses plain key when no workspace is set", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspaceId(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.setItem("draft", "data");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
|
||||
});
|
||||
|
||||
it("namespaces key when workspace is set", () => {
|
||||
it("namespaces key with slug when workspace is set", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspaceId("ws_abc");
|
||||
setCurrentWorkspace("acme", "ws_abc");
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.setItem("draft", "data");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_abc", "data");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:acme", "data");
|
||||
|
||||
storage.getItem("draft");
|
||||
expect(adapter.getItem).toHaveBeenCalledWith("draft:ws_abc");
|
||||
expect(adapter.getItem).toHaveBeenCalledWith("draft:acme");
|
||||
});
|
||||
|
||||
it("follows workspace changes dynamically", () => {
|
||||
const adapter = mockAdapter();
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
setCurrentWorkspaceId("ws_1");
|
||||
setCurrentWorkspace("team-a", "ws_1");
|
||||
storage.setItem("draft", "v1");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_1", "v1");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:team-a", "v1");
|
||||
|
||||
setCurrentWorkspaceId("ws_2");
|
||||
setCurrentWorkspace("team-b", "ws_2");
|
||||
storage.setItem("draft", "v2");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_2", "v2");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:team-b", "v2");
|
||||
});
|
||||
|
||||
it("removeItem uses current workspace", () => {
|
||||
it("removeItem uses current workspace slug", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspaceId("ws_x");
|
||||
setCurrentWorkspace("dev", "ws_x");
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.removeItem("draft");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("draft:ws_x");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("draft:dev");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,69 @@
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
// Paired module vars — always set/cleared together by the workspace layout.
|
||||
// _currentSlug is the primary identifier (matches the URL segment).
|
||||
// _currentWsId is derived (from the React Query workspace list) and used for
|
||||
// query keys and path-embedded API calls where UUID is required.
|
||||
let _currentSlug: string | null = null;
|
||||
let _currentWsId: string | null = null;
|
||||
const _rehydrateFns: Array<() => void> = [];
|
||||
|
||||
export function setCurrentWorkspaceId(wsId: string | null) {
|
||||
const _rehydrateFns: Array<() => void> = [];
|
||||
const _slugSubscribers = new Set<(slug: string | null) => void>();
|
||||
let _pendingNotify = false;
|
||||
let _pendingRehydrate = false;
|
||||
|
||||
/**
|
||||
* Set both the current workspace slug and UUID at once.
|
||||
* Called by the workspace layout's render-phase ref guard.
|
||||
* Notifies slug subscribers (e.g. WSProvider via useSyncExternalStore).
|
||||
*/
|
||||
export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
|
||||
const slugChanged = _currentSlug !== slug;
|
||||
_currentSlug = slug;
|
||||
_currentWsId = wsId;
|
||||
if (slugChanged && !_pendingNotify) {
|
||||
_pendingNotify = true;
|
||||
// Defer and deduplicate subscriber notifications:
|
||||
// 1. Defer: avoids "cannot update component B while rendering A"
|
||||
// (React 19 render-phase restriction).
|
||||
// 2. Deduplicate: rapid A→B switches only notify once with the
|
||||
// final slug, avoiding a wasted WS connect+disconnect cycle.
|
||||
// The module vars are already updated synchronously above, so
|
||||
// authHeaders() and getCurrentSlug() return the correct value
|
||||
// immediately — subscribers are only for async consumers like
|
||||
// WSProvider that need to reconnect the WebSocket.
|
||||
queueMicrotask(() => {
|
||||
_pendingNotify = false;
|
||||
const current = _currentSlug;
|
||||
for (const fn of _slugSubscribers) {
|
||||
fn(current);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Current workspace slug (from URL). */
|
||||
export function getCurrentSlug(): string | null {
|
||||
return _currentSlug;
|
||||
}
|
||||
|
||||
/** Current workspace UUID (derived from slug + workspace list cache). */
|
||||
export function getCurrentWsId(): string | null {
|
||||
return _currentWsId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes of the current workspace slug. Returns an unsubscribe
|
||||
* function. Designed for React's `useSyncExternalStore` (WSProvider reconnect).
|
||||
*/
|
||||
export function subscribeToCurrentSlug(
|
||||
fn: (slug: string | null) => void,
|
||||
): () => void {
|
||||
_slugSubscribers.add(fn);
|
||||
return () => {
|
||||
_slugSubscribers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** Register a persist store's rehydrate function to be called on workspace switch. */
|
||||
@@ -13,24 +71,34 @@ export function registerForWorkspaceRehydration(fn: () => void) {
|
||||
_rehydrateFns.push(fn);
|
||||
}
|
||||
|
||||
/** Rehydrate all registered workspace-scoped persist stores from the new namespace. */
|
||||
/**
|
||||
* Rehydrate all registered workspace-scoped persist stores from the new
|
||||
* namespace. Deferred to a microtask + deduplicated for the same reason
|
||||
* as slug subscriber notification: Zustand persist rehydrate synchronously
|
||||
* setState()s the store, which schedules updates on any component
|
||||
* subscribed to that store. Calling this from a component's render phase
|
||||
* would violate React 19's "no cross-component updates during render"
|
||||
* rule. Persist stores can tolerate one microtask of staleness — they're
|
||||
* UI preferences, not security-critical state.
|
||||
*/
|
||||
export function rehydrateAllWorkspaceStores() {
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentWorkspaceId(): string | null {
|
||||
return _currentWsId;
|
||||
if (_pendingRehydrate) return;
|
||||
_pendingRehydrate = true;
|
||||
queueMicrotask(() => {
|
||||
_pendingRehydrate = false;
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that automatically namespaces keys with the current workspace ID.
|
||||
* Reads _currentWsId at call time, so it follows workspace switches dynamically.
|
||||
* Storage that automatically namespaces keys with the current workspace slug.
|
||||
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
|
||||
*/
|
||||
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
|
||||
const resolve = (key: string) =>
|
||||
_currentWsId ? `${key}:${_currentWsId}` : key;
|
||||
_currentSlug ? `${key}:${_currentSlug}` : key;
|
||||
|
||||
return {
|
||||
getItem: (key) => adapter.getItem(resolve(key)),
|
||||
|
||||
@@ -6,13 +6,17 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { WSClient } from "../api/ws-client";
|
||||
import type { WSEventType, StorageAdapter } from "../types";
|
||||
import type { StoreApi, UseBoundStore } from "zustand";
|
||||
import type { AuthState } from "../auth/store";
|
||||
import type { WorkspaceStore } from "../workspace/store";
|
||||
import {
|
||||
getCurrentSlug,
|
||||
subscribeToCurrentSlug,
|
||||
} from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
|
||||
|
||||
@@ -31,8 +35,6 @@ export interface WSProviderProps {
|
||||
wsUrl: string;
|
||||
/** Platform-created auth store instance */
|
||||
authStore: UseBoundStore<StoreApi<AuthState>>;
|
||||
/** Platform-created workspace store instance */
|
||||
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
|
||||
/** Platform-specific storage adapter for reading auth tokens */
|
||||
storage: StorageAdapter;
|
||||
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
|
||||
@@ -45,17 +47,25 @@ export function WSProvider({
|
||||
children,
|
||||
wsUrl,
|
||||
authStore,
|
||||
workspaceStore,
|
||||
storage,
|
||||
cookieAuth,
|
||||
onToast,
|
||||
}: WSProviderProps) {
|
||||
const user = authStore((s) => s.user);
|
||||
const workspace = workspaceStore((s) => s.workspace);
|
||||
// Reactive read of the current workspace slug (URL-driven singleton in
|
||||
// packages/core/platform/workspace-storage.ts). When the workspace switches,
|
||||
// the useEffect below tears down the old WS connection and opens a new one
|
||||
// bound to the new workspace slug. SSR snapshot is `null` because this
|
||||
// provider only renders client-side under CoreProvider.
|
||||
const wsSlug = useSyncExternalStore(
|
||||
subscribeToCurrentSlug,
|
||||
getCurrentSlug,
|
||||
() => null,
|
||||
);
|
||||
const [wsClient, setWsClient] = useState<WSClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !workspace) return;
|
||||
if (!user || !wsSlug) return;
|
||||
|
||||
// In token mode we need a token from storage; in cookie mode the HttpOnly
|
||||
// cookie is sent automatically with the WS upgrade request.
|
||||
@@ -66,7 +76,7 @@ export function WSProvider({
|
||||
logger: createLogger("ws"),
|
||||
cookieAuth,
|
||||
});
|
||||
ws.setAuth(token, workspace.id);
|
||||
ws.setAuth(token, wsSlug);
|
||||
setWsClient(ws);
|
||||
ws.connect();
|
||||
|
||||
@@ -74,9 +84,9 @@ export function WSProvider({
|
||||
ws.disconnect();
|
||||
setWsClient(null);
|
||||
};
|
||||
}, [user, workspace, wsUrl, storage, cookieAuth]);
|
||||
}, [user, wsSlug, wsUrl, storage, cookieAuth]);
|
||||
|
||||
const stores: RealtimeSyncStores = { authStore, workspaceStore };
|
||||
const stores: RealtimeSyncStores = { authStore };
|
||||
|
||||
// Centralized WS -> store sync (uses state so it re-subscribes when WS changes)
|
||||
useRealtimeSync(wsClient, stores, onToast);
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { WSClient } from "../api/ws-client";
|
||||
import type { StoreApi, UseBoundStore } from "zustand";
|
||||
import type { AuthState } from "../auth/store";
|
||||
import type { WorkspaceStore } from "../workspace/store";
|
||||
import { createLogger } from "../logger";
|
||||
import { clearWorkspaceStorage } from "../platform/storage-cleanup";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
import { getCurrentWsId, getCurrentSlug } from "../platform/workspace-storage";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
@@ -23,6 +23,7 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inb
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { paths } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -54,7 +55,6 @@ const logger = createLogger("realtime-sync");
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
authStore: UseBoundStore<StoreApi<AuthState>>;
|
||||
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +79,7 @@ export function useRealtimeSync(
|
||||
stores: RealtimeSyncStores,
|
||||
onToast?: (message: string, type?: "info" | "error") => void,
|
||||
) {
|
||||
const { authStore, workspaceStore } = stores;
|
||||
const { authStore } = stores;
|
||||
const qc = useQueryClient();
|
||||
// Main sync: onAny -> refreshMap with debounce
|
||||
useEffect(() => {
|
||||
@@ -87,39 +87,39 @@ export function useRealtimeSync(
|
||||
|
||||
const refreshMap: Record<string, () => void> = {
|
||||
inbox: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onInboxInvalidate(qc, wsId);
|
||||
},
|
||||
agent: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
},
|
||||
member: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
},
|
||||
workspace: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
skill: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
},
|
||||
project: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
const userId = authStore.getState().user?.id;
|
||||
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
},
|
||||
autopilot: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
},
|
||||
};
|
||||
@@ -166,7 +166,7 @@ export function useRealtimeSync(
|
||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (!issue?.id) return;
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
onIssueUpdated(qc, wsId, issue);
|
||||
if (issue.status) {
|
||||
@@ -178,21 +178,21 @@ export function useRealtimeSync(
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (!issue) return;
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onIssueCreated(qc, wsId, issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (!issue_id) return;
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onIssueDeleted(qc, wsId, issue_id);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onInboxNew(qc, wsId, item);
|
||||
});
|
||||
|
||||
@@ -260,16 +260,34 @@ export function useRealtimeSync(
|
||||
|
||||
// --- Side-effect handlers (toast, navigation) ---
|
||||
|
||||
// After the current workspace disappears (deleted or we were kicked out),
|
||||
// navigate to another workspace the user still has access to, or to
|
||||
// onboarding. We use a full-page navigation: this reliably tears down any
|
||||
// in-flight queries / subscriptions tied to the dead workspace without
|
||||
// relying on framework-specific routers from here in core.
|
||||
const relocateAfterWorkspaceLoss = async (lostWsId: string) => {
|
||||
const wsList = await qc.fetchQuery({
|
||||
...workspaceListOptions(),
|
||||
staleTime: 0,
|
||||
});
|
||||
const next = wsList.find((w) => w.id !== lostWsId);
|
||||
const target = next ? paths.workspace(next.slug).issues() : paths.onboarding();
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(target);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
clearWorkspaceStorage(defaultStorage, workspace_id);
|
||||
const currentWs = workspaceStore.getState().workspace;
|
||||
if (currentWs?.id === workspace_id) {
|
||||
// Event payload has UUID; look up slug from cached workspace list
|
||||
// since clearWorkspaceStorage keys are namespaced by slug.
|
||||
const wsList = qc.getQueryData<{ id: string; slug: string }[]>(workspaceKeys.list()) ?? [];
|
||||
const deletedSlug = wsList.find((w) => w.id === workspace_id)?.slug;
|
||||
if (deletedSlug) clearWorkspaceStorage(defaultStorage, deletedSlug);
|
||||
if (getCurrentWsId() === workspace_id) {
|
||||
logger.warn("current workspace deleted, switching");
|
||||
onToast?.("This workspace was deleted", "info");
|
||||
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
|
||||
workspaceStore.getState().hydrateWorkspace(wsList);
|
||||
});
|
||||
relocateAfterWorkspaceLoss(workspace_id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -277,13 +295,14 @@ export function useRealtimeSync(
|
||||
const { user_id } = p as MemberRemovedPayload;
|
||||
const myUserId = authStore.getState().user?.id;
|
||||
if (user_id === myUserId) {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
|
||||
logger.warn("removed from workspace, switching");
|
||||
onToast?.("You were removed from this workspace", "info");
|
||||
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
|
||||
workspaceStore.getState().hydrateWorkspace(wsList);
|
||||
});
|
||||
const slug = getCurrentSlug();
|
||||
const wsId = getCurrentWsId();
|
||||
if (slug && wsId) {
|
||||
clearWorkspaceStorage(defaultStorage, slug);
|
||||
logger.warn("removed from workspace, switching");
|
||||
onToast?.("You were removed from this workspace", "info");
|
||||
relocateAfterWorkspaceLoss(wsId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -312,14 +331,14 @@ export function useRealtimeSync(
|
||||
|
||||
// invitation:accepted / declined / revoked — refresh invitation lists
|
||||
const unsubInvitationAccepted = ws.on("invitation:accepted", () => {
|
||||
const currentWsId = workspaceStore.getState().workspace?.id;
|
||||
const currentWsId = getCurrentWsId();
|
||||
if (currentWsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(currentWsId) });
|
||||
}
|
||||
});
|
||||
const unsubInvitationDeclined = ws.on("invitation:declined", () => {
|
||||
const currentWsId = workspaceStore.getState().workspace?.id;
|
||||
const currentWsId = getCurrentWsId();
|
||||
if (currentWsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
|
||||
}
|
||||
@@ -357,11 +376,11 @@ export function useRealtimeSync(
|
||||
|
||||
// Helpers reused by chat lifecycle handlers.
|
||||
const invalidatePendingAggregate = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
const id = getCurrentWsId();
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
const id = getCurrentWsId();
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
@@ -457,7 +476,7 @@ export function useRealtimeSync(
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
}, [ws, qc, authStore, workspaceStore, onToast]);
|
||||
}, [ws, qc, authStore, onToast]);
|
||||
|
||||
// Reconnect -> refetch all data to recover missed events
|
||||
useEffect(() => {
|
||||
@@ -466,7 +485,7 @@ export function useRealtimeSync(
|
||||
const unsub = ws.onReconnect(async () => {
|
||||
logger.info("reconnected, refetching all data");
|
||||
try {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
||||
@@ -484,5 +503,5 @@ export function useRealtimeSync(
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, [ws, qc, workspaceStore]);
|
||||
}, [ws, qc]);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
export * from "./store";
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./hooks";
|
||||
|
||||
import type { createWorkspaceStore as CreateWorkspaceStoreFn } from "./store";
|
||||
|
||||
type WorkspaceStoreInstance = ReturnType<typeof CreateWorkspaceStoreFn>;
|
||||
|
||||
/** Module-level singleton — set once at app boot via `registerWorkspaceStore()`. */
|
||||
let _store: WorkspaceStoreInstance | null = null;
|
||||
|
||||
/**
|
||||
* Register the workspace store instance created by the app.
|
||||
* Must be called at boot before any component renders.
|
||||
*/
|
||||
export function registerWorkspaceStore(store: WorkspaceStoreInstance) {
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton accessor — a Zustand hook backed by the registered instance.
|
||||
* Supports `useWorkspaceStore(selector)` and `useWorkspaceStore.getState()`.
|
||||
*/
|
||||
export const useWorkspaceStore: WorkspaceStoreInstance = new Proxy(
|
||||
(() => {}) as unknown as WorkspaceStoreInstance,
|
||||
{
|
||||
apply(_target, _thisArg, args) {
|
||||
if (!_store)
|
||||
throw new Error(
|
||||
"Workspace store not initialised — call registerWorkspaceStore() first",
|
||||
);
|
||||
return (_store as unknown as (...a: unknown[]) => unknown)(...args);
|
||||
},
|
||||
get(_target, prop) {
|
||||
// Allow property inspection (HMR/React Refresh) before registration
|
||||
if (!_store) return undefined;
|
||||
return Reflect.get(_store, prop);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Workspace } from "../types";
|
||||
import { api } from "../api";
|
||||
import { workspaceKeys, workspaceListOptions } from "./queries";
|
||||
import { useWorkspaceStore } from "./index";
|
||||
import { workspaceKeys } from "./queries";
|
||||
|
||||
export function useCreateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; slug: string; description?: string }) =>
|
||||
api.createWorkspace(data),
|
||||
// Seed the workspace list cache BEFORE callers navigate to /{newWs.slug}/issues.
|
||||
// The destination [workspaceSlug]/layout queries by slug from this cache;
|
||||
// without seeding, it would briefly show "loading" before the background
|
||||
// invalidation completes. TanStack Query guarantees this onSuccess runs
|
||||
// before mutateAsync's resolver / before any callback-style onSuccess
|
||||
// passed to mutate(), so any caller that navigates after the mutation
|
||||
// resolves will see the seeded data synchronously. Switching workspaces
|
||||
// is pure navigation now — no imperative store writes needed.
|
||||
onSuccess: (newWs) => {
|
||||
// Add to cache before switching so sidebar list is consistent on first render
|
||||
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] = []) => [...old, newWs]);
|
||||
useWorkspaceStore.getState().switchWorkspace(newWs);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
@@ -24,14 +29,6 @@ export function useLeaveWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
|
||||
onSuccess: async (_, workspaceId) => {
|
||||
const currentWsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (currentWsId === workspaceId) {
|
||||
// staleTime: 0 forces a real network fetch — cache still has the left workspace
|
||||
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
@@ -42,14 +39,6 @@ export function useDeleteWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
|
||||
onSuccess: async (_, workspaceId) => {
|
||||
const currentWsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (currentWsId === workspaceId) {
|
||||
// staleTime: 0 forces a real network fetch — cache still has the deleted workspace
|
||||
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { Workspace } from "../types";
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||
@@ -19,6 +20,14 @@ export function workspaceListOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the workspace whose slug matches, from the cached workspace list. */
|
||||
export function workspaceBySlugOptions(slug: string) {
|
||||
return queryOptions({
|
||||
...workspaceListOptions(),
|
||||
select: (list: Workspace[]) => list.find((w) => w.slug === slug) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function memberListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.members(wsId),
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, StorageAdapter } from "../types";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { createLogger } from "../logger";
|
||||
import { setCurrentWorkspaceId, rehydrateAllWorkspaceStores } from "../platform/workspace-storage";
|
||||
|
||||
const logger = createLogger("workspace-store");
|
||||
|
||||
interface WorkspaceStoreOptions {
|
||||
storage?: StorageAdapter;
|
||||
}
|
||||
|
||||
interface WorkspaceState {
|
||||
workspace: Workspace | null;
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
/**
|
||||
* Pick a workspace from a list and set it as current.
|
||||
* The list itself is NOT stored here — it lives in React Query.
|
||||
*/
|
||||
hydrateWorkspace: (
|
||||
wsList: Workspace[],
|
||||
preferredWorkspaceId?: string | null,
|
||||
) => Workspace | null;
|
||||
/** Switch to a workspace. Caller provides the full object (from React Query). */
|
||||
switchWorkspace: (ws: Workspace) => void;
|
||||
/** Update current workspace data in place (e.g. after rename). */
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
clearWorkspace: () => void;
|
||||
}
|
||||
|
||||
export type WorkspaceStore = WorkspaceState & WorkspaceActions;
|
||||
|
||||
export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) {
|
||||
const storage = options?.storage;
|
||||
|
||||
return create<WorkspaceStore>((set) => ({
|
||||
// Only the currently selected workspace (UI state).
|
||||
// The workspace list is server state and lives in React Query.
|
||||
workspace: null,
|
||||
|
||||
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
|
||||
const nextWorkspace =
|
||||
(preferredWorkspaceId
|
||||
? wsList.find((item) => item.id === preferredWorkspaceId)
|
||||
: null) ??
|
||||
wsList[0] ??
|
||||
null;
|
||||
|
||||
if (!nextWorkspace) {
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspaceId(null);
|
||||
rehydrateAllWorkspaceStores();
|
||||
storage?.removeItem("multica_workspace_id");
|
||||
set({ workspace: null });
|
||||
return null;
|
||||
}
|
||||
|
||||
api.setWorkspaceId(nextWorkspace.id);
|
||||
setCurrentWorkspaceId(nextWorkspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
storage?.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: (ws) => {
|
||||
logger.info("switching to", ws.id);
|
||||
api.setWorkspaceId(ws.id);
|
||||
setCurrentWorkspaceId(ws.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
storage?.setItem("multica_workspace_id", ws.id);
|
||||
set({ workspace: ws });
|
||||
},
|
||||
|
||||
updateWorkspace: (ws) => {
|
||||
set((state) => ({
|
||||
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
||||
}));
|
||||
},
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspaceId(null);
|
||||
rehydrateAllWorkspaceStores();
|
||||
set({ workspace: null });
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -353,7 +353,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
delay = 200,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
@@ -50,13 +50,12 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-lg border border-border bg-popover px-2.5 py-1 text-xs text-popover-foreground has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-priority: var(--priority);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
@@ -86,7 +85,6 @@
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
--brand: oklch(0.55 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.95 0.002 286);
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.75 0.16 85);
|
||||
--info: oklch(0.55 0.18 250);
|
||||
@@ -130,7 +128,6 @@
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--brand: oklch(0.65 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.2 0.005 286);
|
||||
--success: oklch(0.65 0.15 145);
|
||||
--warning: oklch(0.70 0.16 85);
|
||||
--info: oklch(0.65 0.18 250);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
import { AgentListItem } from "./agent-list-item";
|
||||
import { AgentDetail } from "./agent-detail";
|
||||
@@ -140,28 +141,28 @@ export function AgentsPage() {
|
||||
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
|
||||
{/* Left column — agent list */}
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<PageHeader className="justify-between">
|
||||
<h1 className="text-sm font-semibold">Agents</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
{archivedCount > 0 && (
|
||||
<Button
|
||||
variant={showArchived ? "secondary" : "ghost"}
|
||||
size="icon-xs"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
title={showArchived ? "Show active agents" : "Show archived agents"}
|
||||
>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
<Archive className="text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
<Plus className="text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Bot className="h-8 w-8 text-muted-foreground/40" />
|
||||
|
||||
@@ -125,7 +125,7 @@ export function SkillsTab({
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
size="icon-sm"
|
||||
onClick={() => handleRemove(skill.id)}
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
|
||||
@@ -8,7 +8,6 @@ import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockSendCode = vi.hoisted(() => vi.fn());
|
||||
const mockVerifyCode = vi.hoisted(() => vi.fn());
|
||||
const mockHydrateWorkspace = vi.hoisted(() => vi.fn());
|
||||
const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
|
||||
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
|
||||
const mockApiSetToken = vi.hoisted(() => vi.fn());
|
||||
@@ -39,20 +38,6 @@ vi.mock("@multica/core/auth", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
useWorkspaceStore: Object.assign(
|
||||
(selector?: (s: unknown) => unknown) => {
|
||||
const state = { hydrateWorkspace: mockHydrateWorkspace };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{
|
||||
getState: () => ({
|
||||
hydrateWorkspace: mockHydrateWorkspace,
|
||||
}),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: mockApiListWorkspaces,
|
||||
@@ -224,11 +209,10 @@ describe("LoginPage", () => {
|
||||
// Code verification
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("calls verifyCode, listWorkspaces, hydrateWorkspace, then onSuccess", async () => {
|
||||
it("calls verifyCode, seeds workspace list cache, then onSuccess", async () => {
|
||||
mockSendCode.mockResolvedValueOnce(undefined);
|
||||
mockVerifyCode.mockResolvedValueOnce(undefined);
|
||||
mockApiListWorkspaces.mockResolvedValueOnce([{ id: "ws-1" }]);
|
||||
mockHydrateWorkspace.mockReturnValueOnce({ id: "ws-1" });
|
||||
|
||||
render(<LoginPage onSuccess={onSuccess} />);
|
||||
|
||||
@@ -253,9 +237,11 @@ describe("LoginPage", () => {
|
||||
"123456",
|
||||
);
|
||||
expect(mockApiListWorkspaces).toHaveBeenCalled();
|
||||
expect(mockHydrateWorkspace).toHaveBeenCalledWith(
|
||||
// The workspace list is seeded into React Query so onSuccess can read
|
||||
// it synchronously to compute a destination URL.
|
||||
expect(mockSetQueryData).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(["workspaces", "list"]),
|
||||
[{ id: "ws-1" }],
|
||||
undefined,
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
@@ -620,7 +606,6 @@ describe("LoginPage", () => {
|
||||
mockSendCode.mockResolvedValueOnce(undefined);
|
||||
mockVerifyCode.mockResolvedValueOnce(undefined);
|
||||
mockApiListWorkspaces.mockResolvedValueOnce([{ id: "ws-1" }]);
|
||||
mockHydrateWorkspace.mockReturnValueOnce({ id: "ws-1" });
|
||||
const onTokenObtained = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -674,43 +659,6 @@ describe("LoginPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// lastWorkspaceId
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("passes lastWorkspaceId to hydrateWorkspace", async () => {
|
||||
mockSendCode.mockResolvedValueOnce(undefined);
|
||||
mockVerifyCode.mockResolvedValueOnce(undefined);
|
||||
mockApiListWorkspaces.mockResolvedValueOnce([
|
||||
{ id: "ws-1" },
|
||||
{ id: "ws-2" },
|
||||
]);
|
||||
mockHydrateWorkspace.mockReturnValueOnce({ id: "ws-2" });
|
||||
|
||||
render(
|
||||
<LoginPage onSuccess={onSuccess} lastWorkspaceId="ws-2" />,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText(/email/i), "test@example.com");
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/check your email/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const otpInput = getOTPInput();
|
||||
await user.type(otpInput, "123456");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHydrateWorkspace).toHaveBeenCalledWith(
|
||||
[{ id: "ws-1" }, { id: "ws-2" }],
|
||||
"ws-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
InputOTPSlot,
|
||||
} from "@multica/ui/components/ui/input-otp";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { User } from "@multica/core/types";
|
||||
@@ -45,14 +44,13 @@ interface CliCallbackConfig {
|
||||
interface LoginPageProps {
|
||||
/** Logo element rendered above the title */
|
||||
logo?: ReactNode;
|
||||
/** Called after successful login + workspace hydration */
|
||||
/** Called after successful login. The workspace list is seeded into React
|
||||
* Query before this fires, so the caller can compute a destination URL. */
|
||||
onSuccess: () => void;
|
||||
/** Google OAuth config. Omit to disable Google login. */
|
||||
google?: GoogleAuthConfig;
|
||||
/** CLI callback config for authorizing CLI tools. */
|
||||
cliCallback?: CliCallbackConfig;
|
||||
/** Preferred workspace ID to restore after login. */
|
||||
lastWorkspaceId?: string | null;
|
||||
/** Called after a token is obtained (e.g. to set cookies). */
|
||||
onTokenObtained?: () => void;
|
||||
/** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */
|
||||
@@ -98,7 +96,6 @@ export function LoginPage({
|
||||
onSuccess,
|
||||
google,
|
||||
cliCallback,
|
||||
lastWorkspaceId,
|
||||
onTokenObtained,
|
||||
onGoogleLogin,
|
||||
}: LoginPageProps) {
|
||||
@@ -200,11 +197,12 @@ export function LoginPage({
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal path
|
||||
// Normal path: seed the workspace list into the Query cache so the
|
||||
// caller's onSuccess can read it synchronously to compute a destination
|
||||
// URL (first workspace's slug, or onboarding).
|
||||
await useAuthStore.getState().verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWorkspaceId);
|
||||
onTokenObtained?.();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
@@ -215,7 +213,7 @@ export function LoginPage({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
|
||||
[email, onSuccess, cliCallback, onTokenObtained, qc],
|
||||
);
|
||||
|
||||
const handleResend = async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user