Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
5ab3632d2c fix(auth): support non-localhost CLI callback for self-hosted VMs
The CLI auth callback was hardcoded to localhost, breaking self-hosted
setups where the browser runs on a different machine than the CLI.

- CLI: derive callback host from configured app URL; bind to 0.0.0.0
  when the app URL is not localhost so remote browsers can reach it
- Frontend: expand validateCliCallback to accept RFC 1918 private IPs
  (10.x, 172.16-31.x, 192.168.x) in addition to localhost

Closes #923
2026-04-14 13:37:08 +08:00
346 changed files with 3663 additions and 21604 deletions

View File

@@ -55,10 +55,8 @@ ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# Only set explicitly if frontend and backend are on different domains.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)

View File

@@ -3,17 +3,6 @@ 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:

View File

@@ -3,17 +3,6 @@ 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:

View File

@@ -35,13 +35,11 @@ Closes #
## Checklist
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure

7
.gitignore vendored
View File

@@ -12,17 +12,10 @@ build
bin
dist-electron
*.tsbuildinfo
# ...except electron-builder's source resources dir, which holds tracked
# config files (entitlements, icons) — not build output.
!apps/desktop/build/
!apps/desktop/build/**
# env
.env*
!.env.example
# Desktop production config is public (backend URL, etc.) — track it so
# `pnpm package` produces a release-ready build without extra setup.
!apps/desktop/.env.production
# test coverage
coverage

View File

@@ -9,7 +9,6 @@ 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
@@ -309,199 +308,6 @@ 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

View File

@@ -1,383 +0,0 @@
# 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-723slug 不在 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"); // 硬跳 /issuesworkspace-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`
- 切到 BURL 不变 → 访问失败 / 显示错数据
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 三个具体 bughook 抽象作为后续迭代。
### 改动范围
| 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 Bvisibilitychange 触发 invalidate—— 代码最少、收益最明显,能当天止血
2. **同步开始**:任务 3navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
3. **规划立项**:任务 2URL 化)—— 大改造,需要单独开一个 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 已存在
```

View File

@@ -104,8 +104,6 @@ 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) & \

View File

@@ -115,21 +115,6 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
| **Deployment** | Cloud-first | Local-first |
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
| **Extensibility** | Skills system | Skills + Plugin system |
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
@@ -184,13 +169,3 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -117,21 +117,6 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
大功告成!你的 Agent 现在是团队的一员了。 🎉
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
| **部署** | 云端优先 | 本地优先 |
| **管理深度** | 轻量Issue / Project / Labels | 重度(组织架构 / 审批 / 预算) |
| **扩展** | Skills 系统 | Skills + 插件系统 |
**简单来说Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
## 架构
```
@@ -172,13 +157,3 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -1,12 +0,0 @@
# Production environment for `pnpm package` / `pnpm build`.
# electron-vite (Vite under the hood) reads this automatically in
# production mode and inlines the values into the renderer bundle via
# import.meta.env.VITE_*. These are public URLs, not secrets.
# Backend API + websocket the desktop app talks to.
VITE_API_URL=https://api.multica.ai
VITE_WS_URL=wss://api.multica.ai/ws
# Public web app URL — used to build shareable links like "Copy link to
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
VITE_APP_URL=https://multica.ai

View File

@@ -4,5 +4,3 @@ out
.DS_Store
.eslintcache
*.log*
# CLI binary bundled at build time (from server/bin/)
resources/bin/

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron / V8 need JIT and unsigned executable memory under the
hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Required so the app can spawn the bundled `multica` Go binary and
any other child processes (e.g. agent CLIs) without Gatekeeper
blocking exec. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- Network client — the daemon talks to the backend + GitHub releases. -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -19,16 +19,10 @@ mac:
target:
- dmg
- zip
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
# `${name}` produces for scoped package names.
artifactName: multica-desktop-${version}-${arch}.${ext}
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
# unaffected because `pnpm package` already requires the Developer ID
# signing cert — notarization is a strict superset.
notarize: true
artifactName: ${name}-${version}-${arch}.${ext}
notarize: false
dmg:
artifactName: multica-desktop-${version}-${arch}.${ext}
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage

View File

@@ -1,26 +1,41 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const remoteApi = env.VITE_REMOTE_API;
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
return {
main: {
plugins: [externalizeDepsPlugin()],
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
...(remoteApi && {
proxy: {
"/api": { target: remoteApi, changeOrigin: true },
"/auth": { target: remoteApi, changeOrigin: true },
"/uploads": { target: remoteApi, changeOrigin: true },
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
},
}),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
},
dedupe: ["react", "react-dom"],
},
},
};
});

View File

@@ -1,13 +1,6 @@
import globals from "globals";
import reactConfig from "@multica/eslint-config/react";
export default [
...reactConfig,
{ ignores: ["out/", "dist/"] },
{
files: ["scripts/**/*.{mjs,js}"],
languageOptions: {
globals: { ...globals.node },
},
},
];

View File

@@ -4,16 +4,15 @@
"private": true,
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"dev": "pnpm run bundle-cli && electron-vite dev",
"build": "pnpm run bundle-cli && electron-vite build",
"dev": "electron-vite dev",
"dev:remote": "electron-vite dev --mode remote",
"build": "electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "node scripts/package.mjs",
"package": "electron-builder",
"lint": "eslint .",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
@@ -23,13 +22,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@multica/core": "workspace:*",
"@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",
@@ -39,8 +37,6 @@
"@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:",
@@ -48,11 +44,9 @@
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env node
// Builds the `multica` CLI from server/cmd/multica and copies the binary
// into apps/desktop/resources/bin/ so electron-vite (dev) and electron-
// builder (prod) pick it up. Running this on every dev/build/package
// invocation guarantees the bundled CLI always matches the current Go
// source — no more stale binary surprises. Go's build cache makes the
// no-op case (nothing changed) effectively free.
//
// ldflags mirror `make build` so `multica --version` reports a meaningful
// version / commit / date.
//
// Graceful: if `go` is not installed (e.g. frontend-only contributor), we
// skip the build and fall through to auto-install at runtime. A genuine
// Go compile error is fatal — you want that to block dev, not hide.
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
const binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
function sh(cmd) {
try {
return execSync(cmd, { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
function hasGo() {
try {
execSync("go version", { stdio: "pipe" });
return true;
} catch {
return false;
}
}
async function exists(p) {
try {
await access(p, constants.F_OK);
return true;
} catch {
return false;
}
}
if (hasGo()) {
const version = sh("git describe --tags --always --dirty") || "dev";
const commit = sh("git rev-parse --short HEAD") || "unknown";
const date = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
);
execFileSync(
"go",
[
"build",
"-ldflags",
ldflags,
"-o",
join("bin", binName),
"./cmd/multica",
],
{ cwd: serverDir, stdio: "inherit" },
);
} else {
console.warn(
"[bundle-cli] `go` not found in PATH — skipping CLI build. " +
"Desktop will use whatever is already in resources/bin/, or fall back " +
"to auto-installing the latest release at runtime.",
);
}
if (!(await exists(srcBinary))) {
console.warn(
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
`auto-installing the latest release at runtime.`,
);
process.exit(0);
}
await mkdir(destDir, { recursive: true });
await copyFile(srcBinary, destBinary);
await chmod(destBinary, 0o755);
// macOS: ad-hoc sign so Gatekeeper doesn't complain when the parent app
// (which itself may be unsigned in dev) spawns the child.
if (process.platform === "darwin") {
try {
execSync(`codesign -s - --force ${JSON.stringify(destBinary)}`, {
stdio: "pipe",
});
} catch {
// Non-fatal. Unsigned binaries still run when the parent app is trusted.
}
}
console.log(`[bundle-cli] bundled ${srcBinary}${destBinary}`);

View File

@@ -1,149 +0,0 @@
#!/usr/bin/env node
// Wrapper around `electron-builder` that keeps the Desktop version in
// lockstep with the CLI. Both are derived from `git describe --tags
// --always --dirty` — the same source GoReleaser reads for the CLI
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
// into resources/bin/), then `electron-vite build` to produce the
// main/preload/renderer bundles under out/, then invokes electron-builder
// with `-c.extraMetadata.version=<derived>` so the override applies at
// build time without mutating the tracked package.json.
//
// 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`). 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.
import { execFileSync, spawnSync, execSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(here, "..");
function sh(cmd) {
try {
return execSync(cmd, { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
/**
* Pure transformation from the `git describe --tags --always --dirty`
* output to the value we feed into electron-builder's extraMetadata.version.
*
* - empty input → null (caller should fall back)
* - "v0.1.36" → "0.1.36"
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
*
* Leading `v` is stripped so the result is valid semver for package.json.
*/
export function normalizeGitVersion(raw) {
if (!raw) return null;
const stripped = raw.replace(/^v/, "");
if (!/^\d/.test(stripped)) {
// No reachable tag — `git describe` fell back to just the commit hash.
return `0.0.0-${stripped}`;
}
return stripped;
}
function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function main() {
// Step 1: build + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
// 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)`);
} else {
console.warn(
"[package] could not derive version from git; falling back to package.json",
);
}
// Step 4: assemble electron-builder args.
const passthrough = process.argv.slice(2);
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
// credentials, and would otherwise hit a hard failure at the notarize
// step. Detect the missing env and flip notarize off for this run only.
if (!process.env.APPLE_TEAM_ID) {
console.warn(
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
);
builderArgs.push("-c.mac.notarize=false");
}
builderArgs.push(...passthrough);
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
process.exit(1);
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main();
}

View File

@@ -1,39 +0,0 @@
import { describe, it, expect } from "vitest";
import { normalizeGitVersion } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
expect(normalizeGitVersion("")).toBe(null);
expect(normalizeGitVersion(null)).toBe(null);
expect(normalizeGitVersion(undefined)).toBe(null);
});
it("strips the leading v on a clean tag", () => {
expect(normalizeGitVersion("v0.1.36")).toBe("0.1.36");
expect(normalizeGitVersion("v1.0.0")).toBe("1.0.0");
});
it("preserves the prerelease suffix between tags", () => {
expect(normalizeGitVersion("v0.1.35-14-gf1415e96")).toBe(
"0.1.35-14-gf1415e96",
);
});
it("preserves the dirty suffix on a modified worktree", () => {
expect(normalizeGitVersion("v0.1.35-14-gf1415e96-dirty")).toBe(
"0.1.35-14-gf1415e96-dirty",
);
});
it("handles v-prefixed prerelease tags", () => {
expect(normalizeGitVersion("v1.0.0-alpha")).toBe("1.0.0-alpha");
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
});
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
// `git describe --tags --always` returns just the short commit hash
// when there are no tags in the history at all.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
});
});

View File

@@ -1,173 +0,0 @@
import { app } from "electron";
import { execFile } from "child_process";
import { createHash } from "crypto";
import { createReadStream, createWriteStream, existsSync } from "fs";
import { chmod, mkdir, rename, rm } from "fs/promises";
import { join, dirname } from "path";
import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
export function managedCliPath(): string {
return join(app.getPath("userData"), "bin", binaryName());
}
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
});
}
async function downloadToFile(url: string, dest: string): Promise<void> {
const res = await fetch(url, { redirect: "follow" });
if (!res.ok || !res.body) {
throw new Error(`download failed: ${res.status} ${res.statusText}`);
}
await mkdir(dirname(dest), { recursive: true });
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
await pipeline(nodeStream, createWriteStream(dest));
}
// Fetch goreleaser's published checksums.txt and parse it into a
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
async function fetchChecksums(): Promise<Map<string, string>> {
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
const res = await fetch(url, { redirect: "follow" });
if (!res.ok) {
throw new Error(
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
);
}
const text = await res.text();
const map = new Map<string, string>();
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
if (match) map.set(match[2], match[1].toLowerCase());
}
return map;
}
async function sha256OfFile(path: string): Promise<string> {
const hash = createHash("sha256");
await pipeline(createReadStream(path), hash);
return hash.digest("hex");
}
async function verifyChecksum(
archivePath: string,
assetName: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
}
async function extractArchive(archive: string, dest: string): Promise<void> {
await mkdir(dest, { recursive: true });
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
// - macOS/Linux: GNU tar or bsdtar
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
await run("tar", ["-xf", archive, "-C", dest]);
}
async function installFresh(): Promise<string> {
const target = managedCliPath();
const assetName = platformAssetName();
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
await mkdir(workDir, { recursive: true });
try {
const archivePath = join(workDir, assetName);
console.log(`[cli-bootstrap] downloading ${url}`);
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
const extractedBin = join(workDir, binaryName());
if (!existsSync(extractedBin)) {
throw new Error(
`archive ${assetName} did not contain ${binaryName()} at its root`,
);
}
await mkdir(dirname(target), { recursive: true });
await rename(extractedBin, target);
await chmod(target, 0o755);
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
if (process.platform === "darwin") {
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
});
}
console.log(`[cli-bootstrap] installed CLI at ${target}`);
return target;
} finally {
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Returns the path to a usable `multica` binary. If one is already present at
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(): Promise<string> {
const target = managedCliPath();
if (existsSync(target)) return target;
return installFresh();
}

View File

@@ -1,902 +0,0 @@
import { app, ipcMain, BrowserWindow } from "electron";
import { execFile } from "child_process";
import {
readFile,
writeFile,
mkdir,
rm,
open,
stat,
} from "fs/promises";
import {
existsSync,
watchFile,
unwatchFile,
type StatsListener,
} from "fs";
import { join } from "path";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
const DEFAULT_HEALTH_PORT = 19514;
const POLL_INTERVAL_MS = 5_000;
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
const LOG_TAIL_RETRY_MS = 2_000;
const LOG_TAIL_MAX_RETRIES = 5;
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
interface ActiveProfile {
name: string; // "" = default profile
port: number;
}
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
let logTailWatcher: { path: string; listener: StatsListener } | null = null;
let currentState: DaemonStatus["state"] = "installing_cli";
let getMainWindow: () => BrowserWindow | null = () => null;
let operationInProgress = false;
let cachedCliBinary: string | null | undefined = undefined;
let cliResolvePromise: Promise<string | null> | null = null;
let cachedCliBinaryVersion: string | null | undefined = undefined;
// Set when a CLI version mismatch was detected but the running daemon is
// busy executing tasks. The poll loop retries the check on each tick and
// fires the restart once active_task_count drops to 0.
let pendingVersionRestart = false;
let targetApiBaseUrl: string | null = null;
let activeProfile: ActiveProfile | null = null;
// Serialize all writes to any profile config file. Multiple paths
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
// may try to write concurrently; chaining them avoids interleaved writes
// corrupting the JSON.
let configWriteChain: Promise<void> = Promise.resolve();
// Keep the Go impl in sync: server/cmd/multica/cmd_daemon.go healthPortForProfile.
function healthPortForProfile(profile: string): number {
if (!profile) return DEFAULT_HEALTH_PORT;
let sum = 0;
for (const b of Buffer.from(profile, "utf-8")) sum += b;
return DEFAULT_HEALTH_PORT + 1 + (sum % 1000);
}
function profileDir(profile: string): string {
return profile
? join(homedir(), ".multica", "profiles", profile)
: join(homedir(), ".multica");
}
function profileConfigPath(profile: string): string {
return join(profileDir(profile), "config.json");
}
function profileLogPath(profile: string): string {
return join(profileDir(profile), "daemon.log");
}
// Sidecar file that records which Multica user the cached PAT in config.json
// was minted for. The Go CLI/daemon never read or write this file, so it
// survives Go-side config rewrites. Used to detect user switches and mint a
// fresh PAT instead of reusing a token that belongs to a previous user.
function profileUserIdPath(profile: string): string {
return join(profileDir(profile), ".desktop-user-id");
}
async function readProfileUserId(profile: string): Promise<string | null> {
try {
const raw = await readFile(profileUserIdPath(profile), "utf-8");
const trimmed = raw.trim();
return trimmed || null;
} catch {
return null;
}
}
async function writeProfileUserId(
profile: string,
userId: string,
): Promise<void> {
await mkdir(profileDir(profile), { recursive: true });
await writeFile(profileUserIdPath(profile), userId, "utf-8");
}
async function removeProfileUserId(profile: string): Promise<void> {
try {
await rm(profileUserIdPath(profile));
} catch {
// Already gone — nothing to do.
}
}
function normalizeUrl(u: string): string {
if (!u) return "";
try {
const parsed = new URL(u);
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
} catch {
return u.replace(/\/+$/, "").toLowerCase();
}
}
function urlsMatch(a: string, b: string): boolean {
const na = normalizeUrl(a);
const nb = normalizeUrl(b);
return na.length > 0 && na === nb;
}
function sendStatus(status: DaemonStatus): void {
const win = getMainWindow();
win?.webContents.send("daemon:status", status);
}
interface HealthPayload {
status?: string;
pid?: number;
uptime?: string;
daemon_id?: string;
device_name?: string;
server_url?: string;
cli_version?: string;
active_task_count?: number;
agents?: string[];
workspaces?: unknown[];
}
async function fetchHealthAtPort(
port: number,
): Promise<HealthPayload | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_000);
const res = await fetch(`http://127.0.0.1:${port}/health`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) return null;
return (await res.json()) as HealthPayload;
} catch {
return null;
}
}
// Desktop owns a dedicated CLI profile named after the target API host, so it
// never reads or writes the user's hand-configured profiles. Profile dir:
// ~/.multica/profiles/desktop-<host>/
function deriveProfileName(targetUrl: string): string {
try {
const url = new URL(targetUrl);
const host = url.host.replace(/:/g, "-").toLowerCase();
return `desktop-${host}`;
} catch {
return "desktop";
}
}
async function readProfileConfig(
profile: string,
): Promise<Record<string, unknown>> {
try {
const raw = await readFile(profileConfigPath(profile), "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
async function writeProfileConfig(
profile: string,
cfg: Record<string, unknown>,
): Promise<void> {
const op = async () => {
await mkdir(profileDir(profile), { recursive: true });
await writeFile(
profileConfigPath(profile),
JSON.stringify(cfg, null, 2),
"utf-8",
);
};
const next = configWriteChain.catch(() => {}).then(op);
configWriteChain = next.catch(() => {});
return next;
}
/**
* Returns the Desktop-owned profile for the current target API URL. Creates
* the profile's config.json on demand with `server_url` pinned to the target.
*
* This function never falls back to the default profile, and never touches a
* profile whose name doesn't start with `desktop-`, so the user's manually
* configured CLI profiles are untouched.
*/
async function resolveActiveProfile(): Promise<ActiveProfile> {
const target = targetApiBaseUrl;
if (!target) return { name: "", port: DEFAULT_HEALTH_PORT };
const name = deriveProfileName(target);
const cfg = await readProfileConfig(name);
if (cfg.server_url !== target) {
cfg.server_url = target;
await writeProfileConfig(name, cfg);
console.log(`[daemon] initialized profile "${name}" → ${target}`);
}
return { name, port: healthPortForProfile(name) };
}
async function ensureActiveProfile(): Promise<ActiveProfile> {
if (activeProfile) return activeProfile;
activeProfile = await resolveActiveProfile();
return activeProfile;
}
function invalidateActiveProfile(): void {
activeProfile = null;
}
async function fetchHealth(): Promise<DaemonStatus> {
// While the CLI is being downloaded or has permanently failed, short-circuit
// polling — there's nothing to probe yet and /health calls would just return
// "stopped", which would overwrite the correct setup state in the UI.
if (currentState === "installing_cli" || currentState === "cli_not_found") {
return { state: currentState };
}
const active = await ensureActiveProfile();
const data = await fetchHealthAtPort(active.port);
if (!data || data.status !== "running") {
return {
state: currentState === "starting" ? "starting" : "stopped",
profile: active.name,
};
}
// Safety: if we have a target URL and the daemon on our port reports a
// different server_url, it's not "our" daemon — drop it and re-resolve.
if (
targetApiBaseUrl &&
data.server_url &&
!urlsMatch(data.server_url, targetApiBaseUrl)
) {
invalidateActiveProfile();
return { state: "stopped" };
}
return {
state: "running",
pid: data.pid,
uptime: data.uptime,
daemonId: data.daemon_id,
deviceName: data.device_name,
agents: data.agents ?? [],
workspaceCount: Array.isArray(data.workspaces)
? data.workspaces.length
: 0,
profile: active.name,
serverUrl: data.server_url,
};
}
function findCliOnPath(): string | null {
const candidates = process.platform === "win32" ? ["multica.exe"] : ["multica"];
const paths = (process.env["PATH"] ?? "").split(
process.platform === "win32" ? ";" : ":",
);
if (process.platform === "darwin") {
paths.push("/opt/homebrew/bin", "/usr/local/bin");
}
for (const name of candidates) {
for (const dir of paths) {
const full = join(dir, name);
if (existsSync(full)) return full;
}
}
return null;
}
/**
* Returns the path to the CLI binary bundled inside the Desktop app.
*
* - Dev (`electron-vite dev`): `app.getAppPath()` → `apps/desktop`, resolving
* to `apps/desktop/resources/bin/multica`. `bundle-cli.mjs` populates this
* before dev starts, so iterating on Go changes is "make build → restart".
* - Packaged: `app.getAppPath()` → `<Multica.app>/Contents/Resources/app.asar`.
* electron-builder's `asarUnpack: resources/**` extracts the binary to
* `app.asar.unpacked/`, so we swap the path segment to execute it.
*/
function bundledCliPath(): string {
const binName = process.platform === "win32" ? "multica.exe" : "multica";
return join(app.getAppPath(), "resources", "bin", binName).replace(
"app.asar",
"app.asar.unpacked",
);
}
/**
* Returns a usable `multica` binary path. Priority:
* 1. Cached result from a previous successful resolve.
* 2. Bundled binary shipped with the Desktop app (`bundle-cli.mjs`).
* 3. Managed binary already installed in userData (`managedCliPath`).
* 4. Download + install latest release into userData.
* 5. `multica` on PATH (dev convenience / user-installed via brew).
* Returns `null` only when all of the above fail.
*
* Bundled is preferred so Desktop iterates in lockstep with Go changes in
* the same repo — avoids the 404 / stale-API problem when the Desktop's
* TS side is ahead of the last published CLI release.
*
* This function is idempotent and safe to call concurrently — in-flight
* installs are de-duplicated via `cliResolvePromise`.
*/
async function resolveCliBinary(): Promise<string | null> {
if (cachedCliBinary !== undefined) return cachedCliBinary;
if (cliResolvePromise) return cliResolvePromise;
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
}
const managed = managedCliPath();
if (existsSync(managed)) {
cachedCliBinary = managed;
return managed;
}
try {
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
} catch (err) {
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
const onPath = findCliOnPath();
cachedCliBinary = onPath;
return onPath;
}
})();
try {
return await cliResolvePromise;
} finally {
cliResolvePromise = null;
}
}
/**
* Reads the version of the currently resolved CLI binary by invoking
* `multica version --output json`. Cached for the process lifetime — the
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
const bin = await resolveCliBinary();
if (!bin) {
cachedCliBinaryVersion = null;
return null;
}
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
cachedCliBinaryVersion = parsed.version ?? null;
} catch (err) {
console.warn("[daemon] failed to read CLI binary version:", err);
cachedCliBinaryVersion = null;
}
return cachedCliBinaryVersion;
}
/**
* Compares the running daemon's `cli_version` against the CLI binary we
* would use to spawn a new one, and restarts only when safe. The decision
* logic itself is in `version-decision.ts` (pure, unit-tested); this
* wrapper handles the async plumbing and side effects.
*
* Restart is only fired when ALL of:
* - a daemon is actually running on the active profile's port
* - both sides report a version and the strings differ
* - `active_task_count` is 0 (no in-flight agent work would be killed)
*
* On a confirmed mismatch while the daemon is busy, `pendingVersionRestart`
* is set; the poll loop retries this function on each 5s tick and will fire
* the restart as soon as the daemon drains.
*/
async function ensureRunningDaemonVersionMatches(): Promise<
"restarted" | "deferred" | "ok" | "not_running"
> {
const active = await ensureActiveProfile();
const running = await fetchHealthAtPort(active.port);
const bundled = await getCliBinaryVersion();
const action = decideVersionAction(bundled, running);
switch (action) {
case "not_running":
pendingVersionRestart = false;
return "not_running";
case "ok":
pendingVersionRestart = false;
return "ok";
case "defer": {
if (!pendingVersionRestart) {
const activeTasks = running?.active_task_count ?? 0;
console.log(
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`,
);
}
pendingVersionRestart = true;
return "deferred";
}
case "restart":
console.log(
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`,
);
pendingVersionRestart = false;
await restartDaemon();
return "restarted";
}
}
/**
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
* days and signatures are tied to a specific backend instance.
*/
async function mintPat(jwt: string): Promise<string> {
if (!targetApiBaseUrl) {
throw new Error("mint PAT: target API URL not set");
}
const url = `${targetApiBaseUrl.replace(/\/+$/, "")}/api/tokens`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
// Omit expires_in_days → server treats as null → non-expiring PAT.
body: JSON.stringify({ name: "Multica Desktop" }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
}
const data = (await res.json()) as { token?: unknown };
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
throw new Error("mint PAT: response missing token");
}
return data.token;
}
/**
* Ensure the active profile's config.json has a usable token for the daemon.
*
* - Input from the renderer is the user's JWT (from localStorage) plus the
* current user's id, so we can detect session changes.
* - If the profile already has a cached PAT (`mul_...`) AND the sidecar user
* id matches the caller, reuse it — minting fresh on every launch would
* accumulate garbage in the user's tokens page.
* - On user mismatch (or first run) call POST /api/tokens with the JWT to
* mint a fresh PAT, overwriting any stale cached PAT. This is the critical
* path: without it, a previous user's PAT would be used by a new session.
* - If the caller happens to pass a PAT directly, write it through.
* - When we mint fresh and a daemon is already running, restart it so the
* new credentials take effect (the Go daemon reads config at startup).
*/
async function syncToken(
tokenFromRenderer: string,
userId: string,
): Promise<void> {
const active = await ensureActiveProfile();
const config = await readProfileConfig(active.name);
const previousUserId = await readProfileUserId(active.name);
const userChanged = Boolean(previousUserId) && previousUserId !== userId;
const sameUserWithCachedPat =
!userChanged &&
previousUserId === userId &&
typeof config.token === "string" &&
config.token.startsWith("mul_");
let finalToken: string;
if (tokenFromRenderer.startsWith("mul_")) {
finalToken = tokenFromRenderer;
} else if (sameUserWithCachedPat) {
finalToken = config.token as string;
} else {
try {
finalToken = await mintPat(tokenFromRenderer);
console.log(
`[daemon] minted PAT for profile "${active.name}" (user_changed=${userChanged})`,
);
} catch (err) {
console.error("[daemon] failed to mint PAT:", err);
throw err;
}
}
config.token = finalToken;
if (targetApiBaseUrl) config.server_url = targetApiBaseUrl;
await writeProfileConfig(active.name, config);
await writeProfileUserId(active.name, userId);
// If we just rotated credentials onto a running daemon, restart it so the
// in-memory token in the Go process matches the new config.
if (userChanged) {
try {
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
console.log(
"[daemon] user switched — restarting daemon with new credentials",
);
void restartDaemon();
}
} catch (err) {
console.warn("[daemon] restart-on-user-switch failed:", err);
}
}
}
async function loadPrefs(): Promise<DaemonPrefs> {
try {
const raw = await readFile(PREFS_PATH, "utf-8");
const parsed = JSON.parse(raw);
return { ...DEFAULT_PREFS, ...parsed };
} catch {
return { ...DEFAULT_PREFS };
}
}
async function savePrefs(prefs: DaemonPrefs): Promise<void> {
const dir = join(homedir(), ".multica");
await mkdir(dir, { recursive: true });
await writeFile(PREFS_PATH, JSON.stringify(prefs, null, 2), "utf-8");
}
async function clearToken(): Promise<void> {
const active = await ensureActiveProfile();
const config = await readProfileConfig(active.name);
if ("token" in config) {
delete config.token;
await writeProfileConfig(active.name, config);
}
// Always drop the sidecar so a subsequent syncToken from any user is
// treated as a fresh mint, not a reuse of a stale cached PAT.
await removeProfileUserId(active.name);
}
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
if (operationInProgress) {
return { success: false, error: "Another daemon operation is in progress" };
}
operationInProgress = true;
try {
return await fn();
} finally {
operationInProgress = false;
}
}
function profileArgs(active: ActiveProfile): string[] {
return active.name ? ["--profile", active.name] : [];
}
// 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. 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();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
pollOnce();
return { success: true };
}
currentState = "starting";
sendStatus({ state: "starting" });
const args = ["daemon", "start", ...profileArgs(active)];
return new Promise((resolve) => {
execFile(
bin,
args,
{ timeout: 20_000, env: desktopSpawnEnv() },
(err) => {
if (err) {
currentState = "stopped";
sendStatus({ state: "stopped" });
resolve({ success: false, error: err.message });
return;
}
// Stay in "starting" until pollOnce confirms /health — the CLI
// returning 0 only means the supervisor was spawned, not that the
// daemon process is already listening.
pollOnce();
resolve({ success: true });
},
);
});
}
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
currentState = "stopping";
sendStatus({ state: "stopping" });
const args = ["daemon", "stop", ...profileArgs(active)];
return new Promise((resolve) => {
execFile(bin, args, { timeout: 15_000 }, (err) => {
if (err) {
resolve({ success: false, error: err.message });
} else {
resolve({ success: true });
}
currentState = "stopped";
sendStatus({ state: "stopped" });
});
});
}
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
const stopResult = await stopDaemon();
if (!stopResult.success) return stopResult;
return startDaemon();
}
async function pollOnce(): Promise<void> {
const status = await fetchHealth();
currentState = status.state;
sendStatus(status);
// Retry a deferred version-mismatch restart once the daemon drains.
if (pendingVersionRestart && status.state === "running") {
void ensureRunningDaemonVersionMatches();
}
}
function startPolling(): void {
if (statusPollTimer) return;
pollOnce();
statusPollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
}
/**
* Ensures the CLI binary is available, then transitions into the normal
* stopped/running state machine. Called once at startup and again on
* user-triggered `daemon:retry-install`.
*/
async function bootstrapCli(): Promise<void> {
const bin = await resolveCliBinary();
if (!bin) {
currentState = "cli_not_found";
sendStatus({ state: "cli_not_found" });
return;
}
currentState = "stopped";
sendStatus({ state: "stopped" });
startPolling();
}
function stopPolling(): void {
if (statusPollTimer) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
const LOG_TAIL_INITIAL_WINDOW_BYTES = 32 * 1024;
const LOG_TAIL_INITIAL_LINES = 200;
const LOG_TAIL_POLL_MS = 500;
async function readLogRange(
path: string,
startAt: number,
length: number,
): Promise<string> {
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, startAt);
return buffer.subarray(0, bytesRead).toString("utf-8");
} finally {
await handle.close();
}
}
function sendLines(win: BrowserWindow, text: string): void {
const lines = text.split("\n").filter((line) => line.length > 0);
for (const line of lines) {
win.webContents.send("daemon:log-line", line);
}
}
// Cross-platform tail -f replacement: read the tail of the file once, then
// poll its stat with fs.watchFile and forward any new bytes since the last
// known offset. watchFile works on macOS, Linux, and Windows; spawn("tail")
// would silently fail on Windows.
function startLogTail(win: BrowserWindow, retryCount = 0): void {
stopLogTail();
void ensureActiveProfile().then(async (active) => {
const logPath = profileLogPath(active.name);
if (!existsSync(logPath)) {
if (retryCount < LOG_TAIL_MAX_RETRIES) {
setTimeout(() => startLogTail(win, retryCount + 1), LOG_TAIL_RETRY_MS);
}
return;
}
let position = 0;
try {
const initialStats = await stat(logPath);
const windowBytes = Math.min(
initialStats.size,
LOG_TAIL_INITIAL_WINDOW_BYTES,
);
const startAt = initialStats.size - windowBytes;
if (windowBytes > 0) {
const text = await readLogRange(logPath, startAt, windowBytes);
const lines = text
.split("\n")
.filter((line) => line.length > 0)
.slice(-LOG_TAIL_INITIAL_LINES);
for (const line of lines) {
win.webContents.send("daemon:log-line", line);
}
}
position = initialStats.size;
} catch (err) {
console.warn("[daemon] log tail initial read failed:", err);
return;
}
const listener: StatsListener = (curr) => {
const target = getMainWindow();
if (!target) return;
// File rotated/truncated — restart from the new beginning.
if (curr.size < position) position = 0;
if (curr.size === position) return;
const from = position;
const length = curr.size - from;
position = curr.size;
readLogRange(logPath, from, length)
.then((text) => sendLines(target, text))
.catch((err) => {
console.warn("[daemon] log tail read failed:", err);
});
};
watchFile(logPath, { interval: LOG_TAIL_POLL_MS }, listener);
logTailWatcher = { path: logPath, listener };
});
}
function stopLogTail(): void {
if (logTailWatcher) {
unwatchFile(logTailWatcher.path, logTailWatcher.listener);
logTailWatcher = null;
}
}
export function setupDaemonManager(
windowGetter: () => BrowserWindow | null,
): void {
getMainWindow = windowGetter;
ipcMain.handle("daemon:set-target-api-url", async (_e, url: string) => {
const normalized = url || null;
if (targetApiBaseUrl !== normalized) {
console.log(`[daemon] target API URL set to ${normalized ?? "(none)"}`);
targetApiBaseUrl = normalized;
invalidateActiveProfile();
await pollOnce();
}
});
ipcMain.handle("daemon:start", () => withGuard(() => startDaemon()));
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
ipcMain.handle("daemon:get-status", () => fetchHealth());
ipcMain.handle(
"daemon:sync-token",
(_event, token: string, userId: string) => syncToken(token, userId),
);
ipcMain.handle("daemon:clear-token", () => clearToken());
ipcMain.handle("daemon:is-cli-installed", async () => {
const bin = await resolveCliBinary();
return bin !== null;
});
ipcMain.handle("daemon:retry-install", async () => {
cachedCliBinary = undefined;
cliResolvePromise = null;
// A retry-install may land a new CLI at a different version; drop the
// cached version string so the next check re-reads the binary.
cachedCliBinaryVersion = undefined;
await bootstrapCli();
});
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
ipcMain.handle(
"daemon:set-prefs",
(_event, prefs: Partial<DaemonPrefs>) =>
loadPrefs().then((cur) => {
const merged = { ...cur, ...prefs };
return savePrefs(merged).then(() => merged);
}),
);
ipcMain.handle("daemon:auto-start", async () => {
const prefs = await loadPrefs();
if (!prefs.autoStart) return;
const bin = await resolveCliBinary();
if (!bin) return;
const health = await fetchHealth();
if (health.state === "running") {
// Daemon is up but may be running an older CLI than the one we just
// bundled. Restart it so the new binary actually takes effect.
await ensureRunningDaemonVersionMatches();
return;
}
await startDaemon();
});
ipcMain.on("daemon:start-log-stream", () => {
const win = getMainWindow();
if (win) startLogTail(win);
});
ipcMain.on("daemon:stop-log-stream", () => {
stopLogTail();
});
// First-run CLI install kicks off here. Status bar shows "Setting up…"
// until the managed binary is on disk (instant on subsequent launches).
currentState = "installing_cli";
sendStatus({ state: "installing_cli" });
void bootstrapCli();
let isQuitting = false;
app.on("before-quit", (event) => {
if (isQuitting) return;
stopPolling();
stopLogTail();
loadPrefs().then(async (prefs) => {
if (prefs.autoStop) {
isQuitting = true;
event.preventDefault();
try {
await stopDaemon();
} catch {
// Best-effort stop on quit
}
app.quit();
}
});
});
}

View File

@@ -1,30 +1,7 @@
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";
@@ -136,18 +113,9 @@ if (!gotTheLock) {
return shell.openExternal(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (create-workspace, onboarding) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;
mainWindow?.setWindowButtonVisibility(!immersive);
});
createWindow();
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {

View File

@@ -1,88 +0,0 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-decision";
describe("decideVersionAction", () => {
it("returns not_running when health payload is null", () => {
expect(decideVersionAction("v1.0.0", null)).toBe("not_running");
});
it("returns not_running when status is not 'running'", () => {
expect(
decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }),
).toBe("not_running");
});
it("returns ok when bundled version is unknown (fail safe)", () => {
expect(
decideVersionAction(null, {
status: "running",
cli_version: "v1.0.0",
active_task_count: 0,
}),
).toBe("ok");
});
it("returns ok when running daemon does not report cli_version (older daemon)", () => {
expect(
decideVersionAction("v1.0.0", {
status: "running",
active_task_count: 0,
}),
).toBe("ok");
});
it("returns ok when versions match exactly", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.3",
active_task_count: 5,
}),
).toBe("ok");
});
it("returns restart when versions differ and daemon is idle", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
active_task_count: 0,
}),
).toBe("restart");
});
it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
}),
).toBe("restart");
});
it("returns defer when versions differ but daemon is busy", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
active_task_count: 2,
}),
).toBe("defer");
});
it("transitions defer → restart as tasks drain", () => {
// Same bundled version across three observations while the daemon ages.
const bundled = "v2.0.0";
const base = { status: "running", cli_version: "v1.9.0" } as const;
expect(
decideVersionAction(bundled, { ...base, active_task_count: 3 }),
).toBe("defer");
expect(
decideVersionAction(bundled, { ...base, active_task_count: 1 }),
).toBe("defer");
expect(
decideVersionAction(bundled, { ...base, active_task_count: 0 }),
).toBe("restart");
});
});

View File

@@ -1,37 +0,0 @@
// Pure decision logic for the daemon version-check flow. Kept in its own
// module so it can be unit-tested without mocking Electron, execFile, or
// the HTTP health probe.
export interface VersionCheckHealth {
status?: string;
cli_version?: string;
active_task_count?: number;
}
export type VersionAction = "restart" | "defer" | "ok" | "not_running";
/**
* Decides what the daemon-manager should do given the currently-resolved
* bundled CLI version and the latest /health payload.
*
* not_running: no daemon is up, nothing to do
* ok: versions match, OR either side is unknown (fail safe)
* defer: versions differ but the daemon is busy — wait for drain
* restart: versions differ and the daemon is idle — safe to restart
*
* Pure function: no I/O, no side effects, no module state.
*/
export function decideVersionAction(
bundled: string | null,
running: VersionCheckHealth | null,
): VersionAction {
if (!running || running.status !== "running") return "not_running";
const runningVersion = running.cli_version;
if (!bundled || !runningVersion) return "ok";
if (runningVersion === bundled) return "ok";
const activeTasks = running.active_task_count ?? 0;
if (activeTasks > 0) return "defer";
return "restart";
}

View File

@@ -5,44 +5,6 @@ interface DesktopAPI {
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
}
interface DaemonStatus {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
profile?: string;
serverUrl?: string;
}
interface DaemonPrefs {
autoStart: boolean;
autoStop: boolean;
}
interface DaemonAPI {
start: () => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
restart: () => Promise<{ success: boolean; error?: string }>;
getStatus: () => Promise<DaemonStatus>;
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
setTargetApiUrl: (url: string) => Promise<void>;
syncToken: (token: string, userId: string) => Promise<void>;
clearToken: () => Promise<void>;
isCliInstalled: () => Promise<boolean>;
getPrefs: () => Promise<DaemonPrefs>;
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
autoStart: () => Promise<void>;
retryInstall: () => Promise<void>;
startLogStream: () => void;
stopLogStream: () => void;
onLogLine: (callback: (line: string) => void) => () => void;
}
interface UpdaterAPI {
@@ -57,7 +19,6 @@ declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
daemonAPI: DaemonAPI;
updater: UpdaterAPI;
}
}

View File

@@ -13,60 +13,6 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
};
interface DaemonStatus {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
profile?: string;
serverUrl?: string;
}
const daemonAPI = {
start: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:start"),
stop: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:stop"),
restart: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:restart"),
getStatus: (): Promise<DaemonStatus> =>
ipcRenderer.invoke("daemon:get-status"),
onStatusChange: (callback: (status: DaemonStatus) => void) => {
const handler = (_: unknown, status: DaemonStatus) => callback(status);
ipcRenderer.on("daemon:status", handler);
return () => ipcRenderer.removeListener("daemon:status", handler);
},
setTargetApiUrl: (url: string): Promise<void> =>
ipcRenderer.invoke("daemon:set-target-api-url", url),
syncToken: (token: string, userId: string): Promise<void> =>
ipcRenderer.invoke("daemon:sync-token", token, userId),
clearToken: (): Promise<void> =>
ipcRenderer.invoke("daemon:clear-token"),
isCliInstalled: (): Promise<boolean> =>
ipcRenderer.invoke("daemon:is-cli-installed"),
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
ipcRenderer.invoke("daemon:get-prefs"),
setPrefs: (prefs: Partial<{ autoStart: boolean; autoStop: boolean }>): Promise<{ autoStart: boolean; autoStop: boolean }> =>
ipcRenderer.invoke("daemon:set-prefs", prefs),
autoStart: (): Promise<void> =>
ipcRenderer.invoke("daemon:auto-start"),
retryInstall: (): Promise<void> =>
ipcRenderer.invoke("daemon:retry-install"),
startLogStream: () => ipcRenderer.send("daemon:start-log-stream"),
stopLogStream: () => ipcRenderer.send("daemon:stop-log-stream"),
onLogLine: (callback: (line: string) => void) => {
const handler = (_: unknown, line: string) => callback(line);
ipcRenderer.on("daemon:log-line", handler);
return () => ipcRenderer.removeListener("daemon:log-line", handler);
},
};
const updaterAPI = {
@@ -92,7 +38,6 @@ const updaterAPI = {
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
contextBridge.exposeInMainWorld("daemonAPI", daemonAPI);
contextBridge.exposeInMainWorld("updater", updaterAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
@@ -100,7 +45,5 @@ if (process.contextIsolated) {
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
// @ts-expect-error - fallback for non-isolated context
window.daemonAPI = daemonAPI;
// @ts-expect-error - fallback for non-isolated context
window.updater = updaterAPI;
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
@@ -14,62 +13,22 @@ 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).
useEffect(() => {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// 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).
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
setBootstrapping(true);
try {
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();
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
} 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(() => {
if (!user) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
const userId = user.id;
(async () => {
try {
await window.daemonAPI.syncToken(token, userId);
await window.daemonAPI.autoStart();
} catch (err) {
console.error("Failed to sync daemon on login", err);
}
})();
}, [user]);
if (isLoading || bootstrapping) {
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
@@ -81,32 +40,14 @@ function AppContent() {
return <DesktopShell />;
}
// Backend the daemon should connect to — same URL the renderer talks to.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
try {
await window.daemonAPI.clearToken();
} catch {
// Best-effort — clearing is followed by stop which also hardens state.
}
try {
await window.daemonAPI.stop();
} catch {
// Daemon may already be stopped.
}
}
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
>
<AppContent />
</CoreProvider>

View File

@@ -1,309 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Server,
ChevronDown,
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet";
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
interface DaemonPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: DaemonStatus;
}
const LOG_LEVEL_COLORS: Record<string, string> = {
INFO: "text-info",
WARN: "text-warning",
ERROR: "text-destructive",
DEBUG: "text-muted-foreground",
};
function colorizeLogLine(line: string): { level: string; className: string } {
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
if (line.includes(level)) return { level, className };
}
return { level: "", className: "text-muted-foreground" };
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4 py-1">
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="truncate text-right text-sm">{value}</span>
</div>
);
}
function StatusDot({ state }: { state: DaemonState }) {
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
}
interface LogEntry {
id: number;
line: string;
}
const MAX_LOG_LINES = 500;
let logIdCounter = 0;
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
window.daemonAPI.startLogStream();
const unsub = window.daemonAPI.onLogLine((line) => {
setLogs((prev) => {
const next = [...prev, { id: ++logIdCounter, line }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => {
unsub();
window.daemonAPI.stopLogStream();
};
}, [open]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
const handleLogScroll = useCallback(() => {
const el = logContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAutoScroll(atBottom);
}, []);
const scrollToBottom = useCallback(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col sm:max-w-md"
showCloseButton={false}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
<SheetTitle className="flex items-center gap-2">
<Server className="size-4" />
Local Daemon
</SheetTitle>
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close"
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</SheetHeader>
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
<div className="shrink-0 space-y-4">
{/* Status info */}
<div className="rounded-lg border p-3 space-y-0.5">
<InfoRow
label="Status"
value={
<span className="flex items-center gap-1.5">
<StatusDot state={status.state} />
{DAEMON_STATE_LABELS[status.state]}
</span>
}
/>
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
<InfoRow label="Profile" value={status.profile || "default"} />
{status.serverUrl && (
<InfoRow
label="Server"
value={
<span className="font-mono text-xs" title={status.serverUrl}>
{status.serverUrl}
</span>
}
/>
)}
{status.agents && status.agents.length > 0 && (
<InfoRow label="Agents" value={status.agents.join(", ")} />
)}
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
{status.daemonId && (
<InfoRow
label="Daemon ID"
value={<span className="font-mono text-xs">{status.daemonId}</span>}
/>
)}
{typeof status.workspaceCount === "number" && (
<InfoRow label="Workspaces" value={status.workspaceCount} />
)}
{status.pid && (
<InfoRow
label="PID"
value={<span className="font-mono text-xs">{status.pid}</span>}
/>
)}
</div>
{/* Actions */}
{status.state === "installing_cli" ? (
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
Setting up the local runtime this only happens the first time.
</div>
) : status.state === "cli_not_found" ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
<p className="text-sm">
Couldn&apos;t download the local runtime. Check your network
connection and try again.
</p>
<Button
size="sm"
variant="outline"
onClick={async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
) : (
<div className="flex gap-2">
{status.state === "stopped" ? (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
<Play className="size-3.5 mr-1.5" />
Start
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleStop}
disabled={actionLoading || isTransitioning}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionLoading || isTransitioning}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
</>
)}
</div>
)}
</div>
{/* Logs — fills remaining vertical space down to the sheet bottom */}
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
<div className="flex items-center justify-between shrink-0">
<h3 className="text-sm font-medium">Logs</h3>
{!autoScroll && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={scrollToBottom}
>
<ChevronDown className="size-3 mr-1" />
Scroll to bottom
</Button>
)}
</div>
<div
ref={logContainerRef}
onScroll={handleLogScroll}
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<p className="text-muted-foreground/50 text-center py-8">
{status.state === "running"
? "Waiting for logs…"
: "Start the daemon to see logs"}
</p>
) : (
logs.map((entry) => {
const { className } = colorizeLogLine(entry.line);
return (
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
{entry.line}
</div>
);
})
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,155 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Server,
Activity,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
window.daemonAPI.getStatus().then((s) => setStatus(s));
const unsub = window.daemonAPI.onStatusChange((s) => {
setStatus(s);
setActionLoading(false);
});
return unsub;
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
if (!result.success) {
setActionLoading(false);
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
const isRunning = status.state === "running";
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
return (
<>
<div
role="button"
tabIndex={0}
onClick={() => setPanelOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setPanelOpen(true);
}
}}
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
<Server className="size-4 text-muted-foreground" />
</div>
<div>
<h3 className="text-sm font-medium">Local Daemon</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
{isRunning && status.uptime && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
</>
)}
{isRunning && status.agents && status.agents.length > 0 && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
</>
)}
</div>
</div>
</div>
<div
className="flex items-center gap-1.5 shrink-0"
onClick={stopPropagation}
>
{isStopped && (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={actionLoading || status.state === "cli_not_found"}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="outline"
onClick={handleStop}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isTransitioning && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</div>
</div>
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
</>
);
}

View File

@@ -1,103 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import type { DaemonPrefs } from "../../../shared/daemon-types";
function SettingRow({
label,
description,
children,
}: {
label: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
export function DaemonSettingsTab() {
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
window.daemonAPI.isCliInstalled().then(setCliInstalled);
}, []);
const updatePref = useCallback(
async (key: keyof DaemonPrefs, value: boolean) => {
setSaving(true);
const updated = await window.daemonAPI.setPrefs({ [key]: value });
setPrefs(updated);
setSaving(false);
},
[],
);
return (
<div>
<h2 className="text-lg font-semibold">Daemon</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure how the local agent daemon behaves with the desktop app.
</p>
<div className="mt-6 divide-y">
<SettingRow
label="Auto-start on launch"
description="Automatically start the daemon when the app opens and you are logged in."
>
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving}
/>
</SettingRow>
<SettingRow
label="Auto-stop on quit"
description="Stop the daemon when the desktop app is closed. Disable this to keep the daemon running in the background."
>
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving}
/>
</SettingRow>
<div className="py-4">
<p className="text-sm font-medium">CLI Status</p>
<p className="text-sm text-muted-foreground mt-1">
{cliInstalled === null
? "Checking…"
: cliInstalled
? "multica CLI is installed and available in PATH."
: "multica CLI not found. Install it to enable daemon management."}
</p>
{cliInstalled === false && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() =>
window.desktopAPI.openExternal(
"https://github.com/multica-ai/multica#cli-installation",
)
}
>
Installation Guide
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,23 +1,15 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
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 { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { AppSidebar, DashboardGuard } 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 { OnboardingGate } from "./onboarding-gate";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -36,7 +28,6 @@ function SidebarTopBar() {
<button
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
@@ -44,7 +35,6 @@ function SidebarTopBar() {
<button
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
@@ -54,34 +44,6 @@ function SidebarTopBar() {
);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// 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, isMobile } = useSidebar();
const sidebarHidden = state === "collapsed" || isMobile;
return (
<header
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>
);
}
function useInternalLinkHandler() {
useEffect(() => {
const handler = (e: Event) => {
@@ -101,47 +63,40 @@ 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>
<OnboardingGate
onboarding={(onComplete) => (
<div className="flex min-h-screen items-center justify-center overflow-auto bg-background px-6 py-12">
<StepWorkspace onNext={onComplete} />
<DashboardGuard
loginPath="/login"
loadingFallback={
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
}
>
{/* 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 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">
{/* Tab bar + drag region */}
<header
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
{/* 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 />
</div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
</WorkspaceSlugProvider>
</OnboardingGate>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
</DashboardGuard>
</DesktopNavigationProvider>
);
}

View File

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

View File

@@ -1,40 +0,0 @@
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}</>;
}

View File

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

View File

@@ -6,27 +6,11 @@
@custom-variant dark (&:is(.dark *));
/* Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
Web app uses the same stack via next/font/google in apps/web/app/layout.tsx —
keep the CJK fallback tail in sync across both files. The Inter primary family
differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
Both resolve to Inter glyphs, so rendering is identical in practice.
Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
Per-character fallback: Latin chars render with Inter, Chinese chars with
PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
would falsely signal alignment guarantees. Browser default fallback handles
the rare mixed case correctly. */
/* Geist font: define CSS variables that tokens.css @theme inline references.
Web app gets these from next/font/google; desktop must set them explicitly. */
:root {
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
--font-sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
@source "../../../../../packages/ui/**/*.tsx";

View File

@@ -1,9 +1,9 @@
import ReactDOM from "react-dom/client";
import App from "./App";
// Inter variable font covers all weights (100-900) in a single file.
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
import "@fontsource-variable/inter";
import "@fontsource/geist-sans/400.css";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";

View File

@@ -1,17 +0,0 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { AutopilotDetailPage as AutopilotDetail } from "@multica/views/autopilots/components";
import { useWorkspaceId } from "@multica/core/hooks";
import { autopilotDetailOptions } from "@multica/core/autopilots/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function AutopilotDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data } = useQuery(autopilotDetailOptions(wsId, id!));
useDocumentTitle(data ? `${data.autopilot.title}` : "Autopilot");
if (!id) return null;
return <AutopilotDetail autopilotId={id} />;
}

View File

@@ -1,9 +1,11 @@
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
const WEB_URL = import.meta.env.VITE_WEB_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.
@@ -21,9 +23,9 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell.
// Initial workspace navigation happens in routes.tsx via IndexRedirect.
// Auth store update triggers AppContent re-render → shows DesktopShell
}}
onGoogleLogin={handleGoogleLogin}
/>

View File

@@ -7,11 +7,6 @@ import {
import { useAuthStore } from "@multica/core/auth";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
// that actually points somewhere a teammate can open.
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
/**
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
@@ -69,7 +64,7 @@ export function DesktopNavigationProvider({
const tabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
}),
[pathname],
);
@@ -112,7 +107,7 @@ export function TabNavigationProvider({
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
}),
[router, location],
);

View File

@@ -6,28 +6,16 @@ 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";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { 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.
@@ -59,137 +47,45 @@ function PageShell() {
);
}
function OnboardingRoute() {
const nav = useNavigation();
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() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/**
* Route definitions shared by all tabs.
*
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
* slug to a workspace and syncing side-effects (api client, persist namespace,
* Zustand mirror). Global (pre-workspace) routes — onboarding and invite —
* sit at the top level alongside the workspace wrapper.
*/
/** Route definitions shared by all tabs (no layout wrapper). */
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
children: [
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
// list from React Query cache (seeded by AuthInitializer on reopen
// or App.tsx on deep-link login) and bounces to the first
// workspace's issues page — or onboarding if the user has none.
{ index: true, element: <IndexRedirect /> },
{ index: true, element: <Navigate to="/issues" replace /> },
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
{
path: "onboarding",
element: <OnboardingRoute />,
handle: { title: "Get Started" },
path: "issues/:id",
element: <IssueDetailPage />,
handle: { title: "Issue" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
path: "projects",
element: <ProjectsPage />,
handle: { title: "Projects" },
},
{
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" },
},
],
path: "projects/:id",
element: <ProjectDetailPage />,
handle: { title: "Project" },
},
{
path: "my-issues",
element: <MyIssuesPage />,
handle: { title: "My Issues" },
},
{
path: "runtimes",
element: <RuntimesPage />,
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 />,
handle: { title: "Settings" },
},
],
},

View File

@@ -3,7 +3,6 @@ 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";
@@ -46,50 +45,29 @@ interface TabStore {
// ---------------------------------------------------------------------------
const ROUTE_ICONS: Record<string, string> = {
inbox: "Inbox",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",
autopilots: "ListTodo",
agents: "Bot",
runtimes: "Monitor",
skills: "BookOpenText",
settings: "Settings",
"/inbox": "Inbox",
"/my-issues": "CircleUser",
"/issues": "ListTodo",
"/projects": "FolderKanban",
"/agents": "Bot",
"/runtimes": "Monitor",
"/skills": "BookOpenText",
"/settings": "Settings",
};
/**
* 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.
*/
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
export function resolveRouteIcon(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
return ROUTE_ICONS[pathname]
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
/**
* Sentinel path for new tabs with no explicit destination. The tab store is
* workspace-implicit — it doesn't know which workspace is active, so it can't
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
* matches the top-level index route, which redirects to the workspace default
* (slug-aware redirect lives in routes.tsx / App.tsx).
*
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
const DEFAULT_PATH = "/";
const DEFAULT_PATH = "/issues";
function createId(): string {
return createSafeId();
@@ -199,28 +177,12 @@ export const useTabStore = create<TabStore>()(
| undefined;
if (!persisted?.tabs?.length) return currentState;
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,
};
});
const tabs: Tab[] = persisted.tabs.map((tab) => ({
...tab,
router: createTabRouter(tab.path),
historyIndex: 0,
historyLength: 1,
}));
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)

View File

@@ -1,53 +0,0 @@
export type DaemonState =
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found";
export interface DaemonStatus {
state: DaemonState;
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
/** CLI profile this daemon belongs to. Empty string means the default profile. */
profile?: string;
/** Backend URL the daemon connects to. */
serverUrl?: string;
}
export interface DaemonPrefs {
autoStart: boolean;
autoStop: boolean;
}
export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
running: "bg-emerald-500",
stopped: "bg-muted-foreground/40",
starting: "bg-amber-500 animate-pulse",
stopping: "bg-amber-500 animate-pulse",
installing_cli: "bg-sky-500 animate-pulse",
cli_not_found: "bg-red-500",
};
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
running: "Running",
stopped: "Stopped",
starting: "Starting…",
stopping: "Stopping…",
installing_cli: "Setting up…",
cli_not_found: "Setup Failed",
};
export function formatUptime(uptime?: string): string {
if (!uptime) return "";
const match = uptime.match(/(?:(\d+)h)?(\d+)m/);
if (!match) return uptime;
const h = match[1] ? `${match[1]}h ` : "";
const m = match[2] ? `${match[2]}m` : "";
return `${h}${m}`.trim() || uptime;
}

View File

@@ -1 +0,0 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -4,8 +4,7 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts",
"test/setup.ts"
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,

View File

@@ -1,13 +0,0 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
passWithNoTests: true,
},
});

View File

@@ -78,13 +78,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, or `hermes`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
- [Codex](https://github.com/openai/codex) (`codex`)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini`)
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)

View File

@@ -88,7 +88,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | Google's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
@@ -132,8 +131,6 @@ Agent-specific overrides:
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
### Self-Hosted Server

View File

@@ -11,7 +11,7 @@ Go to [multica.ai](https://multica.ai) and create an account.
## 2. Install the CLI and start the daemon
Give this instruction to your AI agent (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, etc.):
Give this instruction to your AI agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
@@ -45,7 +45,7 @@ Then configure, authenticate, and start the daemon:
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime
@@ -55,7 +55,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
## 4. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 5. Assign your first task

View File

@@ -83,7 +83,6 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` on PATH)
- OpenCode (`opencode` on PATH)
- OpenClaw (`openclaw` on PATH)
- Hermes (`hermes` on PATH)
@@ -234,8 +233,6 @@ Agent-specific overrides:
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
## Database Setup

View File

@@ -15,7 +15,7 @@ When an agent is assigned a task in Multica:
1. The daemon detects the task assignment
2. It creates an isolated workspace directory
3. It spawns the appropriate agent CLI (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes)
3. It spawns the appropriate agent CLI (Claude Code, Codex, OpenClaw, or OpenCode)
4. The agent executes autonomously, streaming progress back to Multica
5. Results are reported — success, failure, or blockers
@@ -29,28 +29,21 @@ Real-time progress is streamed via WebSocket so you can follow along in the Mult
|----------|-------------|-------------|
| Claude Code | `claude` | Anthropic's coding agent |
| Codex | `codex` | OpenAI's coding agent |
| Gemini CLI | `gemini` | Google's coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
## Reusable Skills
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
- Deployments
- Migrations
- Code reviews
- Common patterns
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
Skills are shared across the workspace, so any agent (or human) can leverage them.
## Multi-Workspace Support

View File

@@ -11,7 +11,7 @@ Once you have the CLI installed (or signed up for [Multica Cloud](https://multic
multica setup # Configure, authenticate, and start the daemon
```
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) available on your PATH.
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
## 2. Verify your runtime
@@ -21,7 +21,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
## 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 4. Assign your first task

View File

@@ -7,7 +7,7 @@ description: Multica — the open-source managed agents platform. Turn coding ag
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **Gemini CLI**, **OpenClaw**, **OpenCode**, and **Hermes**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
## Features
@@ -24,7 +24,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
@@ -35,7 +35,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
Gemini/Hermes
OpenClaw/Code
└──────────────┘
```

View File

@@ -1,27 +0,0 @@
"use client";
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() {
const router = useRouter();
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
if (!isLoading && !user) {
router.replace(
`${paths.login()}?next=${encodeURIComponent(paths.invite(params.id))}`,
);
}
}, [isLoading, user, router, params.id]);
if (isLoading || !user) return null;
return <InvitePage invitationId={params.id} />;
}

View File

@@ -11,10 +11,13 @@ function createWrapper() {
);
}
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
}));
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockHydrateWorkspace: vi.fn(),
}),
);
// Mock next/navigation
vi.mock("next/navigation", () => ({
@@ -44,6 +47,16 @@ 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: {

View File

@@ -2,11 +2,8 @@
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import type { Workspace } from "@multica/core/types";
import { useWorkspaceStore } from "@multica/core/workspace";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -14,7 +11,6 @@ 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();
@@ -22,50 +18,25 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
// `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");
const nextUrl = searchParams.get("next") || "/issues";
// Already authenticated — honor ?next= or fall back to first workspace /
// onboarding. Skip this entire path when the user arrived to authorize the CLI.
// Already authenticated — redirect to dashboard (skip if CLI callback)
useEffect(() => {
if (isLoading || !user || cliCallbackRaw) return;
if (nextUrl) {
if (!isLoading && user && !cliCallbackRaw) {
router.replace(nextUrl);
return;
}
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]);
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
const lastWorkspaceId =
typeof window !== "undefined"
? localStorage.getItem("multica_workspace_id")
: null;
const handleSuccess = () => {
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(),
);
const ws = useWorkspaceStore.getState().workspace;
router.push(ws ? nextUrl : "/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 ? `next:${nextUrl}` : "",
]
.filter(Boolean)
.join(",") || undefined;
return (
<LoginPage
onSuccess={handleSuccess}
@@ -74,7 +45,7 @@ function LoginPageContent() {
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: googleState,
state: platform === "desktop" ? "platform:desktop" : undefined,
}
: undefined
}
@@ -83,6 +54,7 @@ function LoginPageContent() {
? { url: cliCallbackRaw, state: cliState }
: undefined
}
lastWorkspaceId={lastWorkspaceId}
onTokenObtained={setLoggedInCookie}
/>
);

View File

@@ -3,7 +3,6 @@
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() {
@@ -13,14 +12,12 @@ export default function OnboardingPage() {
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
if (!isLoading && !user) router.replace("/login");
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
/>
<OnboardingWizard onComplete={() => router.push("/issues")} />
);
}

View File

@@ -11,6 +11,8 @@ 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>

View File

@@ -1,6 +1,5 @@
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: {
@@ -20,10 +19,5 @@ export const metadata: Metadata = {
};
export default function LandingPage() {
return (
<>
<RedirectIfAuthenticated />
<MulticaLanding />
</>
);
return <MulticaLanding />;
}

View File

@@ -1,13 +0,0 @@
"use client";
import { use } from "react";
import { AutopilotDetailPage } from "@multica/views/autopilots/components";
export default function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <AutopilotDetailPage autopilotId={id} />;
}

View File

@@ -1,7 +0,0 @@
"use client";
import { AutopilotsPage } from "@multica/views/autopilots/components";
export default function Page() {
return <AutopilotsPage />;
}

View File

@@ -1,79 +0,0 @@
"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>
);
}

View File

@@ -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,6 +22,7 @@ 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);
@@ -38,11 +39,8 @@ function CallbackContent() {
return;
}
const state = searchParams.get("state") || "";
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
const state = searchParams.get("state");
const isDesktop = state === "platform:desktop";
const redirectUri = `${window.location.origin}/auth/callback`;
@@ -63,21 +61,15 @@ function CallbackContent() {
.then(async () => {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
// URL is now the source of truth for the current workspace — the
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
// in the first workspace's issues, or /onboarding if the user has none.
const [first] = wsList;
const defaultDest = first
? paths.workspace(first.slug).issues()
: paths.onboarding();
router.push(nextUrl || defaultDest);
const lastWsId = localStorage.getItem("multica_workspace_id");
const ws = await hydrateWorkspace(wsList, lastWsId);
router.push(ws ? "/issues" : "/onboarding");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}
}, [searchParams, loginWithGoogle, router, qc]);
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
if (desktopToken) {
return (
@@ -114,7 +106,7 @@ function CallbackContent() {
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<a href={paths.login()} className="text-primary underline-offset-4 hover:underline">
<a href="/login" className="text-primary underline-offset-4 hover:underline">
Back to login
</a>
</CardContent>

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Inter, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { cn } from "@multica/ui/lib/utils";
@@ -7,38 +7,8 @@ import { WebProviders } from "@/components/web-providers";
import { LocaleSync } from "@/components/locale-sync";
import "./globals.css";
// Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
// Desktop app uses the same stack via apps/desktop/src/renderer/src/globals.css —
// keep the CJK fallback tail in sync across both files. The Inter primary family
// differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
// fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
// Both resolve to Inter glyphs, so rendering is identical in practice.
// Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
// the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
// Per-character fallback: Latin chars render with Inter, Chinese chars with
// PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"sans-serif",
],
});
// Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
// non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
// here would falsely signal alignment guarantees. Browser default fallback handles
// the rare mixed case correctly.
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-mono",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
});
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const viewport: Viewport = {
width: "device-width",
@@ -89,7 +59,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />

View File

@@ -41,7 +41,7 @@ export function HowItWorksSection() {
</div>
<div className="mt-14 flex flex-wrap items-center gap-4">
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.howItWorks.cta}
</Link>
<Link

View File

@@ -48,7 +48,7 @@ export function LandingFooter() {
</div>
<div className="mt-6">
<Link
href={user ? "/" : "/login"}
href={user ? "/issues" : "/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}

View File

@@ -54,7 +54,7 @@ export function LandingHeader({
{t.header.github}
</Link>
<Link
href={user ? "/" : "/login"}
href={user ? "/issues" : "/login"}
className={headerButtonClassName("solid", variant)}
>
{user ? t.header.dashboard : t.header.login}

View File

@@ -8,7 +8,6 @@ import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
CodexLogo,
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
@@ -41,7 +40,7 @@ export function LandingHero() {
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
@@ -71,10 +70,6 @@ export function LandingHero() {
<CodexLogo className="size-5" />
<span className="text-[15px] font-medium">Codex</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<GeminiCliLogo className="size-5" />
<span className="text-[15px] font-medium">Gemini CLI</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<OpenClawLogo className="size-5" />
<span className="text-[15px] font-medium">OpenClaw</span>

View File

@@ -1,45 +0,0 @@
"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;
}

View File

@@ -136,19 +136,6 @@ export function OpenClawLogo({ className }: { className?: string }) {
);
}
export function GeminiCliLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="currentColor"
>
<path d="M12 0C12 0 12 8 8 12C12 12 12 12 12 24C12 24 12 16 16 12C12 12 12 12 12 0Z" />
</svg>
);
}
export function OpenCodeLogo({ className }: { className?: string }) {
return (
<svg

View File

@@ -277,57 +277,6 @@ 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",
title: "Gemini CLI & Agent Env Vars",
changes: [],
features: [
"Google Gemini CLI as a new agent runtime with live log streaming",
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
"Sub-issues inherit parent project automatically",
],
improvements: [
"Editor bubble menu and link preview rewritten for reliability",
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
"Self-hosted WebSocket URL auto-derived for LAN access",
],
fixes: [
"S3 upload keys scoped by workspace (security)",
"Workspace membership validation for subscriptions and uploads (security)",
"Active tasks auto-cancelled when issue status changes to cancelled",
"Agent task stall when process hangs on stdout",
"Daemon trigger prompt now embeds the actual triggering comment content",
"Login and dashboard redirect stability improvements",
],
},
{
version: "0.1.28",
date: "2026-04-13",

View File

@@ -277,57 +277,6 @@ 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",
title: "Gemini CLI 与 Agent 环境变量",
changes: [],
features: [
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
"Agent 自定义环境变量router/proxy 模式),新增专用设置标签页",
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
"CLI `--parent` 更新父 Issue`--content-stdin` 管道输入评论内容",
"子 Issue 自动继承父级项目",
],
improvements: [
"编辑器气泡菜单和链接预览重写",
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析",
"自部署 WebSocket URL 自动适配局域网访问",
],
fixes: [
"S3 上传路径按工作区隔离(安全)",
"订阅和上传新增工作区成员身份校验(安全)",
"Issue 状态改为已取消时自动终止进行中的任务",
"Agent 进程 stdout 挂起导致任务卡住",
"Daemon 触发提示现在嵌入实际的触发评论内容",
"登录和仪表盘跳转稳定性改进",
],
},
{
version: "0.1.28",
date: "2026-04-13",

View File

@@ -45,10 +45,6 @@ const nextConfig: NextConfig = {
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
];
},
};

View File

@@ -1,81 +1,10 @@
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();
}
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(_request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: [
"/",
"/issues/:path*",
"/projects/:path*",
"/agents/:path*",
"/inbox/:path*",
"/my-issues/:path*",
"/autopilots/:path*",
"/runtimes/:path*",
"/skills/:path*",
"/settings/:path*",
],
matcher: ["/"],
};

View File

@@ -54,9 +54,6 @@ export const mockAgents: Agent[] = [
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
@@ -78,6 +75,7 @@ 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) => {

View File

@@ -36,8 +36,6 @@ 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"
@@ -74,4 +72,3 @@ services:
volumes:
pgdata:
backend_uploads:

View File

@@ -69,12 +69,10 @@
| 角色 | 字体 | 用途 |
|------|------|------|
| 正文/UI | Inter (`--font-sans`) | 所有界面文字的默认字体CJK 字符自动 fallback 到系统字体PingFang SC / Microsoft YaHei / Noto Sans CJK SC |
| 正文/UI | Geist Sans (`--font-sans`) | 所有界面文字的默认字体 |
| 代码/数据 | Geist Mono (`--font-mono`) | 代码块、ID、时间戳、等宽数据 |
| 标题 | `--font-heading`= `--font-sans` | 页面标题、区块标题 |
字体栈在 `apps/web/app/layout.tsx``apps/desktop/src/renderer/src/globals.css` 两处声明,修改时需同步。
### 3.2 字号纪律
**整个项目只使用 3 个核心字号 + 1 个特殊字号:**
@@ -100,7 +98,7 @@
| `font-normal` (400) | 正文、描述、大部分文字 |
| `font-medium` (500) | 标签、按钮、导航项、标题、选中状态 |
**禁止** `font-bold` / `font-semibold`——任务管理工具追求信息密度和"轻"感,加粗会破坏层次节奏。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
**禁止** `font-bold` / `font-semibold`——它们在 Geist 字体下显得突兀,破坏界面的"轻"感。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
---

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +0,0 @@
# Workspace URL 化重构 — 项目汇报
**日期**2026-04-15
**作者**Naiyuan
**状态**:调研完成,待评审
---
## 一、为什么要做
当前 workspace 上下文完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里**不含任何 workspace 信息**。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
这个设计已经在产品里直接表现为 3 个已知问题:
1. **分享链接不可靠**MUL-43`/issues/abc` 发给另一个成员,会用他自己 localStorage 里的 workspace 去解析,导致 404 或看到错误 workspace 的数据
2. **手机端无法切 workspace**MUL-509切换只靠 sidebar UI手机端不展开 sidebar 就没有切换入口
3. **多 tab 互相覆盖**`multica_workspace_id` 是全局 localStorage key两个 tab 打开不同 workspace 会互相污染
除了这 3 个显性 bug架构上的"多份 workspace 状态拷贝互相同步"也带来一些隐性问题(创建 workspace 闪页、切换 workspace 时 cache 竞态等),积累时间越长后续改动越难。
行业惯例Linear / Notion / Vercel / GitHub都是 `/{workspace-slug}/...` 的 URL 形态,把 URL 当作 workspace 的唯一来源。这是我们应该对齐的最佳实践。
## 二、调研结论
### 好消息:基础设施已经就位
- 数据库 `workspace.slug` 字段已经存在(`TEXT UNIQUE NOT NULL`),用户创建时手动指定且不可修改
- 后端已有 `GetWorkspaceBySlug` 查询
- 前端 `Workspace` 类型已包含 `slug` 字段
- Web 端认证已经切换为 HttpOnly cookie 模式Next.js middleware 可读到登录态
也就是说这次改造**不需要大量后端改动**,主要是前端路由和状态管理的重新组织。
### 坏消息:范围比最初估计大
初看以为只是"URL 前缀加个 slug",调研后发现必须一起做的事情有:
1. **URL 路由重组**web 端所有 dashboard 路由迁到 `app/[workspaceSlug]/(dashboard)/*`desktop 端所有 react-router 路由加 `/:workspaceSlug` 前缀
2. **状态管理清理**:删除 `useWorkspaceStore.workspace` 作为独立状态,改为从 URL 派生;删除 `hydrateWorkspace` / `switchWorkspace` actions切 workspace 变成纯导航);删除 `localStorage["multica_workspace_id"]`
3. **所有路径引用替换**`push("/issues")` 改为 path builder`paths.issues()`),影响 ~25 个组件文件
4. **Mutation 副作用重构**`useCreateWorkspace` / `useLeaveWorkspace` / `useDeleteWorkspace` 里的 `switchWorkspace` 调用全部移除(这些调用正是 MUL-727 闪页、MUL-728 删除后不跳转、MUL-820 接受邀请不切 workspace 等一系列 bug 的根因)
5. **桌面端 tab 系统适配**tab 路径天然包含 workspace切 workspace = 开新 tab 或导航,不再有全局切换动作
6. **Shareable URL 修复**:桌面端 `getShareableUrl` 当前生成 `https://www.multica.ai/issues/abc`(缺 slug需要更新
7. **后端保留词校验**slug 不能和前端顶级路由冲突(`login``onboarding``invite``api``settings` 等),后端创建时校验
8. **内部 markdown 链接兼容**issue 评论里写的 `[foo](/issues/abc)` 触发的 `multica:navigate` 事件需要自动补当前 workspace slug
### 不需要改的(边界已确认)
- 邮件邀请链接 `/invite/{id}` — 接受邀请是 pre-workspace 流程,不需要 slug
- `mention://type/id` 协议 — 只存 UUIDworkspace-agnostic
- CLI 登录 URL — `/login` 也是 pre-workspace不需要 slug
- 后端 API 路径 — 保持 `/api/workspaces/{id}`slug 仅用于前端 URL
- 桌面端 `multica://auth/callback` — 认证回调,不涉及 workspace
## 三、方案要点
**核心原则**URL 是 workspace 上下文的唯一 source of truth其他状态都是派生态。
**URL 形状**`/{workspace-slug}/issues/{id}` (和 Linear / Notion 一致)
**切换 workspace = 导航**sidebar 下拉改为 `<Link href="/{new-slug}/issues">`,不再有命令式的 `switchWorkspace` 函数。这样一次性消除前面列出的一大批 mutation 副作用 bug。
**预估影响面**~30-35 个文件,其中约 20 个是机械替换hardcoded 路径 → path builder真正需要思考的核心逻辑改动集中在 5-6 个文件。
**一个 PR 合并**中间状态不可运行URL 结构是原子变化),不拆 PR。worktree 里充分开发和自测,一次 review 合并。
## 四、执行与测试计划
### 执行阶段
1. **本周内**:完成方案详细实施文档(精确到文件 / 行号 / 代码片段)
2. **下一步**:在独立 worktree 上开发AI 辅助写代码,过程中人工 review
3. **开发完成后**:本地跑全套验证(`make check` — TypeScript + 单测 + Go 测试 + E2E
### 测试阶段
1. **本地自测**
- 已知功能路径(创建 / 浏览 / 搜索 issue切换 workspace接受邀请分享链接
- 已知 bug 场景MUL-43 / MUL-509 / MUL-727 / MUL-820逐一验证已修复
- 多 tab 场景(两个 tab 打开不同 workspace 互不影响)
2. **测试环境部署**:本地通过后发测试环境,全员试用几天,观察:
- 是否有回归(特别是导航流、创建/删除 workspace、邀请流程
- URL 使用感受(分享、收藏、刷新)
3. **灰度 / 生产**:测试环境稳定后推生产
### 风险提示
- **唯一的硬中断点**:现有的 `/issues` 等 URL 在重构后会 404产品还没正式 ship、用户量可忽略所以不做兼容性重定向
- **E2E 测试断言**:约 20-30 处 URL 断言需要更新
- **后端保留词清单**:如果现有 workspace 里有名字撞到保留词的(例如正好叫 `settings`),需要提前 migrate可能性极低因 slug 限制较严)
## 五、附注
这次重构会**顺带修掉**以下已登记 issue不需要单独开 PR
| Issue | 修复方式 |
|---|---|
| MUL-43切换 workspace 报错 / 分享链接失效) | URL 带 slug根本解决 |
| MUL-509手机端无法切 workspace | 切换变导航,手机能点链接就能切 |
| MUL-723workspace 不在 URL | 核心目标 |
| MUL-727创建 workspace 闪 /issues | 删除 mutation 里的 switchWorkspace 副作用 |
| MUL-728删除 workspace 后留在 /settings | 删除成功后 navigate 到下一个 workspace |
| MUL-820sidebar Join 不切 workspace | Join 改成跳转到 `/invite/{id}` 走统一路径 |
不在本次范围内的Issue #951WebSocket 半开导致 cache 陈旧)—— 这是 realtime 层独立问题,单独 PR 处理。
---
**当前状态**:准备进入详细实施方案撰写,预计完成后再同步一次。

View File

@@ -24,12 +24,10 @@ test.describe("Authentication", () => {
await page.goto("/login");
await page.evaluate(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
// 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.goto("/issues");
await page.waitForURL("**/login", { timeout: 10000 });
});

View File

@@ -16,9 +16,8 @@ test.describe("Comments", () => {
});
test("can add a comment on an issue", async ({ page }) => {
// 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();
// Wait for issues to load and click first one
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
@@ -43,7 +42,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-]+/);

View File

@@ -7,10 +7,7 @@
import "./env";
import pg from "pg";
// `||` (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 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 {
@@ -21,7 +18,6 @@ interface TestWorkspace {
export class TestApiClient {
private token: string | null = null;
private workspaceSlug: string | null = null;
private workspaceId: string | null = null;
private createdIssueIds: string[] = [];
@@ -90,16 +86,11 @@ 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;
}
@@ -159,8 +150,7 @@ export class TestApiClient {
...((init?.headers as Record<string, string>) ?? {}),
};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceSlug) headers["X-Workspace-Slug"] = this.workspaceSlug;
else if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
return fetch(`${API_BASE}${path}`, { ...init, headers });
}
}

View File

@@ -9,25 +9,19 @@ 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): Promise<string> {
export async function loginAsDefault(page: Page) {
const api = new TestApiClient();
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
const workspace = await api.ensureWorkspace(
"E2E Workspace",
DEFAULT_E2E_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(`/${workspace.slug}/issues`);
await page.goto("/issues");
await page.waitForURL("**/issues", { timeout: 10000 });
return workspace.slug;
}
/**

View File

@@ -65,10 +65,8 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
// 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}"]`);
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();

View File

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

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