mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
75 Commits
v0.2.19
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94dc5e0ec8 | ||
|
|
da5dbc6224 | ||
|
|
2129aa3dee | ||
|
|
2fd388da08 | ||
|
|
cba3db0d7f | ||
|
|
b1345685a3 | ||
|
|
44608713bb | ||
|
|
a28312c0b4 | ||
|
|
72d5135bf0 | ||
|
|
924c69114d | ||
|
|
700e6f3f24 | ||
|
|
d68f1f4bf1 | ||
|
|
281779330e | ||
|
|
949dffdf7e | ||
|
|
e6e9c64484 | ||
|
|
c6a26facd3 | ||
|
|
b6a3f8ed58 | ||
|
|
8c9c52b023 | ||
|
|
562949e1cb | ||
|
|
65f6e9c9f2 | ||
|
|
79d28b0da6 | ||
|
|
aeccd4f26e | ||
|
|
68ed2a32d9 | ||
|
|
f508190065 | ||
|
|
d5611d550a | ||
|
|
28b29ec5ee | ||
|
|
b98c2a5a0f | ||
|
|
b9118ae9b8 | ||
|
|
06880d6ba2 | ||
|
|
472e78022e | ||
|
|
5bf0e7022d | ||
|
|
665ac39730 | ||
|
|
55b7e2e93a | ||
|
|
80c5bb9e9e | ||
|
|
6a665c68a3 | ||
|
|
174b8c62a6 | ||
|
|
768d3f8b0c | ||
|
|
7dfa72465c | ||
|
|
0b969483a6 | ||
|
|
e024ab1232 | ||
|
|
f4eb83bd41 | ||
|
|
dde42ba84a | ||
|
|
9467a8c616 | ||
|
|
cfa38df97b | ||
|
|
4ad0a0b847 | ||
|
|
1fd583ef65 | ||
|
|
286ecf04b1 | ||
|
|
bd82607645 | ||
|
|
365e84b920 | ||
|
|
86e7de3e41 | ||
|
|
936ccce8fa | ||
|
|
49ccd22027 | ||
|
|
e66bd593ea | ||
|
|
7528022355 | ||
|
|
391a4ecd09 | ||
|
|
54d895a210 | ||
|
|
40a984c997 | ||
|
|
9ccaf18479 | ||
|
|
866b901943 | ||
|
|
9baa72cc68 | ||
|
|
576304519b | ||
|
|
f0a3f5ddeb | ||
|
|
22136a55fc | ||
|
|
375534573c | ||
|
|
2a59236575 | ||
|
|
415060e6be | ||
|
|
f745a3bbbe | ||
|
|
a475c17283 | ||
|
|
e4103f6ad7 | ||
|
|
2d9c153695 | ||
|
|
805071b5b1 | ||
|
|
f0c845b777 | ||
|
|
9587a577e2 | ||
|
|
21e3cfaa01 | ||
|
|
01855f6b09 |
@@ -181,8 +181,10 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -200,6 +202,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
@@ -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-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
33
apps/desktop/src/main/app-version.ts
Normal file
33
apps/desktop/src/main/app-version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
@@ -914,6 +914,20 @@ export function setupDaemonManager(
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
|
||||
// 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";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -110,6 +111,22 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
@@ -203,7 +220,7 @@ if (!gotTheLock) {
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
|
||||
1
apps/desktop/src/preload/index.d.ts
vendored
1
apps/desktop/src/preload/index.d.ts
vendored
@@ -68,6 +68,7 @@ interface DaemonAPI {
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
openLogFile: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
|
||||
@@ -145,6 +145,8 @@ const daemonAPI = {
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:open-log-file"),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
|
||||
@@ -7,13 +7,14 @@ import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -99,18 +100,29 @@ function AppContent() {
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
// Bridge local daemon IPC status into the runtimes cache so this user's
|
||||
// own daemon flips to offline/online sub-second instead of waiting on the
|
||||
// server's 75s sweeper. Resolves wsId from the active tab so workspace
|
||||
// switches automatically rebind the subscription.
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const activeWsId = activeWorkspaceSlug
|
||||
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Workspace presence wins over onboarding state: a user invited into an
|
||||
// existing workspace must enter that workspace, not be trapped in the
|
||||
// onboarding overlay just because their personal `onboarded_at` is null.
|
||||
// Onboarding is only the right destination when the account has zero
|
||||
// workspaces AND has never onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (wsCount > 0) return;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
} else {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
|
||||
@@ -1,150 +1,261 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Copy as CopyIcon,
|
||||
Search,
|
||||
Server,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
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";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
|
||||
|
||||
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;
|
||||
/** Number of runtimes this local daemon has registered (for the context badge). */
|
||||
runtimeCount: number;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
let logIdCounter = 0;
|
||||
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
|
||||
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
|
||||
INFO: "border-foreground/15 text-foreground/80",
|
||||
WARN: "border-warning/40 text-warning",
|
||||
ERROR: "border-destructive/40 text-destructive",
|
||||
};
|
||||
|
||||
// What gets rendered in the viewport — a single line or a folded group of
|
||||
// consecutive lines that share the same `message`. The group form is what
|
||||
// turns a wall of `DBG poll: no tasks` into a single placeholder.
|
||||
type DisplayItem =
|
||||
| { kind: "line"; line: ParsedLogLine }
|
||||
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
|
||||
|
||||
export function DaemonPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
runtimeCount,
|
||||
}: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
// Each level chip is an independent toggle. DEBUG is off by default so
|
||||
// poll-loop noise doesn't drown out real events when the panel opens —
|
||||
// users opt in if they want to see it.
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
|
||||
);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
|
||||
const idCounterRef = useRef(0);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// --- Log stream subscription ---
|
||||
// Active only while the modal is open. On open we replay the file's tail
|
||||
// (~200 lines) so users have context for "what just happened"; on close
|
||||
// we tear down the watcher so the main process isn't doing work for a
|
||||
// hidden UI.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
idCounterRef.current = 0;
|
||||
|
||||
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;
|
||||
const id = ++idCounterRef.current;
|
||||
const parsed = parseLogLine(line, id);
|
||||
const next =
|
||||
prev.length >= MAX_LOG_LINES
|
||||
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
|
||||
: [...prev, parsed];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
// --- Derived: counts per level (for filter chip badges) ---
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
};
|
||||
for (const l of logs) {
|
||||
if (l.level) counts[l.level] += 1;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
const handleLogScroll = useCallback(() => {
|
||||
// --- Derived: filtered list (level toggle + search) ---
|
||||
// Lines that didn't parse (level = null) always pass — they're typically
|
||||
// panic stack traces / partial writes; never silently drop them.
|
||||
const filtered = useMemo(() => {
|
||||
let result = logs;
|
||||
result = result.filter((l) => {
|
||||
if (!l.level) return true;
|
||||
return enabledLevels.has(l.level);
|
||||
});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((l) => l.raw.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [logs, enabledLevels, search]);
|
||||
|
||||
// --- Derived: collapse runs of consecutive lines that share the same
|
||||
// message into a single group placeholder. The most common case is the
|
||||
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
|
||||
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
|
||||
// doesn't strand groups.
|
||||
const displayed = useMemo<DisplayItem[]>(() => {
|
||||
const out: DisplayItem[] = [];
|
||||
for (const line of filtered) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last) {
|
||||
out.push({ kind: "line", line });
|
||||
continue;
|
||||
}
|
||||
const lastMessage =
|
||||
last.kind === "line" ? last.line.message : last.first.message;
|
||||
if (lastMessage && lastMessage === line.message) {
|
||||
if (last.kind === "line") {
|
||||
out[out.length - 1] = {
|
||||
kind: "group",
|
||||
first: last.line,
|
||||
rest: [line],
|
||||
};
|
||||
} else {
|
||||
last.rest.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: "line", line });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
|
||||
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [displayed, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
|
||||
// here. Re-enabling lives in the "Jump to latest" footer button so a
|
||||
// burst of lines doesn't yank a reading user back to the bottom.
|
||||
if (!atBottom && autoScroll) setAutoScroll(false);
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setAutoScroll(true);
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
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 toggleLevel = useCallback((lv: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lv)) next.delete(lv);
|
||||
else next.add(lv);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 toggleFields = useCallback((id: number) => {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 toggleGroup = useCallback((id: number) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
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>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Server className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Local daemon logs
|
||||
</DialogTitle>
|
||||
<ContextBadge status={status} runtimeCount={runtimeCount} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -153,157 +264,412 @@ export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</SheetHeader>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
}
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{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'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>
|
||||
{/* Level toggle chips. Each chip is independent — click to
|
||||
show/hide that level. DEBUG starts hidden because the
|
||||
poll-loop heartbeat dominates otherwise. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((lv) => (
|
||||
<FilterChip
|
||||
key={lv}
|
||||
active={enabledLevels.has(lv)}
|
||||
onClick={() => toggleLevel(lv)}
|
||||
label={lv}
|
||||
count={levelCounts[lv]}
|
||||
variant={lv}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
<CopyIcon className="size-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs viewport */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{displayed.length === 0 ? (
|
||||
<EmptyState
|
||||
hasLogs={logs.length > 0}
|
||||
hasFilter={hasActiveFilter}
|
||||
isRunning={status.state === "running"}
|
||||
/>
|
||||
) : (
|
||||
<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 className="flex flex-col">
|
||||
{displayed.map((item) =>
|
||||
item.kind === "line" ? (
|
||||
<LogLineRow
|
||||
key={item.line.id}
|
||||
line={item.line}
|
||||
expanded={expandedFields.has(item.line.id)}
|
||||
onToggle={() => toggleFields(item.line.id)}
|
||||
search={search}
|
||||
/>
|
||||
) : (
|
||||
<GroupRows
|
||||
key={item.first.id}
|
||||
first={item.first}
|
||||
rest={item.rest}
|
||||
expanded={expandedGroups.has(item.first.id)}
|
||||
onToggle={() => toggleGroup(item.first.id)}
|
||||
expandedFields={expandedFields}
|
||||
onToggleFields={toggleFields}
|
||||
search={search}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Status bar — count only. The "is the user following" state is
|
||||
communicated implicitly by the presence of the Jump-to-latest
|
||||
button below; an explicit "Paused" word read as "log stream is
|
||||
paused" (it isn't — data keeps flowing into the buffer). */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
Showing {filtered.length} of {logs.length}
|
||||
{logs.length === MAX_LOG_LINES && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
(buffer full)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function ContextBadge({
|
||||
status,
|
||||
runtimeCount,
|
||||
}: {
|
||||
status: DaemonStatus;
|
||||
runtimeCount: number;
|
||||
}) {
|
||||
const isRunning = status.state === "running";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && runtimeCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
variant?: LogLevel;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
|
||||
active
|
||||
? variant
|
||||
? LEVEL_BADGE_CLASS[variant]
|
||||
: "bg-accent text-accent-foreground"
|
||||
: "border-dashed text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
active ? "text-current/80" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
|
||||
LEVEL_BADGE_CLASS[level],
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLineRow({
|
||||
line,
|
||||
expanded,
|
||||
onToggle,
|
||||
search,
|
||||
}: {
|
||||
line: ParsedLogLine;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
search: string;
|
||||
}) {
|
||||
const fieldEntries = Object.entries(line.fields);
|
||||
const hasFields = fieldEntries.length > 0;
|
||||
|
||||
// Unparseable line — render the raw text so nothing is hidden. Common
|
||||
// for panic stack traces and partial writes during log rotation.
|
||||
if (!line.timestamp || !line.level) {
|
||||
return (
|
||||
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
|
||||
{highlight(line.raw, search)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
|
||||
hasFields && "cursor-pointer",
|
||||
)}
|
||||
onClick={hasFields ? onToggle : undefined}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<LevelBadge level={line.level} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="break-words">{highlight(line.message, search)}</span>
|
||||
{hasFields && !expanded && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60">
|
||||
{fieldEntries
|
||||
.map(([k, v]) => `${k}=${truncateValue(v)}`)
|
||||
.join(" ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && hasFields && (
|
||||
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{fieldEntries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-muted-foreground/70">{k}</span>
|
||||
<span className="break-all text-foreground/85">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
first,
|
||||
rest,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedFields,
|
||||
onToggleFields,
|
||||
search,
|
||||
}: {
|
||||
first: ParsedLogLine;
|
||||
rest: ParsedLogLine[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedFields: Set<number>;
|
||||
onToggleFields: (id: number) => void;
|
||||
search: string;
|
||||
}) {
|
||||
// Folded: show the first occurrence so the user still sees a sample
|
||||
// (timestamp, level, message), then a click-to-expand placeholder for
|
||||
// the suppressed run. The placeholder uses a dashed border + italics
|
||||
// so the eye reads it as "not a real line".
|
||||
if (!expanded) {
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>
|
||||
{rest.length} more “{truncateValue(first.message, 48)}
|
||||
” — click to expand
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unfolded: render every line, then a small "collapse" affordance at
|
||||
// the end so the user can put the toothpaste back in the tube.
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
{rest.map((l) => (
|
||||
<LogLineRow
|
||||
key={l.id}
|
||||
line={l}
|
||||
expanded={expandedFields.has(l.id)}
|
||||
onToggle={() => onToggleFields(l.id)}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>collapse {rest.length + 1} repeated</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasLogs,
|
||||
hasFilter,
|
||||
isRunning,
|
||||
}: {
|
||||
hasLogs: boolean;
|
||||
hasFilter: boolean;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
if (hasFilter) {
|
||||
title = "No matching log lines";
|
||||
subtitle = "Try a different search or level toggle.";
|
||||
} else if (!isRunning) {
|
||||
title = "Daemon isn't running";
|
||||
subtitle = "Start the daemon to see logs here.";
|
||||
} else if (!hasLogs) {
|
||||
title = "Waiting for logs…";
|
||||
subtitle = "New entries will appear in real time.";
|
||||
} else {
|
||||
title = "";
|
||||
subtitle = "";
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
|
||||
<p className="text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function truncateValue(value: string, max = 32): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const q = query.toLowerCase();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(q);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,94 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
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";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
runtimes
|
||||
.filter((r) => r.daemon_id === status.daemonId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}, [runtimes, status.daemonId]);
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
(t) =>
|
||||
localRuntimeIds.has(t.runtime_id) &&
|
||||
(t.status === "running" || t.status === "dispatched"),
|
||||
),
|
||||
[snapshot, localRuntimeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
@@ -36,7 +108,10 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
@@ -44,112 +119,214 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
} else {
|
||||
setConfirmStop(true);
|
||||
}
|
||||
}, [affectedTasks.length, performStop]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRetryInstall = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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();
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
|
||||
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>
|
||||
</>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
</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 && (
|
||||
<>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</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} />
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
status={status}
|
||||
runtimeCount={runtimeCount}
|
||||
/>
|
||||
|
||||
<StopConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
affectedCount={affectedTasks.length}
|
||||
onConfirm={() => {
|
||||
setConfirmStop(false);
|
||||
void performStop();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function StopConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
affectedCount,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
affectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const plural = affectedCount === 1 ? "" : "s";
|
||||
const verb = affectedCount === 1 ? "is" : "are";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Stop daemon with {affectedCount} active task{plural}?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{affectedCount} task{plural} {verb} currently running on this
|
||||
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
|
||||
— affected tasks get marked <strong>failed</strong> once the
|
||||
timeout hits. The daemon won't auto-restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Stop daemon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, type ReactNode } 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";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -10,7 +16,7 @@ function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
@@ -23,14 +29,44 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
// One row inside the diagnostics block. Values that are likely to be
|
||||
// long IDs / URLs render as monospaced + truncated with a tooltip.
|
||||
function DiagnosticsRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</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);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
@@ -98,6 +134,68 @@ export function DaemonSettingsTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics — moved out of the logs panel so the panel can focus
|
||||
on logs. These fields matter for support tickets and bug reports,
|
||||
not for everyday use. */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold">Diagnostics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Identification and connection details. Useful when filing a bug
|
||||
report or investigating why a runtime isn't showing up.
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
|
||||
<DiagnosticsRow
|
||||
label="State"
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Uptime"
|
||||
value={status.uptime ? formatUptime(status.uptime) : "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="PID"
|
||||
value={status.pid ?? "—"}
|
||||
mono={!!status.pid}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Daemon ID"
|
||||
value={status.daemonId ?? "—"}
|
||||
mono={!!status.daemonId}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Profile"
|
||||
value={status.profile || "default"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Server URL"
|
||||
value={status.serverUrl ?? "—"}
|
||||
mono={!!status.serverUrl}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Device name"
|
||||
value={status.deviceName ?? "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Workspaces"
|
||||
value={
|
||||
typeof status.workspaceCount === "number"
|
||||
? status.workspaceCount
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
@@ -157,9 +158,11 @@ export function DesktopShell() {
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling */}
|
||||
{/* 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>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLogLine } from "./parse-daemon-log";
|
||||
|
||||
// All sample lines below are taken verbatim from real daemon output (Go
|
||||
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
|
||||
// stay aligned with what tint actually writes — not what we assume.
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses tint's 3-letter INF level", () => {
|
||||
const line =
|
||||
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.timestamp).toBe("17:52:35.587");
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("task completed");
|
||||
expect(r.fields).toEqual({
|
||||
component: "daemon",
|
||||
task: "c45266e5",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-letter DBG / WRN / ERR levels", () => {
|
||||
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
|
||||
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
|
||||
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("still accepts 4-letter level names (defensive against config changes)", () => {
|
||||
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("regular");
|
||||
});
|
||||
|
||||
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
|
||||
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
|
||||
// We treat the base level as canonical and drop the delta from the UI.
|
||||
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("unusual delta");
|
||||
});
|
||||
|
||||
it("preserves message text containing colons and special chars", () => {
|
||||
// Real sample: "tool #1: Skill component=daemon task=..."
|
||||
const r = parseLogLine(
|
||||
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("tool #1: Skill");
|
||||
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
|
||||
});
|
||||
|
||||
it("unquotes a double-quoted value containing escaped quotes", () => {
|
||||
// Real sample with escaped quotes inside the agent's emitted text.
|
||||
const line =
|
||||
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent");
|
||||
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
|
||||
expect(r.fields.task).toBe("8791b717");
|
||||
});
|
||||
|
||||
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
|
||||
// Real sample: error="Post \"http://...\": dial tcp ..."
|
||||
const line =
|
||||
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.level).toBe("WARN");
|
||||
expect(r.message).toBe("claim task failed");
|
||||
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
|
||||
expect(r.fields.error).toBe(
|
||||
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
|
||||
const line =
|
||||
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent command");
|
||||
expect(r.fields.exec).toBe("claude");
|
||||
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
|
||||
});
|
||||
|
||||
it("handles message words ending with characters before the field block", () => {
|
||||
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
|
||||
const r = parseLogLine(
|
||||
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("execenv: prepared env");
|
||||
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
|
||||
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
|
||||
expect(r.fields).toEqual({});
|
||||
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for unrecognised level tokens", () => {
|
||||
// If tint ever emits something we don't know, never crash; show raw.
|
||||
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
|
||||
});
|
||||
|
||||
it("attaches an id to every parsed line for stable React keys", () => {
|
||||
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
|
||||
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
|
||||
expect(a.id).toBe(7);
|
||||
expect(b.id).toBe(8);
|
||||
});
|
||||
|
||||
it("returns empty fields object when there are no key=value pairs", () => {
|
||||
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
|
||||
expect(r.message).toBe("a bare message with no fields");
|
||||
expect(r.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
96
apps/desktop/src/renderer/src/components/parse-daemon-log.ts
Normal file
96
apps/desktop/src/renderer/src/components/parse-daemon-log.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Pure parser for daemon log lines. The daemon writes via Go's slog with
|
||||
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
|
||||
// has a stable shape:
|
||||
//
|
||||
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
|
||||
//
|
||||
// We split it into structured pieces so the UI can render timestamp,
|
||||
// level, message and structured fields in separate columns and let users
|
||||
// filter / search across them. Anything that doesn't match (panic stack
|
||||
// traces, third-party prints, partial writes during log rotation) falls
|
||||
// back to a raw view — we never drop input.
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ParsedLogLine {
|
||||
/** Monotonic id assigned at receive time; stable across re-renders. */
|
||||
id: number;
|
||||
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
|
||||
timestamp: string | null;
|
||||
level: LogLevel | null;
|
||||
/** Human-readable message body, with structured fields stripped off. */
|
||||
message: string;
|
||||
/** key/value pairs trailing the message. Empty if there were none. */
|
||||
fields: Record<string, string>;
|
||||
/** The original line, kept for fallback rendering and copy-to-clipboard. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
|
||||
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
|
||||
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
|
||||
// against future config changes) and normalize them to a canonical
|
||||
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
|
||||
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
|
||||
// and complicates the level filter chips.
|
||||
const HEADER_RE =
|
||||
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
|
||||
|
||||
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
|
||||
DEBUG: "DEBUG",
|
||||
DBG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
INF: "INFO",
|
||||
WARN: "WARN",
|
||||
WRN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
ERR: "ERROR",
|
||||
};
|
||||
// Anchored to the END of the remaining string so we peel one field at a
|
||||
// time from the right. `value` is either a double-quoted string (which may
|
||||
// contain escaped chars) or any non-whitespace run.
|
||||
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractTrailingFields(rest: string): {
|
||||
message: string;
|
||||
fields: Record<string, string>;
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
let work = rest;
|
||||
while (true) {
|
||||
const match = work.match(TRAILING_FIELD_RE);
|
||||
if (!match || match.index === undefined) break;
|
||||
fields[match[1]!] = unquote(match[2]!);
|
||||
work = work.slice(0, match.index);
|
||||
}
|
||||
return { message: work.trim(), fields };
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string, id: number): ParsedLogLine {
|
||||
const match = raw.match(HEADER_RE);
|
||||
if (!match) {
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const [, timestamp, level, rest] = match;
|
||||
const normalized = LEVEL_NORMALIZE[level!];
|
||||
if (!normalized) {
|
||||
// Unknown level token — keep raw shape so we don't mis-categorize.
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const { message, fields } = extractTrailingFields(rest!);
|
||||
return {
|
||||
id,
|
||||
timestamp: timestamp!,
|
||||
level: normalized,
|
||||
message,
|
||||
fields,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
@@ -40,7 +39,6 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
@@ -22,7 +23,7 @@ export function UpdatesSettingsTab() {
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
: { status: "up-to-date" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -35,6 +36,15 @@ export function UpdatesSettingsTab() {
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
@@ -45,7 +55,7 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
You're on the latest version.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,7 @@ export function WorkspaceRouteLayout() {
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
|
||||
18
apps/desktop/src/renderer/src/pages/agent-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/agent-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agent = agents.find((a) => a.id === id) ?? null;
|
||||
|
||||
useDocumentTitle(agent?.name ?? "Agent");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedAgentDetailPage agentId={id} />;
|
||||
}
|
||||
18
apps/desktop/src/renderer/src/pages/runtime-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/runtime-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function RuntimeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const runtime = runtimes?.find((r) => r.id === id);
|
||||
|
||||
useDocumentTitle(runtime?.name ?? "Runtime");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedRuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
76
apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
Normal file
76
apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { runtimeKeys } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
|
||||
* type — we redeclare the fields we consume here to avoid coupling the bridge
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
|
||||
* field is overridden; other fields (name, provider, last_seen_at, etc)
|
||||
* remain server-authoritative. We deliberately ignore intermediate states
|
||||
* (starting / stopping / installing_cli / cli_not_found) so the cache
|
||||
* doesn't flap during boot — if the daemon is in such a state, the runtime
|
||||
* is effectively offline anyway, and the server-side sweeper will mark it
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return {
|
||||
...rt,
|
||||
status: "online",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local daemon status changes via Electron IPC and writes them
|
||||
* into the runtimes Query cache for the active workspace.
|
||||
*
|
||||
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
|
||||
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
|
||||
* we know about local daemon state instantly via IPC, so we use it to
|
||||
* pre-populate the cache and give users a sub-second feedback loop. Web and
|
||||
* "looking at someone else's daemon" still go through the server path.
|
||||
*
|
||||
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
|
||||
* in the same workspace (one per provider). We map across all matches so
|
||||
* every related runtime row sees the same status flip.
|
||||
*/
|
||||
export function useDaemonIPCBridge(wsId: string | undefined): void {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
|
||||
if (!daemonAPI?.onStatusChange) return;
|
||||
|
||||
const unsubscribe = daemonAPI.onStatusChange((status) => {
|
||||
if (!status.daemonId) return;
|
||||
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((rt) =>
|
||||
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [wsId, qc]);
|
||||
}
|
||||
@@ -115,10 +115,10 @@ export function DesktopNavigationProvider({
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() can read URL search params.
|
||||
// Must stay in sync with TabNavigationProvider below; a partial shape
|
||||
// here (just pathname) silently broke focus-mode anchor resolution on
|
||||
// `/inbox?issue=…`.
|
||||
// shell-level consumers of useNavigation() — ChatWindow in particular —
|
||||
// can read URL search params. Must stay in sync with TabNavigationProvider
|
||||
// below; a partial shape here (just pathname) silently broke focus-mode
|
||||
// anchor resolution on `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
|
||||
@@ -10,6 +10,8 @@ import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
@@ -18,7 +20,6 @@ import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { ChatPage } from "@multica/views/chat";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
@@ -118,6 +119,11 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{
|
||||
path: "runtimes/:id",
|
||||
element: <RuntimeDetailPage />,
|
||||
handle: { title: "Runtime" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
@@ -125,8 +131,12 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
@@ -101,7 +101,6 @@ interface TabStore {
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
chat: "MessageSquare",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
|
||||
@@ -51,3 +51,35 @@ export function formatUptime(uptime?: string): string {
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
* "what does this mean for me?" — i.e. whether tasks can run on this device.
|
||||
*
|
||||
* `runtimeCount` is the number of runtimes the local daemon has registered
|
||||
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
|
||||
* when state === "running".
|
||||
*/
|
||||
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
if (runtimeCount === 0) {
|
||||
return "Running, but no runtimes have registered yet.";
|
||||
}
|
||||
if (runtimeCount === 1) {
|
||||
return "Running here · 1 runtime available for tasks.";
|
||||
}
|
||||
return `Running here · ${runtimeCount} runtimes available for tasks.`;
|
||||
case "stopped":
|
||||
return "Not running · this device can't take new tasks.";
|
||||
case "starting":
|
||||
return "Starting up the local daemon…";
|
||||
case "stopping":
|
||||
return "Shutting down the local daemon…";
|
||||
case "installing_cli":
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,38 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"members-roles",
|
||||
"issues",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---Agents---",
|
||||
"agents",
|
||||
"agents-create",
|
||||
|
||||
144
apps/docs/content/docs/project-resources.mdx
Normal file
144
apps/docs/content/docs/project-resources.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Project Resources
|
||||
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
|
||||
---
|
||||
|
||||
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
|
||||
|
||||
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
|
||||
|
||||
## Mental model
|
||||
|
||||
A project is no longer just a label. It is a small **resource container**:
|
||||
|
||||
- A project has 0..N **resources**.
|
||||
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
|
||||
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
|
||||
|
||||
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
|
||||
|
||||
## Today: `github_repo`
|
||||
|
||||
The first resource type ships ready to use:
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/owner/repo",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
|
||||
|
||||
## Attaching repos at project creation
|
||||
|
||||
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
|
||||
|
||||
From the **CLI**:
|
||||
|
||||
```bash
|
||||
# Create + attach in one shot. The server attaches resources in the same
|
||||
# transaction as the project create — invalid resources roll back the whole
|
||||
# operation, so you never end up with a project that has half its resources.
|
||||
multica project create \
|
||||
--title "Agent UX 2026" \
|
||||
--repo https://github.com/multica-ai/multica
|
||||
|
||||
# Manage resources later
|
||||
multica project resource list <project-id>
|
||||
multica project resource add <project-id> --type github_repo --url <url>
|
||||
multica project resource remove <project-id> <resource-id>
|
||||
|
||||
# Generic escape hatch for any resource_type the server understands —
|
||||
# no CLI change needed when a new type ships:
|
||||
multica project resource add <project-id> \
|
||||
--type notion_page \
|
||||
--ref '{"page_id":"…","title":"…"}'
|
||||
```
|
||||
|
||||
`--repo` may be repeated; each value is attached as a separate `github_repo` resource.
|
||||
|
||||
## What the agent sees at runtime
|
||||
|
||||
When the daemon spawns an agent for an issue inside a project, two things happen:
|
||||
|
||||
### 1. `.multica/project/resources.json`
|
||||
|
||||
A structured pass-through of the API response, written into the agent's working directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "…",
|
||||
"project_title": "Agent UX 2026",
|
||||
"resources": [
|
||||
{
|
||||
"id": "…",
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/multica-ai/multica",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Skills, helper scripts, or the agent itself can parse this file when they need the *exact* set of resources for the run.
|
||||
|
||||
### 2. A "Project Context" section in the meta-skill prompt
|
||||
|
||||
The agent's `CLAUDE.md` / `AGENTS.md` (depending on provider) now includes a human-readable summary:
|
||||
|
||||
```
|
||||
## Project Context
|
||||
|
||||
This issue belongs to **Agent UX 2026**.
|
||||
|
||||
Project resources (also written to `.multica/project/resources.json`):
|
||||
|
||||
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
|
||||
|
||||
Resources are pointers — open them only when relevant to the task. For
|
||||
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
|
||||
```
|
||||
|
||||
The text is intentionally minimal. The full payload is on disk; the prompt only orients the agent so it knows the project exists and what's attached.
|
||||
|
||||
### Failure mode
|
||||
|
||||
Resource fetch is **best-effort**. If the API call fails, the project section is omitted from the prompt and the file is not written, but the task still starts. Agents never block on missing project context.
|
||||
|
||||
## Adding a new resource type
|
||||
|
||||
The whole point of the abstraction is that new types are cheap. The full path:
|
||||
|
||||
1. **Server validator** (`server/internal/handler/project_resource.go`) — add a case in `validateAndNormalizeResourceRef` that parses and normalizes the new payload.
|
||||
2. **Daemon meta-skill formatter** (`server/internal/daemon/execenv/runtime_config.go`) — add a case in `formatProjectResource` so the agent prompt renders the new type as a readable bullet.
|
||||
3. **TypeScript types** (`packages/core/types/project.ts`) — extend `ProjectResourceType` and add the payload interface.
|
||||
4. **UI renderer** (`packages/views/projects/components/project-resources-section.tsx`) — add a case in `ResourceRow` for the new type.
|
||||
|
||||
There is **no schema migration**, no new sqlc query, no new endpoint, **and no CLI change** — the CLI's generic `--ref '<json>'` flag accepts any payload the validator understands, so day-one support for a new type is purely the four steps above. (You may *optionally* add a per-type CLI shortcut later; not required.)
|
||||
|
||||
The same `project_resource` table and the same three CRUD calls handle every type.
|
||||
|
||||
## Workspace repos vs. project repos
|
||||
|
||||
The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGENTS.md`) is chosen by the daemon claim handler with this precedence:
|
||||
|
||||
- **Project has at least one `github_repo` resource** → only those repos are surfaced to the agent. Workspace-bound repos are intentionally hidden so the agent doesn't have to guess which one belongs to this issue.
|
||||
- **Project has no `github_repo` resources (or the issue isn't in a project)** → fall back to the workspace's repo list as before.
|
||||
|
||||
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
|
||||
|
||||
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
|
||||
|
||||
## What's intentionally **not** in scope here
|
||||
|
||||
- **Cross-project sharing.** Each resource lives on exactly one project today.
|
||||
- **Per-skill resource scoping.** All resources are visible to every skill on the agent's run; type-aware filtering is a follow-up.
|
||||
- **Caching / sync.** `github_repo` is just metadata — checkout still happens via `multica repo checkout` on demand. Cached document text for Notion / Google Docs will arrive with those types.
|
||||
|
||||
These are deliberate omissions — the goal of the first cut is to validate the abstraction with the smallest set of moving parts.
|
||||
1
apps/web/.gitignore
vendored
Normal file
1
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
@@ -72,10 +72,6 @@ function LoginPageContent() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
@@ -89,10 +85,6 @@ function LoginPageContent() {
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
|
||||
@@ -32,20 +32,25 @@ export default function OnboardingPage() {
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
enabled: !!user,
|
||||
});
|
||||
const hasWorkspaces = workspaces.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
if (!workspacesFetched) return;
|
||||
// Bounce out if onboarding doesn't apply: either already onboarded, or
|
||||
// the user already has a workspace (e.g. arrived via invitation) — we
|
||||
// never trap an in-workspace user on the onboarding screen.
|
||||
if (hasOnboarded || hasWorkspaces) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, hasWorkspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
if (isLoading || !user || hasOnboarded || hasWorkspaces) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { AgentDetailPage } from "@multica/views/agents";
|
||||
|
||||
export default function AgentDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <AgentDetailPage agentId={id} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ChatPage as default } from "@multica/views/chat";
|
||||
@@ -3,6 +3,7 @@
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
@@ -13,6 +14,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
extra={
|
||||
<>
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { RuntimeDetailPage } from "@multica/views/runtimes";
|
||||
|
||||
export default function RuntimeDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <RuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
@@ -61,28 +61,54 @@ import CallbackPage from "./page";
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
// Snapshot keys before deleting — forEach + delete skips entries because
|
||||
// the iteration index advances while the underlying list shrinks.
|
||||
Array.from(mockSearchParams.keys()).forEach((k) =>
|
||||
mockSearchParams.delete(k),
|
||||
);
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
|
||||
mockListWorkspaces.mockResolvedValue([
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "ACME",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
|
||||
@@ -66,10 +66,10 @@ function CallbackContent() {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
// Workspace presence beats onboarding state: an invitee with zero
|
||||
// `onboarded_at` but a real workspace must land in that workspace,
|
||||
// not in the new-workspace wizard. A `next=` (e.g. /invite/<id>)
|
||||
// always wins so invite acceptance flows survive auth round-trips.
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
|
||||
61
apps/web/app/not-found.tsx
Normal file
61
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
|
||||
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
|
||||
// inline — these brand experiments have not been promoted to design tokens.
|
||||
// The route lives outside the (landing) group's font scope, so we attach
|
||||
// Instrument Serif locally to match the editorial direction.
|
||||
const CREAM = "#faf9f6";
|
||||
const INK = "#1b1812";
|
||||
const TERRACOTTA = "#a64a2c";
|
||||
|
||||
const editorialSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section
|
||||
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
|
||||
style={{ backgroundColor: CREAM, color: INK }}
|
||||
>
|
||||
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
|
||||
style={{ color: TERRACOTTA }}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
<span>error · not found</span>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
</div>
|
||||
|
||||
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
|
||||
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
|
||||
This page{" "}
|
||||
<em className="not-italic" style={{ color: TERRACOTTA }}>
|
||||
doesn’t exist
|
||||
</em>
|
||||
.
|
||||
</p>
|
||||
<p
|
||||
className="mt-5 max-w-md text-center text-sm leading-relaxed"
|
||||
style={{ color: INK, opacity: 0.6 }}
|
||||
>
|
||||
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
|
||||
style={{ background: INK, color: CREAM }}
|
||||
>
|
||||
Back to Multica
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +283,29 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
title: "Create Issue by Agent, Agent Presence v3 & Daemon WebSocket Heartbeat",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent — press `c`, write one line, pick an agent; issue creation runs async and the result lands in your inbox",
|
||||
"Agent Presence v3 — availability and last-task split into clearer signals, with an execution log on the issue panel showing active and recent runs",
|
||||
"Daemon ↔ server heartbeat now flows over WebSocket with HTTP fallback, cutting task wakeup latency",
|
||||
"Mention picker ranks suggestions by your local recency",
|
||||
],
|
||||
improvements: [
|
||||
"Server caches PAT / daemon token lookups in Redis, so large fleets stop hammering the database on every request",
|
||||
"Backend default agent CLI args via `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` env vars",
|
||||
"Manual and agent create-issue flows share one dialog shell, and picker agents become the default assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create-issue-by-agent no longer leaves tasks stuck queued, and no longer duplicates the issue when an attachment upload fails",
|
||||
"Agent comments respect newlines instead of rendering literal `\\n`, and multi-line replies keep their formatting",
|
||||
"Agent-authored root comments no longer inherit parent @mentions, breaking accidental agent loops",
|
||||
"Cursor agent on Windows preserves multi-line prompts",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
|
||||
@@ -283,6 +283,29 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
title: "Create Issue by Agent、Agent Presence v3 与 Daemon WebSocket 心跳",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent —— 按 `c` 输入一句话并选 Agent,Issue 异步创建,结果回执送达 Inbox",
|
||||
"Agent Presence v3 —— 可用性与最近任务拆成两条更清晰的信号;Issue 详情右侧新增 Execution Log,可看到当前 active run 与历史 run",
|
||||
"Daemon ↔ Server 心跳改走 WebSocket,HTTP 自动 fallback,任务起跑延迟更低",
|
||||
"Mention 选择器按本机最近使用排序",
|
||||
],
|
||||
improvements: [
|
||||
"Server 用 Redis 缓存 PAT / Daemon Token 校验,大型团队不再让 DB 抗下每次请求",
|
||||
"后端支持通过 `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` 配置 Agent CLI 默认参数",
|
||||
"Manual 与 Agent 创建 Issue 共享同一个 Dialog 外壳,picker Agent 会被默认设为 assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create Issue by Agent 不再卡住 queued 任务,也不再因附件上传失败而重复创建 Issue",
|
||||
"Agent 评论保留换行,不再渲染成字面量 `\\n`,多行回复的格式也被完整保留",
|
||||
"Agent 自身发出的根评论不再继承父评论的 @mention,避免互相唤起的死循环",
|
||||
"Windows 下 Cursor Agent 启动时保留多行 prompt",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
4
packages/core/agents/constants.ts
Normal file
4
packages/core/agents/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// User-facing limits enforced symmetrically on the front-end (UI counter +
|
||||
// disabled save) and the back-end (handler validation + DB CHECK constraint).
|
||||
// Kept in core so both apps and the test suite read from one source.
|
||||
export const AGENT_DESCRIPTION_MAX_LENGTH = 255;
|
||||
418
packages/core/agents/derive-presence.test.ts
Normal file
418
packages/core/agents/derive-presence.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import {
|
||||
buildPresenceMap,
|
||||
deriveAgentAvailability,
|
||||
deriveAgentPresenceDetail,
|
||||
deriveWorkload,
|
||||
deriveWorkloadDetail,
|
||||
} from "./derive-presence";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: "rt-1",
|
||||
name: "Test Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 6,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Anchor for all wall-clock comparisons in the suite. Pairs with the
|
||||
// runtime fixture's last_seen_at (10s before NOW) so an "online" runtime
|
||||
// looks fresh by default.
|
||||
const NOW = new Date("2026-04-27T12:00:00Z").getTime();
|
||||
|
||||
function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "rt-1",
|
||||
issue_id: "",
|
||||
status: "queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-27T11:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveAgentAvailability", () => {
|
||||
// Reachability dimension only — runtime + clock decide it; tasks are
|
||||
// irrelevant to this axis.
|
||||
|
||||
it("returns online when runtime is fresh-online", () => {
|
||||
expect(deriveAgentAvailability(makeRuntime(), NOW)).toBe("online");
|
||||
});
|
||||
|
||||
it("returns unstable when runtime just dropped (< 5 min)", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:59:30Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("unstable");
|
||||
});
|
||||
|
||||
it("returns offline when runtime has been gone > 5 min", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:50:00Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("collapses about_to_gc into offline (it's a runtime-card concern, not the dot)", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
// 6.5 days ago — past the 6-day about_to_gc threshold.
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-21T00:00:00Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("returns offline when the runtime is null (deleted / never registered)", () => {
|
||||
expect(deriveAgentAvailability(null, NOW)).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkload", () => {
|
||||
// Atomic 3-way classifier — used by both Agent (per-agent task counts)
|
||||
// and Runtime (per-runtime aggregated counts). Pure functional mapping
|
||||
// from a count pair to a workload label.
|
||||
|
||||
it("returns working when runningCount > 0", () => {
|
||||
expect(deriveWorkload({ runningCount: 1, queuedCount: 0 })).toBe("working");
|
||||
expect(deriveWorkload({ runningCount: 3, queuedCount: 5 })).toBe("working");
|
||||
});
|
||||
|
||||
it("returns queued when nothing running but queuedCount > 0", () => {
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 1 })).toBe("queued");
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 5 })).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns idle when both counts are zero", () => {
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 0 })).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkloadDetail", () => {
|
||||
// Aggregates a task list into running/queued counts before classifying.
|
||||
// Terminal statuses (completed / failed / cancelled) are silently
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
|
||||
it("returns idle when no tasks at all", () => {
|
||||
const r = deriveWorkloadDetail([]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns working when at least one task is running", () => {
|
||||
const r = deriveWorkloadDetail([makeTask({ status: "running" })]);
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns queued when only queued / dispatched tasks exist (no running)", () => {
|
||||
// The "stuck on offline runtime" scenario in isolation: runningCount=0,
|
||||
// queuedCount>0 surfaces as `queued` so the UI can honestly say
|
||||
// "Queued · N" instead of misleading "Running 0/3 +Nq".
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "dispatched" }),
|
||||
]);
|
||||
expect(r.workload).toBe("queued");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns working when running coexists with queued (overflow)", () => {
|
||||
// Capacity-saturated agent: still running, but with a queue building.
|
||||
// The chip says "Working" with the queue expressed as a `+Nq` badge.
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ id: "t1", status: "running" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
makeTask({ id: "t3", status: "queued" }),
|
||||
]);
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores terminal statuses entirely (no historical state in workload)", () => {
|
||||
// Failed / completed / cancelled tasks contribute no count and don't
|
||||
// change the verdict — Recent Work + Inbox handle history, not workload.
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({
|
||||
id: "t-failed",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-completed",
|
||||
status: "completed",
|
||||
completed_at: "2026-04-27T11:00:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-cancelled",
|
||||
status: "cancelled",
|
||||
completed_at: "2026-04-27T10:30:00Z",
|
||||
}),
|
||||
]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("classifies running over queued when both present, regardless of order", () => {
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ id: "t1", status: "queued" }),
|
||||
makeTask({ id: "t2", status: "running" }),
|
||||
]);
|
||||
expect(r.workload).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAgentPresenceDetail", () => {
|
||||
// Composition: the two dimensions are derived independently and the
|
||||
// detail object exposes both. No cross-axis override — workload never
|
||||
// colours the dot, availability never overrides workload.
|
||||
|
||||
it("composes online + working for the common busy case", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [
|
||||
makeTask({ status: "running" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("working");
|
||||
expect(detail.runningCount).toBe(1);
|
||||
expect(detail.queuedCount).toBe(1);
|
||||
expect(detail.capacity).toBe(6);
|
||||
});
|
||||
|
||||
it("composes offline + queued — the canonical 'stuck' case (was previously misleading 'running 0/N')", () => {
|
||||
// The motivation for the redesign: runtime offline + queued tasks
|
||||
// used to surface as `running` with `0/3 +2q` counts (literally false).
|
||||
// Workload now returns `queued` honestly, paired with offline
|
||||
// availability — UI reads "Offline · Queued · 2".
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("queued");
|
||||
expect(detail.runningCount).toBe(0);
|
||||
expect(detail.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("composes unstable + working — runtime hiccup with tasks still in flight", () => {
|
||||
// Recently-lost runtime, but a task is still recorded as running.
|
||||
// Both signals surface independently — amber dot AND working chip —
|
||||
// so the user sees "connection wobbling" alongside "agent is busy".
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:59:00Z",
|
||||
}),
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("unstable");
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("composes offline + idle for an unreachable agent with no tasks pending", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("handles a missing runtime by reporting offline + the task-driven workload", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: null,
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("returns idle workload when only terminal tasks are present (history doesn't bleed in)", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [
|
||||
makeTask({
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("mirrors agent.max_concurrent_tasks into capacity", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent({ max_concurrent_tasks: 3 }),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.capacity).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPresenceMap", () => {
|
||||
it("returns one entry per agent, sourcing tasks by agent_id from a flat list", () => {
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA, agentB],
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "running" }),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
const a = map.get("a");
|
||||
const b = map.get("b");
|
||||
expect(a?.availability).toBe("online");
|
||||
expect(a?.workload).toBe("working");
|
||||
expect(b?.availability).toBe("online");
|
||||
expect(b?.workload).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns offline availability for agents whose runtime_id has no matching runtime", () => {
|
||||
const orphan = makeAgent({ id: "orphan", runtime_id: "missing" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [orphan],
|
||||
runtimes: [],
|
||||
snapshot: [makeTask({ agent_id: "orphan", status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
const o = map.get("orphan");
|
||||
expect(o?.availability).toBe("offline");
|
||||
// Workload still resolves independently — running task counts.
|
||||
expect(o?.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("threads the same `now` so every agent on a shared runtime gets the same availability", () => {
|
||||
// Multi-agent scenario: one local daemon backs N agents, daemon dies.
|
||||
// All dependent agents should report unstable together — the shared
|
||||
// `now` parameter is what guarantees consistent bucket boundaries.
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA, agentB],
|
||||
runtimes: [
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:59:00Z",
|
||||
}),
|
||||
],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "queued" }),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "running" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.availability).toBe("unstable");
|
||||
expect(map.get("b")?.availability).toBe("unstable");
|
||||
// Workload remains independent: a is queued (waiting), b is working.
|
||||
expect(map.get("a")?.workload).toBe("queued");
|
||||
expect(map.get("b")?.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("ignores terminal tasks in the snapshot when building per-agent workload", () => {
|
||||
// Snapshot intentionally still includes each agent's most recent
|
||||
// terminal task (back-end SQL didn't change); the front-end now
|
||||
// filters them out at the workload-derivation step.
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA],
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({
|
||||
id: "t-terminal",
|
||||
agent_id: "a",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.workload).toBe("idle");
|
||||
});
|
||||
});
|
||||
134
packages/core/agents/derive-presence.ts
Normal file
134
packages/core/agents/derive-presence.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Pure derivation of an agent's user-facing presence from raw server data.
|
||||
// The back-end stores facts (which tasks exist, their statuses, the runtime
|
||||
// last_seen_at); the front-end translates them into two orthogonal
|
||||
// dimensions:
|
||||
//
|
||||
// 1. AgentAvailability — derived from runtime reachability only.
|
||||
// 2. Workload — derived from the task counts only.
|
||||
//
|
||||
// They are computed independently and assembled into AgentPresenceDetail.
|
||||
// Workload is strictly "what's on the plate right now" — no historical
|
||||
// terminal state. Past failures / completions live on the detail page
|
||||
// (Recent Work, failure_reason) and Inbox.
|
||||
|
||||
import { deriveRuntimeHealth } from "../runtimes/derive-health";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import type {
|
||||
AgentAvailability,
|
||||
AgentPresenceDetail,
|
||||
Workload,
|
||||
} from "./types";
|
||||
|
||||
// AgentAvailability mirrors RuntimeHealth's reachability buckets but folds
|
||||
// `about_to_gc` into `offline` — both mean "long unreachable" from the
|
||||
// user's standpoint; the GC-warning copy belongs to the runtime card, not
|
||||
// the agent dot.
|
||||
export function deriveAgentAvailability(
|
||||
runtime: AgentRuntime | null,
|
||||
now: number,
|
||||
): AgentAvailability {
|
||||
if (!runtime) return "offline";
|
||||
const health = deriveRuntimeHealth(runtime, now);
|
||||
if (health === "online") return "online";
|
||||
if (health === "recently_lost") return "unstable";
|
||||
return "offline"; // offline | about_to_gc collapse here
|
||||
}
|
||||
|
||||
// Atomic workload derivation: pure 3-way classification of running/queued
|
||||
// counts. Exported so Runtime-level views (which already aggregate counts
|
||||
// per-runtime in their own indices) can plug into the same vocabulary
|
||||
// without re-deriving from raw task arrays.
|
||||
export function deriveWorkload(counts: {
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}): Workload {
|
||||
if (counts.runningCount > 0) return "working";
|
||||
if (counts.queuedCount > 0) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
interface WorkloadDetail {
|
||||
workload: Workload;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}
|
||||
|
||||
// Aggregates a task list into running/queued counts, then classifies via
|
||||
// deriveWorkload. Caller pre-filters to the relevant scope (per-agent or
|
||||
// per-runtime) — we don't filter again here.
|
||||
export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetail {
|
||||
let runningCount = 0;
|
||||
let queuedCount = 0;
|
||||
for (const t of tasks) {
|
||||
if (t.status === "running") {
|
||||
runningCount += 1;
|
||||
} else if (t.status === "queued" || t.status === "dispatched") {
|
||||
queuedCount += 1;
|
||||
}
|
||||
// Terminal statuses (completed / failed / cancelled) intentionally
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
}
|
||||
return {
|
||||
workload: deriveWorkload({ runningCount, queuedCount }),
|
||||
runningCount,
|
||||
queuedCount,
|
||||
};
|
||||
}
|
||||
|
||||
interface DerivePresenceInput {
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
// Tasks for THIS agent only. Callers (buildPresenceMap, hooks) pre-filter
|
||||
// by agent_id — we don't re-check here.
|
||||
tasks: readonly AgentTask[];
|
||||
// Wall-clock millis used by deriveAgentAvailability to bucket runtime
|
||||
// health. Threading it as a parameter keeps the function pure.
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPresenceDetail {
|
||||
const availability = deriveAgentAvailability(input.runtime, input.now);
|
||||
const detail = deriveWorkloadDetail(input.tasks);
|
||||
|
||||
return {
|
||||
availability,
|
||||
workload: detail.workload,
|
||||
runningCount: detail.runningCount,
|
||||
queuedCount: detail.queuedCount,
|
||||
capacity: input.agent.max_concurrent_tasks,
|
||||
};
|
||||
}
|
||||
|
||||
// Workspace-level batch builder. One pass over the workspace's agents
|
||||
// produces a Map<agentId, AgentPresenceDetail> that every list / card /
|
||||
// runtime sub-page can read without re-deriving.
|
||||
export function buildPresenceMap(args: {
|
||||
agents: readonly Agent[];
|
||||
runtimes: readonly AgentRuntime[];
|
||||
// The workspace agent task snapshot: every active task plus each agent's
|
||||
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
|
||||
// — no pre-filtering needed. Terminal rows are silently ignored by
|
||||
// deriveWorkloadDetail (workload is current-state only).
|
||||
snapshot: readonly AgentTask[];
|
||||
now: number;
|
||||
}): Map<string, AgentPresenceDetail> {
|
||||
const out = new Map<string, AgentPresenceDetail>();
|
||||
const runtimesById = new Map<string, AgentRuntime>();
|
||||
for (const r of args.runtimes) runtimesById.set(r.id, r);
|
||||
|
||||
// Group tasks by agent_id once — O(N) — so per-agent derivation is O(1)
|
||||
// task scans rather than O(N×M).
|
||||
const tasksByAgent = new Map<string, AgentTask[]>();
|
||||
for (const t of args.snapshot) {
|
||||
const list = tasksByAgent.get(t.agent_id);
|
||||
if (list) list.push(t);
|
||||
else tasksByAgent.set(t.agent_id, [t]);
|
||||
}
|
||||
|
||||
for (const agent of args.agents) {
|
||||
const runtime = runtimesById.get(agent.runtime_id) ?? null;
|
||||
const tasks = tasksByAgent.get(agent.id) ?? [];
|
||||
out.set(agent.id, deriveAgentPresenceDetail({ agent, runtime, tasks, now: args.now }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
9
packages/core/agents/index.ts
Normal file
9
packages/core/agents/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./types";
|
||||
export * from "./derive-presence";
|
||||
export * from "./queries";
|
||||
export * from "./use-agent-presence";
|
||||
export * from "./use-agent-activity";
|
||||
export * from "./use-workspace-presence-prefetch";
|
||||
export * from "./constants";
|
||||
export * from "./visibility-label";
|
||||
export * from "./use-workspace-agent-availability";
|
||||
84
packages/core/agents/queries.ts
Normal file
84
packages/core/agents/queries.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const agentTaskSnapshotKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-task-snapshot"] as const,
|
||||
list: (wsId: string) => [...agentTaskSnapshotKeys.all(wsId), "list"] as const,
|
||||
};
|
||||
|
||||
export const agentActivityKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-activity"] as const,
|
||||
last30d: (wsId: string) => [...agentActivityKeys.all(wsId), "30d"] as const,
|
||||
};
|
||||
|
||||
export const agentRunCountsKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-run-counts"] as const,
|
||||
last30d: (wsId: string) => [...agentRunCountsKeys.all(wsId), "30d"] as const,
|
||||
};
|
||||
|
||||
// Workspace-scoped agent task snapshot — every active task plus each agent's
|
||||
// most recent terminal task. This is the single shared source of truth that
|
||||
// powers per-agent presence derivation across the app. One fetch per
|
||||
// workspace; all agent dots / hover cards / list rows derive presence from
|
||||
// this cache with zero additional network traffic.
|
||||
//
|
||||
// The 30s staleTime is a safety net only; the primary freshness signal is
|
||||
// WS task events, which invalidate this query immediately. Without WS,
|
||||
// presence still updates within 30s on focus / mount.
|
||||
export function agentTaskSnapshotOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentTaskSnapshotKeys.list(wsId),
|
||||
queryFn: () => api.getAgentTaskSnapshot(),
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace-wide daily task activity for the last 30 days, anchored on
|
||||
// completed_at. One fetch backs both the Agents-list sparkline (which
|
||||
// only uses the trailing 7 buckets via `summarizeActivityWindow`) and
|
||||
// the agent detail "Last 30 days" panel. WS task lifecycle events
|
||||
// invalidate this query in useRealtimeSync; the staleTime is a
|
||||
// tab-focus safety net.
|
||||
export function agentActivity30dOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentActivityKeys.last30d(wsId),
|
||||
queryFn: () => api.getWorkspaceAgentActivity30d(),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace-wide 30-day run counts for the Agents-list RUNS column. Same
|
||||
// single-fetch / WS-invalidate pattern as activity24hOptions.
|
||||
export function agentRunCounts30dOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentRunCountsKeys.last30d(wsId),
|
||||
queryFn: () => api.getWorkspaceAgentRunCounts(),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
export const agentTasksKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-tasks"] as const,
|
||||
detail: (wsId: string, agentId: string) =>
|
||||
[...agentTasksKeys.all(wsId), agentId] as const,
|
||||
};
|
||||
|
||||
// All tasks for a single agent (the agent detail page consumer). Powers both
|
||||
// the inspector's 7-day throughput stats and the Tasks tab list — shared so
|
||||
// they don't fetch twice. WS task events invalidate this via the existing
|
||||
// task-prefix invalidation in useRealtimeSync.
|
||||
export function agentTasksOptions(wsId: string, agentId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentTasksKeys.detail(wsId, agentId),
|
||||
queryFn: () => api.listAgentTasks(agentId),
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
62
packages/core/agents/types.ts
Normal file
62
packages/core/agents/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Derived presence types for agents — the user-facing state we display
|
||||
// across the UI (list dots, hover cards, status lines). Computed in the
|
||||
// front-end from raw server data (agent + runtime + recent tasks); the
|
||||
// back-end never knows about these enums.
|
||||
//
|
||||
// Two orthogonal dimensions, derived independently and answering only
|
||||
// "what's true right now?" — historical / error context lives on the
|
||||
// agent detail page (Recent Work, failure_reason) and Inbox, not in the
|
||||
// list-level summary state:
|
||||
//
|
||||
// 1. AgentAvailability — "Can this agent take work right now?"
|
||||
// Depends only on runtime reachability. The dot colour everywhere in
|
||||
// the app reflects this single dimension; never sticky-red because of
|
||||
// a past task outcome.
|
||||
//
|
||||
// 2. Workload — "What is on this agent's plate right now?"
|
||||
// Depends only on the workspace task snapshot. Three states, each
|
||||
// pointing at a clear user action:
|
||||
// working → tasks running, normal
|
||||
// queued → tasks queued but nothing running (= stuck if availability
|
||||
// is offline/unstable; momentary if online)
|
||||
// idle → nothing to do
|
||||
// No `failed` / `completed` / `cancelled` states — those are historical,
|
||||
// surfaced via Recent Work + Inbox.
|
||||
|
||||
// Runtime-reachability dimension. `unstable` is the transient amber state
|
||||
// during the runtime sweeper's grace window (offline < 5 min); it decays
|
||||
// into `offline` with no new server data, hence the 30s presence tick on
|
||||
// the consuming hooks.
|
||||
export type AgentAvailability =
|
||||
| "online" // 🟢 runtime online and reachable
|
||||
| "unstable" // 🟡 runtime recently_lost (< 5 min) — transient
|
||||
| "offline"; // ⚫ runtime long offline / missing / never registered
|
||||
|
||||
// Current task load on this agent. Three states — never historical,
|
||||
// never an error predictor (Inbox + Recent Work handle that):
|
||||
//
|
||||
// working → runningCount > 0. The runningCount/queuedCount on the detail
|
||||
// object preserve the breakdown for display.
|
||||
// queued → no running task but ≥1 queued/dispatched. Most often means
|
||||
// the runtime is offline and tasks are stuck waiting; a brief
|
||||
// flash on online runtimes between dispatch and run is a
|
||||
// harmless race.
|
||||
// idle → nothing on the plate.
|
||||
//
|
||||
// Pair with availability for the full picture: `online + working` is
|
||||
// normal; `offline + queued` is the "stuck" state we explicitly surface;
|
||||
// `offline + idle` is "agent unavailable, nothing waiting" — both honest.
|
||||
export type Workload =
|
||||
| "working" // ≥1 task currently running
|
||||
| "queued" // nothing running, but ≥1 queued/dispatched
|
||||
| "idle"; // nothing on the plate
|
||||
|
||||
export interface AgentPresenceDetail {
|
||||
availability: AgentAvailability;
|
||||
workload: Workload;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
// Mirrors agent.max_concurrent_tasks — pulled into the detail so the UI
|
||||
// can render `running / capacity` ratios without re-fetching the agent.
|
||||
capacity: number;
|
||||
}
|
||||
192
packages/core/agents/use-agent-activity.test.ts
Normal file
192
packages/core/agents/use-agent-activity.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, AgentActivityBucket } from "../types";
|
||||
import {
|
||||
buildActivityMap,
|
||||
deriveAgentActivity,
|
||||
summarizeActivityWindow,
|
||||
} from "./use-agent-activity";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Fixed anchor — derivation uses local-time start of "today", a real
|
||||
// clock would drift. 12:00 also keeps "today" stable across odd timezones.
|
||||
const NOW = new Date("2026-04-28T12:00:00").getTime();
|
||||
|
||||
function bucket(
|
||||
agentId: string,
|
||||
daysAgo: number,
|
||||
taskCount: number,
|
||||
failedCount = 0,
|
||||
): AgentActivityBucket {
|
||||
const t = new Date(NOW);
|
||||
t.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
agent_id: agentId,
|
||||
bucket_at: new Date(t.getTime() - daysAgo * DAY).toISOString(),
|
||||
task_count: taskCount,
|
||||
failed_count: failedCount,
|
||||
};
|
||||
}
|
||||
|
||||
const fullHistoryAgent: Agent = {
|
||||
id: "a1",
|
||||
workspace_id: "w",
|
||||
runtime_id: "r1",
|
||||
name: "Old Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
// Older than the window so daysSinceCreated saturates at DAYS.
|
||||
created_at: new Date(NOW - 100 * DAY).toISOString(),
|
||||
updated_at: new Date(NOW).toISOString(),
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
|
||||
describe("deriveAgentActivity", () => {
|
||||
it("places buckets in oldest→newest slots across 30 days", () => {
|
||||
const buckets = [
|
||||
bucket("a1", 29, 1), // slot 0
|
||||
bucket("a1", 0, 5), // slot 29
|
||||
];
|
||||
const result = deriveAgentActivity(
|
||||
buckets,
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
expect(result.buckets).toHaveLength(30);
|
||||
expect(result.buckets[0]).toEqual({ total: 1, failed: 0 });
|
||||
expect(result.buckets[29]).toEqual({ total: 5, failed: 0 });
|
||||
expect(result.daysSinceCreated).toBe(30);
|
||||
});
|
||||
|
||||
it("clamps daysSinceCreated for young agents", () => {
|
||||
const created = new Date(NOW - 3 * DAY - 60 * 1000).toISOString();
|
||||
const result = deriveAgentActivity([bucket("fresh", 1, 4)], created, NOW);
|
||||
expect(result.daysSinceCreated).toBe(3);
|
||||
});
|
||||
|
||||
it("treats sub-day-old agents as daysSinceCreated = 0", () => {
|
||||
const created = new Date(NOW - 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = deriveAgentActivity([bucket("fresh", 0, 1)], created, NOW);
|
||||
expect(result.daysSinceCreated).toBe(0);
|
||||
// Today's bucket still records — pre-life days simply look like zero
|
||||
// days, which is on purpose.
|
||||
expect(result.buckets[29]).toEqual({ total: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("ignores buckets older than the 30-day window", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[bucket("a1", 60, 99)],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
expect(
|
||||
result.buckets.reduce((s, b) => s + b.total, 0),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("zero-fills when the agent has no buckets", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
expect(result.buckets).toHaveLength(30);
|
||||
expect(result.buckets.every((b) => b.total === 0 && b.failed === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeActivityWindow", () => {
|
||||
it("rolls up totals across the trailing N buckets", () => {
|
||||
// 5 runs total over the 30-day series.
|
||||
const result = deriveAgentActivity(
|
||||
[
|
||||
bucket("a1", 25, 1), // outside 7d, inside 30d
|
||||
bucket("a1", 6, 1), // inside 7d
|
||||
bucket("a1", 0, 3, 1), // inside 7d
|
||||
],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
const last7 = summarizeActivityWindow(result, 7);
|
||||
expect(last7.totalRuns).toBe(4);
|
||||
expect(last7.totalFailed).toBe(1);
|
||||
expect(last7.buckets).toHaveLength(7);
|
||||
|
||||
const last30 = summarizeActivityWindow(result, 30);
|
||||
expect(last30.totalRuns).toBe(5);
|
||||
expect(last30.totalFailed).toBe(1);
|
||||
expect(last30.buckets).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("returns an empty summary for missing activity", () => {
|
||||
const summary = summarizeActivityWindow(undefined, 7);
|
||||
expect(summary.buckets).toEqual([]);
|
||||
expect(summary.totalRuns).toBe(0);
|
||||
expect(summary.totalFailed).toBe(0);
|
||||
expect(summary.windowDays).toBe(7);
|
||||
});
|
||||
|
||||
it("clamps an oversized window to the available bucket count", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[bucket("a1", 0, 2)],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
const summary = summarizeActivityWindow(result, 1000);
|
||||
expect(summary.buckets).toHaveLength(30);
|
||||
expect(summary.totalRuns).toBe(2);
|
||||
});
|
||||
|
||||
it("returns no buckets when window is 0", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[bucket("a1", 0, 5)],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
const summary = summarizeActivityWindow(result, 0);
|
||||
expect(summary.buckets).toEqual([]);
|
||||
expect(summary.totalRuns).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivityMap", () => {
|
||||
it("groups buckets by agent and yields a derivation per agent", () => {
|
||||
const agents: Agent[] = [
|
||||
fullHistoryAgent,
|
||||
{ ...fullHistoryAgent, id: "a2" },
|
||||
];
|
||||
const buckets: AgentActivityBucket[] = [
|
||||
bucket("a1", 0, 3),
|
||||
bucket("a2", 1, 2, 1),
|
||||
bucket("a1", 2, 4),
|
||||
];
|
||||
const map = buildActivityMap(agents, buckets, NOW);
|
||||
expect(map.size).toBe(2);
|
||||
expect(summarizeActivityWindow(map.get("a1"), 30).totalRuns).toBe(7);
|
||||
expect(summarizeActivityWindow(map.get("a2"), 30).totalRuns).toBe(2);
|
||||
expect(summarizeActivityWindow(map.get("a2"), 30).totalFailed).toBe(1);
|
||||
});
|
||||
|
||||
it("emits a zero-filled entry for an agent with no buckets", () => {
|
||||
const agents: Agent[] = [fullHistoryAgent];
|
||||
const map = buildActivityMap(agents, [], NOW);
|
||||
const a = map.get("a1");
|
||||
expect(a?.buckets).toHaveLength(30);
|
||||
expect(summarizeActivityWindow(a, 30).totalRuns).toBe(0);
|
||||
});
|
||||
});
|
||||
204
packages/core/agents/use-agent-activity.ts
Normal file
204
packages/core/agents/use-agent-activity.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, AgentActivityBucket } from "../types";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { agentActivity30dOptions } from "./queries";
|
||||
|
||||
const DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** One day's tally for the sparkline. */
|
||||
export interface ActivityBucket {
|
||||
total: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
/**
|
||||
* 30 daily buckets, oldest → newest. Days with no activity are
|
||||
* zero-filled. Each surface picks how much of the tail to render: the
|
||||
* Agents list uses 7, the agent detail uses all 30. Reading is the
|
||||
* caller's job (see `summarizeActivityWindow` for the standard
|
||||
* tail-slice + roll-up).
|
||||
*/
|
||||
buckets: ActivityBucket[];
|
||||
/**
|
||||
* Days the agent has existed, capped at DAYS. Pure cosmetic — used by
|
||||
* tooltip copy ("Created 3 days ago"). The sparkline doesn't change
|
||||
* shape for young agents on purpose; pre-life days look the same as
|
||||
* zero days.
|
||||
*/
|
||||
daysSinceCreated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-sized roll-up of an agent's activity series. Both the Agents
|
||||
* list (windowDays=7) and the detail "Last 30 days" panel (windowDays=30)
|
||||
* read through this so the totals can never drift from the bars they
|
||||
* label.
|
||||
*/
|
||||
export interface ActivityWindowSummary {
|
||||
/** Trailing-N buckets from the activity series (newest end). */
|
||||
buckets: ActivityBucket[];
|
||||
/** Sum of `bucket.total` across the window. */
|
||||
totalRuns: number;
|
||||
/** Sum of `bucket.failed` across the window. */
|
||||
totalFailed: number;
|
||||
/** Echo of the input window — the renderer uses it for copy. */
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
const EMPTY: AgentActivity = {
|
||||
buckets: Array.from({ length: DAYS }, () => ({ total: 0, failed: 0 })),
|
||||
daysSinceCreated: DAYS,
|
||||
};
|
||||
|
||||
const EMPTY_SUMMARY: ActivityWindowSummary = {
|
||||
buckets: [],
|
||||
totalRuns: 0,
|
||||
totalFailed: 0,
|
||||
windowDays: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Workspace-wide activity map keyed by `agent.id`. Single-pass batch:
|
||||
* one fetch + one derivation pass backs every row's sparkline on the
|
||||
* list AND the detail panel — adding rows costs O(1) HTTP and O(N)
|
||||
* compute (not O(N) HTTP).
|
||||
*/
|
||||
export function useWorkspaceActivityMap(wsId: string | undefined): {
|
||||
byAgent: Map<string, AgentActivity>;
|
||||
loading: boolean;
|
||||
} {
|
||||
const { data: agents, isPending: agentsPending } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: buckets, isPending: bucketsPending } = useQuery({
|
||||
...agentActivity30dOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
|
||||
const byAgent = useMemo(() => {
|
||||
if (!agents || !buckets) return new Map<string, AgentActivity>();
|
||||
return buildActivityMap(agents, buckets, Date.now());
|
||||
}, [agents, buckets]);
|
||||
|
||||
return { byAgent, loading: agentsPending || bucketsPending };
|
||||
}
|
||||
|
||||
export function buildActivityMap(
|
||||
agents: readonly Agent[],
|
||||
buckets: readonly AgentActivityBucket[],
|
||||
now: number,
|
||||
): Map<string, AgentActivity> {
|
||||
// Group buckets by agent once so per-agent derivation is O(buckets) not
|
||||
// O(agents × buckets).
|
||||
const bucketsByAgent = new Map<string, AgentActivityBucket[]>();
|
||||
for (const b of buckets) {
|
||||
const list = bucketsByAgent.get(b.agent_id);
|
||||
if (list) list.push(b);
|
||||
else bucketsByAgent.set(b.agent_id, [b]);
|
||||
}
|
||||
|
||||
const out = new Map<string, AgentActivity>();
|
||||
for (const agent of agents) {
|
||||
out.set(
|
||||
agent.id,
|
||||
deriveAgentActivity(
|
||||
bucketsByAgent.get(agent.id) ?? [],
|
||||
agent.created_at,
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation: filter the workspace-wide buckets to one agent and
|
||||
* normalise to a fixed 30-element series ending at `now`. Exported for
|
||||
* unit-testing and direct reuse on surfaces that already have the
|
||||
* workspace-wide buckets in hand.
|
||||
*/
|
||||
export function deriveAgentActivity(
|
||||
buckets: readonly AgentActivityBucket[],
|
||||
agentCreatedAt: string,
|
||||
now: number,
|
||||
): AgentActivity {
|
||||
const series: ActivityBucket[] = Array.from({ length: DAYS }, () => ({
|
||||
total: 0,
|
||||
failed: 0,
|
||||
}));
|
||||
|
||||
// Newest slot is the start of "today" in local time; we walk back DAYS
|
||||
// slots so index 0 = oldest, index DAYS-1 = today.
|
||||
const today = startOfDay(now);
|
||||
|
||||
for (const b of buckets) {
|
||||
const ts = new Date(b.bucket_at).getTime();
|
||||
if (Number.isNaN(ts)) continue;
|
||||
const daysAgo = Math.floor((today - startOfDay(ts)) / DAY_MS);
|
||||
if (daysAgo < 0 || daysAgo >= DAYS) continue;
|
||||
const slot = DAYS - 1 - daysAgo;
|
||||
series[slot]!.total += b.task_count;
|
||||
series[slot]!.failed += b.failed_count;
|
||||
}
|
||||
|
||||
const createdAt = new Date(agentCreatedAt).getTime();
|
||||
const ageMs = Number.isFinite(createdAt) ? now - createdAt : Infinity;
|
||||
const daysSinceCreated = Math.min(
|
||||
DAYS,
|
||||
Math.max(0, Math.floor(ageMs / DAY_MS)),
|
||||
);
|
||||
|
||||
return {
|
||||
buckets: series,
|
||||
daysSinceCreated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the trailing N buckets and roll up totals over them. This is the
|
||||
* single entry point both surfaces (list + detail) read through, so the
|
||||
* numbers can never disagree with the bars they label.
|
||||
*
|
||||
* `windowDays` is clamped to the available bucket count, so passing a
|
||||
* value larger than `activity.buckets.length` returns the full series
|
||||
* rather than an out-of-range slice.
|
||||
*/
|
||||
export function summarizeActivityWindow(
|
||||
activity: AgentActivity | undefined,
|
||||
windowDays: number,
|
||||
): ActivityWindowSummary {
|
||||
if (!activity) return { ...EMPTY_SUMMARY, windowDays };
|
||||
const safeWindow = Math.min(
|
||||
Math.max(0, windowDays),
|
||||
activity.buckets.length,
|
||||
);
|
||||
// `slice(-0)` returns the full array (JS quirk: -0 === 0), so guard
|
||||
// explicitly when no window is requested.
|
||||
const slice =
|
||||
safeWindow === 0 ? [] : activity.buckets.slice(-safeWindow);
|
||||
let totalRuns = 0;
|
||||
let totalFailed = 0;
|
||||
for (const b of slice) {
|
||||
totalRuns += b.total;
|
||||
totalFailed += b.failed;
|
||||
}
|
||||
return { buckets: slice, totalRuns, totalFailed, windowDays };
|
||||
}
|
||||
|
||||
function startOfDay(ts: number): number {
|
||||
// Local-time day boundary. The back-end truncates to UTC midnight, but
|
||||
// the user's mental model is "today/yesterday in the timezone they're
|
||||
// looking at"; using local matches that and keeps "today" stable across
|
||||
// a working session even when buckets cross UTC midnight.
|
||||
const d = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
export const __EMPTY_ACTIVITY = EMPTY;
|
||||
162
packages/core/agents/use-agent-presence.ts
Normal file
162
packages/core/agents/use-agent-presence.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
import { agentTaskSnapshotOptions } from "./queries";
|
||||
import {
|
||||
buildPresenceMap,
|
||||
deriveAgentPresenceDetail,
|
||||
} from "./derive-presence";
|
||||
import type { AgentPresenceDetail } from "./types";
|
||||
|
||||
// 30s tick, mirroring useRuntimeHealth. Presence depends on wall-clock time
|
||||
// for one reason: `unstable` (= RuntimeHealth.recently_lost) decays into
|
||||
// `offline` at the 5-minute mark with no new server data. Without a tick the
|
||||
// transition would only render on the next unrelated query update.
|
||||
// The earlier 2-minute "clear failed badge" tick was removed when failed
|
||||
// became sticky; this one re-introduces ticking with a different motivation.
|
||||
const PRESENCE_TICK_MS = 30_000;
|
||||
|
||||
function usePresenceTick(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), PRESENCE_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return tick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-wide presence map keyed by `agent.id`. **The single entry point
|
||||
* for any list / card / runtime sub-view that needs presence for more than
|
||||
* one agent.**
|
||||
*
|
||||
* Why this exists (vs calling `useAgentPresence` per row): the per-agent
|
||||
* hook subscribes to 3 queries. With 30+ rows that's a forest of redundant
|
||||
* memos. This batch hook pays the cost once for the whole page; rows just
|
||||
* `Map.get(id)` — O(1) reads, no extra subscriptions.
|
||||
*
|
||||
* Returned value:
|
||||
* - `byAgent`: ready-to-read Map. Empty if data is still loading.
|
||||
* - `loading`: true until all three input queries have resolved at least
|
||||
* once. Callers can render skeletons during loading.
|
||||
*
|
||||
* Single-agent consumers should keep using `useAgentPresenceDetail`; this
|
||||
* hook is for surfaces that already have a list of agents in hand.
|
||||
*/
|
||||
export function useWorkspacePresenceMap(wsId: string | undefined): {
|
||||
byAgent: Map<string, AgentPresenceDetail>;
|
||||
loading: boolean;
|
||||
} {
|
||||
const { data: agents, isPending: agentsPending, isError: agentsErr } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: runtimes, isPending: runtimesPending, isError: runtimesErr } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: snapshot, isPending: snapshotPending, isError: snapshotErr } = useQuery({
|
||||
...agentTaskSnapshotOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const tick = usePresenceTick();
|
||||
|
||||
const byAgent = useMemo(() => {
|
||||
// Treat errored queries as empty so the map still builds — a 404 on
|
||||
// the snapshot endpoint shouldn't leave every row's presence blank.
|
||||
const safeAgents = agents ?? (agentsErr ? [] : null);
|
||||
const safeRuntimes = runtimes ?? (runtimesErr ? [] : null);
|
||||
const safeSnapshot = snapshot ?? (snapshotErr ? [] : null);
|
||||
if (!safeAgents || !safeRuntimes || !safeSnapshot) {
|
||||
return new Map<string, AgentPresenceDetail>();
|
||||
}
|
||||
return buildPresenceMap({
|
||||
agents: safeAgents,
|
||||
runtimes: safeRuntimes,
|
||||
snapshot: safeSnapshot,
|
||||
now: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agents, runtimes, snapshot, agentsErr, runtimesErr, snapshotErr, tick]);
|
||||
|
||||
return {
|
||||
byAgent,
|
||||
// "loading" only while the queries are genuinely pending — once they
|
||||
// settle (success OR error), we render with whatever we have. This
|
||||
// matches the detail-version behaviour: don't spin forever on errors.
|
||||
loading:
|
||||
(agentsPending && !agentsErr) ||
|
||||
(runtimesPending && !runtimesErr) ||
|
||||
(snapshotPending && !snapshotErr),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-agent presence detail: availability + last task state + counts +
|
||||
* (when failed) failure reason and timestamp. Returns "loading" only while
|
||||
* the underlying queries haven't resolved yet — a missing runtime is a
|
||||
* real state (offline) and resolves into a non-loading detail.
|
||||
*
|
||||
* For surfaces that already have a list of agents in hand (Agents page,
|
||||
* Runtime detail), prefer `useWorkspacePresenceMap` to avoid forest of
|
||||
* redundant subscriptions.
|
||||
*/
|
||||
// Synthesised fallback shown when we can't resolve a real agent (deleted,
|
||||
// archived, or referenced by stale data) but still need to render something
|
||||
// next to the avatar. Yields a gray dot + idle last-task — better than a
|
||||
// skeleton spinning forever.
|
||||
const MISSING_AGENT_DETAIL: AgentPresenceDetail = {
|
||||
availability: "offline",
|
||||
workload: "idle",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
capacity: 0,
|
||||
};
|
||||
|
||||
export function useAgentPresenceDetail(
|
||||
wsId: string | undefined,
|
||||
agentId: string | undefined,
|
||||
): AgentPresenceDetail | "loading" {
|
||||
const { data: agents, isError: agentsErr } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: runtimes, isError: runtimesErr } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: snapshot, isError: snapshotErr } = useQuery({
|
||||
...agentTaskSnapshotOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const tick = usePresenceTick();
|
||||
|
||||
return useMemo<AgentPresenceDetail | "loading">(() => {
|
||||
if (!wsId || !agentId) return "loading";
|
||||
|
||||
// Treat query errors as "no data" rather than "still loading". A 404 /
|
||||
// 5xx on the snapshot endpoint (e.g. backend hasn't deployed the new
|
||||
// route yet) used to leave the UI spinning forever; now we degrade to
|
||||
// an empty list and the dot still renders based on runtime health.
|
||||
const safeAgents = agents ?? (agentsErr ? [] : null);
|
||||
const safeRuntimes = runtimes ?? (runtimesErr ? [] : null);
|
||||
const safeSnapshot = snapshot ?? (snapshotErr ? [] : null);
|
||||
if (!safeAgents || !safeRuntimes || !safeSnapshot) return "loading";
|
||||
|
||||
const agent = safeAgents.find((a) => a.id === agentId);
|
||||
// Agent referenced but not in the workspace's active list (most often:
|
||||
// archived assignee on an old issue). Render a gray-offline fallback
|
||||
// instead of looping in "loading".
|
||||
if (!agent) return MISSING_AGENT_DETAIL;
|
||||
// Missing runtime is a legitimate state (offline) — pass null and let
|
||||
// derive handle it.
|
||||
const runtime = safeRuntimes.find((r) => r.id === agent.runtime_id) ?? null;
|
||||
|
||||
const tasks = safeSnapshot.filter((t) => t.agent_id === agentId);
|
||||
return deriveAgentPresenceDetail({ agent, runtime, tasks, now: Date.now() });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, agentId, agents, runtimes, snapshot, agentsErr, runtimesErr, snapshotErr, tick]);
|
||||
}
|
||||
60
packages/core/agents/use-workspace-agent-availability.ts
Normal file
60
packages/core/agents/use-workspace-agent-availability.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { agentListOptions, memberListOptions } from "../workspace/queries";
|
||||
import { canAssignAgentToIssue } from "../permissions";
|
||||
|
||||
/**
|
||||
* Three-state availability for "does the current user have any agent
|
||||
* they can chat with in this workspace?".
|
||||
*
|
||||
* Why three states (not a boolean): the answer to "is there an agent?"
|
||||
* lives on the server. Until the agent-list query resolves, the answer
|
||||
* is genuinely *unknown*. Callers must distinguish "loading" from
|
||||
* "confirmed empty" — collapsing them to a boolean causes UIs to flash
|
||||
* disabled/empty states for the first few hundred ms after mount, even
|
||||
* when the workspace actually has agents.
|
||||
*
|
||||
* "loading" — agent or member list still in flight (be neutral in UI)
|
||||
* "none" — both queries resolved, user has zero assignable agents
|
||||
* "available" — at least one agent passes archive + visibility filters
|
||||
*/
|
||||
export type WorkspaceAgentAvailability = "loading" | "none" | "available";
|
||||
|
||||
/**
|
||||
* Mirrors the per-agent visibility/archived filter used by AssigneePicker
|
||||
* and the chat agent dropdown, so the three pickers can never disagree on
|
||||
* "is this agent reachable?".
|
||||
*
|
||||
* Members are queried because `canAssignAgentToIssue` reads the caller's
|
||||
* role to decide visibility for `private` agents — without member data,
|
||||
* a freshly-loaded agent list could still produce wrong answers.
|
||||
*/
|
||||
export function useWorkspaceAgentAvailability(): WorkspaceAgentAvailability {
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { data: agents, isFetched: agentsFetched } = useQuery(
|
||||
agentListOptions(wsId),
|
||||
);
|
||||
const { data: members, isFetched: membersFetched } = useQuery(
|
||||
memberListOptions(wsId),
|
||||
);
|
||||
|
||||
if (!agentsFetched || !membersFetched) return "loading";
|
||||
|
||||
const rawRole = members?.find((m) => m.user_id === userId)?.role;
|
||||
const role =
|
||||
rawRole === "owner" || rawRole === "admin" || rawRole === "member"
|
||||
? rawRole
|
||||
: null;
|
||||
|
||||
const hasVisibleAgent = (agents ?? []).some(
|
||||
(a) =>
|
||||
!a.archived_at &&
|
||||
canAssignAgentToIssue(a, { userId: userId ?? null, role }).allowed,
|
||||
);
|
||||
|
||||
return hasVisibleAgent ? "available" : "none";
|
||||
}
|
||||
26
packages/core/agents/use-workspace-presence-prefetch.ts
Normal file
26
packages/core/agents/use-workspace-presence-prefetch.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
import { agentTaskSnapshotOptions } from "./queries";
|
||||
|
||||
// Subscribe to the three queries that power agent presence so they're warm
|
||||
// by the time any hover card / inline indicator first renders. Without this
|
||||
// warm-up, surfaces that don't otherwise touch the snapshot (inbox, issues,
|
||||
// chat) flash a skeleton on first hover while the fetch is in flight.
|
||||
//
|
||||
// useRealtimeSync (WS task / agent / daemon invalidations) and the 30s
|
||||
// presence tick keep these caches fresh after the initial fetch — this hook
|
||||
// only collapses the cold-start window.
|
||||
//
|
||||
// All three are workspace-scoped; the queryKeys include wsId so workspace
|
||||
// switch automatically refetches the new workspace's data with no extra
|
||||
// wiring here. The workspace-scoped layouts on both apps gate rendering on
|
||||
// "workspace resolved", so callers can safely pass useWorkspaceId() — by the
|
||||
// time this hook mounts, wsId is guaranteed non-empty.
|
||||
export function useWorkspacePresencePrefetch(wsId: string | undefined): void {
|
||||
useQuery({ ...agentListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...runtimeListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...agentTaskSnapshotOptions(wsId ?? ""), enabled: !!wsId });
|
||||
}
|
||||
31
packages/core/agents/visibility-label.ts
Normal file
31
packages/core/agents/visibility-label.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { AgentVisibility } from "../types";
|
||||
|
||||
/**
|
||||
* Display labels for agent visibility. The DB stores `private` as the value
|
||||
* but the UI surface name is "Personal" — better matches what the field
|
||||
* actually means now that workspace admins can also assign private agents.
|
||||
*/
|
||||
export const VISIBILITY_LABEL: Record<AgentVisibility, string> = {
|
||||
workspace: "Workspace",
|
||||
private: "Personal",
|
||||
};
|
||||
|
||||
/**
|
||||
* Honest descriptions for assignability. The previous "Only you can assign"
|
||||
* text was a lie — workspace owners and admins can assign private agents too
|
||||
* (server `issue.go:1471-1490`).
|
||||
*/
|
||||
export const VISIBILITY_DESCRIPTION: Record<AgentVisibility, string> = {
|
||||
workspace: "All members can assign",
|
||||
private: "Only you and workspace admins can assign",
|
||||
};
|
||||
|
||||
/** Tooltip suitable for read-only badges on hover/list rows. */
|
||||
export const VISIBILITY_TOOLTIP: Record<AgentVisibility, string> = {
|
||||
workspace: "Workspace — all members can assign",
|
||||
private: "Personal — only you and workspace admins can assign",
|
||||
};
|
||||
|
||||
export function visibilityLabel(v: AgentVisibility): string {
|
||||
return VISIBILITY_LABEL[v];
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
AgentRuntime,
|
||||
InboxItem,
|
||||
IssueSubscriber,
|
||||
@@ -33,6 +35,8 @@ import type {
|
||||
RuntimeUsage,
|
||||
IssueUsageSummary,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimeUsageByAgent,
|
||||
RuntimeUsageByHour,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
@@ -51,6 +55,9 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
UpdateLabelRequest,
|
||||
@@ -71,6 +78,8 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -151,12 +160,17 @@ export interface ImportStarterContentResponse {
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
// Raw decoded JSON body (when the server returned one). Carries structured
|
||||
// error fields like `code` so callers can branch on machine-readable
|
||||
// identifiers instead of pattern-matching the human-readable message.
|
||||
readonly body?: unknown;
|
||||
|
||||
constructor(message: string, status: number, statusText: string) {
|
||||
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +235,19 @@ export class ApiClient {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Reads the response body once for both human-readable error message and
|
||||
// structured fields. The Response stream can only be consumed once, so
|
||||
// both pieces have to come from a single read.
|
||||
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
||||
return { message, body: data };
|
||||
} catch {
|
||||
return { message: fallback, body: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
@@ -243,10 +270,10 @@ export class ApiClient {
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new ApiError(message, res.status, res.statusText);
|
||||
throw new ApiError(message, res.status, res.statusText, body);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
@@ -399,6 +426,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async createFeedback(data: {
|
||||
message: string;
|
||||
url?: string;
|
||||
@@ -566,6 +600,14 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
|
||||
// Permission: agent owner or workspace admin/owner. Server returns the
|
||||
// count of cancelled rows; broadcasts task:cancelled for each so other
|
||||
// surfaces can clear their live cards.
|
||||
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
|
||||
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
@@ -587,6 +629,24 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByAgent(
|
||||
runtimeId: string,
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByHour(
|
||||
runtimeId: string,
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByHour[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
@@ -651,6 +711,28 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
// Workspace-scoped agent task snapshot: every active task
|
||||
// (queued/dispatched/running) plus each agent's most recent terminal task.
|
||||
// Powers the front-end's "active wins, else latest terminal" presence
|
||||
// derivation; one fetch backs every per-agent presence read in the app.
|
||||
// Workspace is resolved server-side from the X-Workspace-Slug header.
|
||||
async getAgentTaskSnapshot(): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agent-task-snapshot`);
|
||||
}
|
||||
|
||||
// Per-agent daily activity for the last 30 days, anchored on
|
||||
// completed_at. One workspace-wide fetch backs both the Agents-list
|
||||
// sparkline (uses trailing 7 buckets) and the agent detail "Last 30
|
||||
// days" panel (uses all 30).
|
||||
async getWorkspaceAgentActivity30d(): Promise<AgentActivityBucket[]> {
|
||||
return this.fetch(`/api/agent-activity-30d`);
|
||||
}
|
||||
|
||||
// Per-agent 30-day total run count for the Agents-list RUNS column.
|
||||
async getWorkspaceAgentRunCounts(): Promise<AgentRunCount[]> {
|
||||
return this.fetch(`/api/agent-run-counts`);
|
||||
}
|
||||
|
||||
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
|
||||
return this.fetch(`/api/issues/${issueId}/active-task`);
|
||||
}
|
||||
@@ -706,6 +788,18 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||||
}
|
||||
|
||||
// Notification preferences
|
||||
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
||||
return this.fetch("/api/notification-preferences");
|
||||
}
|
||||
|
||||
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
||||
return this.fetch("/api/notification-preferences", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ preferences }),
|
||||
});
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{
|
||||
cdn_domain: string;
|
||||
@@ -983,6 +1077,32 @@ export class ApiClient {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Project resources
|
||||
async listProjectResources(
|
||||
projectId: string,
|
||||
): Promise<ListProjectResourcesResponse> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources`);
|
||||
}
|
||||
|
||||
async createProjectResource(
|
||||
projectId: string,
|
||||
data: CreateProjectResourceRequest,
|
||||
): Promise<ProjectResource> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
): Promise<void> {
|
||||
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Labels
|
||||
async listLabels(): Promise<ListLabelsResponse> {
|
||||
return this.fetch(`/api/labels`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, DRAFT_NEW_SESSION } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -11,8 +11,19 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
/** Focus mode is a personal preference — global across workspaces/sessions. */
|
||||
const FOCUS_MODE_KEY = "multica:chat:focusMode";
|
||||
/**
|
||||
* Open/closed preference, persisted globally (not per-workspace) — most users
|
||||
* have one habitual chat-panel preference across workspaces. Missing key =
|
||||
* new user (or cleared storage); default to OPEN so the chat is discoverable.
|
||||
* Once the user toggles even once, their explicit choice is respected on
|
||||
* every subsequent reload.
|
||||
*/
|
||||
const OPEN_KEY = "multica:chat:isOpen";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
@@ -38,6 +49,11 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 380;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
* Kept as a public type because existing consumers (chat-message-list,
|
||||
* views/chat types) import it. Items themselves no longer live in the
|
||||
@@ -68,8 +84,10 @@ export interface ContextAnchor {
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/**
|
||||
@@ -78,20 +96,22 @@ export interface ChatState {
|
||||
* the preference survives workspace switches and reloads.
|
||||
*/
|
||||
focusMode: boolean;
|
||||
/**
|
||||
* Last location where a context anchor could be derived (issue/project/inbox).
|
||||
* Updated globally by useAnchorTracker; used as a fallback for the Chat page
|
||||
* which is its own route and therefore has no anchor of its own.
|
||||
* Not persisted — resets per session; focus mode itself persists.
|
||||
*/
|
||||
lastAnchorLocation: { pathname: string; search: string } | null;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
isExpanded: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
setFocusMode: (on: boolean) => void;
|
||||
setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ChatStoreOptions {
|
||||
@@ -106,13 +126,33 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return slug ? `${base}:${slug}` : base;
|
||||
};
|
||||
|
||||
// Resolve initial isOpen from storage. The three-state read (null /
|
||||
// "true" / "false") is what enables the "new user → open" default while
|
||||
// still honouring an explicit "I closed it" choice on every reload.
|
||||
const storedOpen = storage.getItem(OPEN_KEY);
|
||||
const initialIsOpen = storedOpen === null ? true : storedOpen === "true";
|
||||
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
isOpen: initialIsOpen,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
|
||||
lastAnchorLocation: null,
|
||||
setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }),
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
storage.setItem(OPEN_KEY, String(open));
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
storage.setItem(OPEN_KEY, String(next));
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
||||
if (id) {
|
||||
@@ -127,6 +167,10 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
@@ -152,6 +196,23 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
storage.setItem(CHAT_HEIGHT_KEY, String(h));
|
||||
// Dragging = user chose a manual size → exit expanded mode
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
set({ chatWidth: w, chatHeight: h, isExpanded: false });
|
||||
},
|
||||
setExpanded: (expanded) => {
|
||||
logger.info("setExpanded", { to: expanded });
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
}
|
||||
set({ isExpanded: expanded });
|
||||
},
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
@@ -165,15 +226,10 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
});
|
||||
// lastAnchorLocation is not persisted — reset it here so a pathname
|
||||
// captured in the previous workspace can't be reused against the new
|
||||
// workspace's wsId (would trigger a cross-workspace issue/project fetch
|
||||
// and silently leak context into chat messages).
|
||||
store.setState({
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
lastAnchorLocation: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
41
packages/core/feedback/draft-store.ts
Normal file
41
packages/core/feedback/draft-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
interface FeedbackDraft {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: FeedbackDraft = {
|
||||
message: "",
|
||||
};
|
||||
|
||||
interface FeedbackDraftStore {
|
||||
draft: FeedbackDraft;
|
||||
setDraft: (patch: Partial<FeedbackDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () =>
|
||||
set({ draft: { ...EMPTY_DRAFT } }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!draft.message;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_feedback_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./mutations";
|
||||
export { useFeedbackDraftStore } from "./draft-store";
|
||||
|
||||
@@ -22,6 +22,12 @@ export const issueKeys = {
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
/** Per-issue task list (issue-detail Execution log section). */
|
||||
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
|
||||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||||
* the global WS task: prefix path so any task lifecycle event refreshes
|
||||
* every per-issue list, regardless of which issue is currently mounted. */
|
||||
tasksAll: () => ["issues", "tasks"] as const,
|
||||
};
|
||||
|
||||
export type MyIssuesFilter = Pick<
|
||||
|
||||
36
packages/core/issues/stores/create-mode-store.ts
Normal file
36
packages/core/issues/stores/create-mode-store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Last create-issue mode the user landed on. Drives the global `c` shortcut
|
||||
* and the in-modal mode switch — pressing `c` opens whichever modal the user
|
||||
* used last, and the switch button in either modal updates this so the
|
||||
* preference sticks.
|
||||
*
|
||||
* Workspace-agnostic on purpose: the user's mental preference for "how do I
|
||||
* file an issue" doesn't change per workspace, so this lives in plain
|
||||
* localStorage rather than the workspace-aware StateStorage that scopes
|
||||
* per-workspace stores like quick-create-store / draft-store.
|
||||
*/
|
||||
export type CreateMode = "agent" | "manual";
|
||||
|
||||
interface CreateModeState {
|
||||
lastMode: CreateMode;
|
||||
setLastMode: (mode: CreateMode) => void;
|
||||
}
|
||||
|
||||
export const useCreateModeStore = create<CreateModeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastMode: "agent",
|
||||
setLastMode: (mode) => set({ lastMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "multica_create_mode",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
|
||||
37
packages/core/issues/stores/quick-create-store.ts
Normal file
37
packages/core/issues/stores/quick-create-store.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Per-workspace memory of the last agent the user picked in the Quick Create
|
||||
// modal. Defaulted to that agent on next open so frequent users skip the
|
||||
// picker entirely. Persisted with the workspace-aware StateStorage so
|
||||
// switching workspaces shows the right default automatically. Per-user
|
||||
// scoping comes for free from localStorage being browser-profile-local —
|
||||
// matches how draft-store / issues-scope-store / comment-collapse-store
|
||||
// already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useQuickCreateStore.persist.rehydrate());
|
||||
@@ -5,6 +5,7 @@ import { create } from "zustand";
|
||||
type ModalType =
|
||||
| "create-workspace"
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
|
||||
2
packages/core/notification-preferences/index.ts
Normal file
2
packages/core/notification-preferences/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
34
packages/core/notification-preferences/mutations.ts
Normal file
34
packages/core/notification-preferences/mutations.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { notificationPreferenceKeys } from "./queries";
|
||||
import type { NotificationPreferences, NotificationPreferenceResponse } from "../types";
|
||||
|
||||
export function useUpdateNotificationPreferences() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (preferences: NotificationPreferences) =>
|
||||
api.updateNotificationPreferences(preferences),
|
||||
onMutate: async (preferences) => {
|
||||
await qc.cancelQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
|
||||
const prev = qc.getQueryData<NotificationPreferenceResponse>(
|
||||
notificationPreferenceKeys.all(wsId),
|
||||
);
|
||||
qc.setQueryData<NotificationPreferenceResponse>(
|
||||
notificationPreferenceKeys.all(wsId),
|
||||
(old) => old ? { ...old, preferences } : { workspace_id: wsId, preferences },
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(notificationPreferenceKeys.all(wsId), ctx.prev);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
13
packages/core/notification-preferences/queries.ts
Normal file
13
packages/core/notification-preferences/queries.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const notificationPreferenceKeys = {
|
||||
all: (wsId: string) => ["notification-preferences", wsId] as const,
|
||||
};
|
||||
|
||||
export function notificationPreferenceOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: notificationPreferenceKeys.all(wsId),
|
||||
queryFn: () => api.getNotificationPreferences(),
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,9 @@
|
||||
"./inbox/queries": "./inbox/queries.ts",
|
||||
"./inbox/mutations": "./inbox/mutations.ts",
|
||||
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
|
||||
"./notification-preferences": "./notification-preferences/index.ts",
|
||||
"./notification-preferences/queries": "./notification-preferences/queries.ts",
|
||||
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./chat/queries": "./chat/queries.ts",
|
||||
"./chat/mutations": "./chat/mutations.ts",
|
||||
@@ -42,6 +45,12 @@
|
||||
"./runtimes/queries": "./runtimes/queries.ts",
|
||||
"./runtimes/mutations": "./runtimes/mutations.ts",
|
||||
"./runtimes/hooks": "./runtimes/hooks.ts",
|
||||
"./agents": "./agents/index.ts",
|
||||
"./agents/queries": "./agents/queries.ts",
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./agents/visibility-label": "./agents/visibility-label.ts",
|
||||
"./permissions": "./permissions/index.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("paths.workspace() shape", () => {
|
||||
"autopilots",
|
||||
"agents",
|
||||
"inbox",
|
||||
"chat",
|
||||
"myIssues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
@@ -41,7 +40,6 @@ describe("paths.workspace() shape", () => {
|
||||
["autopilots", "autopilots"],
|
||||
["agents", "agents"],
|
||||
["inbox", "inbox"],
|
||||
["chat", "chat"],
|
||||
["myIssues", "my-issues"],
|
||||
["runtimes", "runtimes"],
|
||||
["skills", "skills"],
|
||||
|
||||
@@ -25,10 +25,11 @@ function workspaceScoped(slug: string) {
|
||||
autopilots: () => `${ws}/autopilots`,
|
||||
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
|
||||
agents: () => `${ws}/agents`,
|
||||
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
|
||||
inbox: () => `${ws}/inbox`,
|
||||
chat: () => `${ws}/chat`,
|
||||
myIssues: () => `${ws}/my-issues`,
|
||||
runtimes: () => `${ws}/runtimes`,
|
||||
runtimeDetail: (id: string) => `${ws}/runtimes/${encode(id)}`,
|
||||
skills: () => `${ws}/skills`,
|
||||
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
|
||||
settings: () => `${ws}/settings`,
|
||||
|
||||
@@ -19,24 +19,24 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("not onboarded → /onboarding regardless of workspaces", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.onboarding(),
|
||||
);
|
||||
expect(
|
||||
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
|
||||
).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded + has workspace → /<first.slug>/issues", () => {
|
||||
it("has workspace → /<first.slug>/issues regardless of onboarded state", () => {
|
||||
const ws = [makeWs("acme"), makeWs("beta")];
|
||||
expect(resolvePostAuthDestination(ws, true)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination(ws, false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
});
|
||||
|
||||
it("onboarded + zero workspaces → /workspaces/new", () => {
|
||||
it("zero workspaces + !onboarded → /onboarding", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("zero workspaces + onboarded → /workspaces/new", () => {
|
||||
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,23 @@ import { paths } from "./paths";
|
||||
|
||||
/**
|
||||
* Priority:
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
* has workspace → /<first.slug>/issues
|
||||
* zero workspaces && !hasOnboarded → /onboarding
|
||||
* zero workspaces && hasOnboarded → /workspaces/new
|
||||
*
|
||||
* Workspace presence wins over onboarding state: a user invited into an
|
||||
* existing workspace must NOT be bounced into the new-workspace wizard
|
||||
* just because their personal `onboarded_at` is still null.
|
||||
*/
|
||||
export function resolvePostAuthDestination(
|
||||
workspaces: Workspace[],
|
||||
hasOnboarded: boolean,
|
||||
): string {
|
||||
if (!hasOnboarded) {
|
||||
return paths.onboarding();
|
||||
}
|
||||
const first = workspaces[0];
|
||||
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
|
||||
if (first) {
|
||||
return paths.workspace(first.slug).issues();
|
||||
}
|
||||
return hasOnboarded ? paths.newWorkspace() : paths.onboarding();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
20
packages/core/permissions/index.ts
Normal file
20
packages/core/permissions/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Public API for the permissions module.
|
||||
*
|
||||
* Exports only what the views currently consume. The full pure-rule set lives
|
||||
* in `./rules` and is available to tests and future surfaces directly. Adding
|
||||
* a new rule to the public API should follow the same minimum-surface pattern
|
||||
* — only export when there's a caller.
|
||||
*/
|
||||
export type {
|
||||
Decision,
|
||||
DecisionReason,
|
||||
PermissionContext,
|
||||
} from "./types";
|
||||
|
||||
export { canAssignAgentToIssue, canEditAgent } from "./rules";
|
||||
|
||||
export {
|
||||
useAgentPermissions,
|
||||
useSkillPermissions,
|
||||
} from "./use-resource-permissions";
|
||||
329
packages/core/permissions/rules.test.ts
Normal file
329
packages/core/permissions/rules.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, Comment, Member, RuntimeDevice, Skill } from "../types";
|
||||
import {
|
||||
canAssignAgentToIssue,
|
||||
canChangeMemberRole,
|
||||
canDeleteComment,
|
||||
canDeleteRuntime,
|
||||
canDeleteSkill,
|
||||
canDeleteWorkspace,
|
||||
canEditAgent,
|
||||
canEditComment,
|
||||
canEditSkill,
|
||||
canManageMembers,
|
||||
canUpdateWorkspaceSettings,
|
||||
} from "./rules";
|
||||
|
||||
const ALICE = "user-alice";
|
||||
const BOB = "user-bob";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agt_1",
|
||||
workspace_id: "ws_1",
|
||||
runtime_id: "rt_1",
|
||||
name: "agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "default",
|
||||
owner_id: ALICE,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkill(createdBy: string | null): Skill {
|
||||
return {
|
||||
id: "skl_1",
|
||||
workspace_id: "ws_1",
|
||||
name: "skill",
|
||||
description: "",
|
||||
content: "",
|
||||
config: {},
|
||||
files: [],
|
||||
created_by: createdBy,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeComment(overrides: Partial<Comment> = {}): Comment {
|
||||
return {
|
||||
id: "cmt_1",
|
||||
issue_id: "iss_1",
|
||||
author_type: "member",
|
||||
author_id: ALICE,
|
||||
content: "hi",
|
||||
type: "comment",
|
||||
parent_id: null,
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
return {
|
||||
id: "rt_1",
|
||||
workspace_id: "ws_1",
|
||||
daemon_id: null,
|
||||
name: "runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "anthropic",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("canEditAgent", () => {
|
||||
const agent = makeAgent({ owner_id: ALICE });
|
||||
|
||||
it("allows the owner", () => {
|
||||
expect(canEditAgent(agent, { userId: ALICE, role: "member" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows workspace owner", () => {
|
||||
expect(canEditAgent(agent, { userId: BOB, role: "owner" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows workspace admin", () => {
|
||||
expect(canEditAgent(agent, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies non-owner member", () => {
|
||||
const d = canEditAgent(agent, { userId: BOB, role: "member" });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_resource_owner");
|
||||
});
|
||||
it("denies when userId is null", () => {
|
||||
const d = canEditAgent(agent, { userId: null, role: null });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_authenticated");
|
||||
});
|
||||
it("denies when agent owner_id is null and user is plain member", () => {
|
||||
const orphan = makeAgent({ owner_id: null });
|
||||
expect(
|
||||
canEditAgent(orphan, { userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
it("admin can still edit an orphan (owner_id null) agent", () => {
|
||||
const orphan = makeAgent({ owner_id: null });
|
||||
expect(canEditAgent(orphan, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAssignAgentToIssue", () => {
|
||||
it("allows any member to assign workspace-visibility agents", () => {
|
||||
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
|
||||
expect(
|
||||
canAssignAgentToIssue(a, { userId: BOB, role: "member" }).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies non-members from assigning workspace agents", () => {
|
||||
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
|
||||
const d = canAssignAgentToIssue(a, { userId: BOB, role: null });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_member");
|
||||
});
|
||||
it("allows the owner to assign their private agent", () => {
|
||||
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
||||
expect(
|
||||
canAssignAgentToIssue(a, { userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("allows workspace admin to assign someone else's private agent", () => {
|
||||
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
||||
expect(
|
||||
canAssignAgentToIssue(a, { userId: BOB, role: "admin" }).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies a plain member from assigning someone else's private agent", () => {
|
||||
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
||||
const d = canAssignAgentToIssue(a, { userId: BOB, role: "member" });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("private_visibility");
|
||||
});
|
||||
it("denies logged-out users", () => {
|
||||
const a = makeAgent({ visibility: "workspace" });
|
||||
const d = canAssignAgentToIssue(a, { userId: null, role: null });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_authenticated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditSkill / canDeleteSkill", () => {
|
||||
const skill = makeSkill(ALICE);
|
||||
it("allows admins", () => {
|
||||
expect(canEditSkill(skill, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows the creator", () => {
|
||||
expect(canEditSkill(skill, { userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(true);
|
||||
});
|
||||
it("denies non-creator member", () => {
|
||||
expect(canEditSkill(skill, { userId: BOB, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
it("denies when created_by is null and user is plain member", () => {
|
||||
expect(
|
||||
canEditSkill(makeSkill(null), { userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
it("canDeleteSkill mirrors canEditSkill", () => {
|
||||
expect(canDeleteSkill(skill, { userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(true);
|
||||
expect(canDeleteSkill(skill, { userId: BOB, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditComment / canDeleteComment", () => {
|
||||
it("allows the author to edit their own comment", () => {
|
||||
const c = makeComment({ author_id: ALICE });
|
||||
expect(canEditComment(c, { userId: ALICE, role: "member" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows workspace admin to edit someone else's comment", () => {
|
||||
const c = makeComment({ author_id: ALICE });
|
||||
expect(canEditComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies non-author non-admin", () => {
|
||||
const c = makeComment({ author_id: ALICE });
|
||||
expect(canEditComment(c, { userId: BOB, role: "member" }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("denies edit on agent-authored comments", () => {
|
||||
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
||||
const d = canEditComment(c, { userId: BOB, role: "owner" });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_resource_owner");
|
||||
});
|
||||
it("admin CAN delete an agent-authored comment", () => {
|
||||
// delete is broader than edit — admins moderate any comment regardless of
|
||||
// author type. Mirrors backend `comment.go:507-512`.
|
||||
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
||||
expect(canDeleteComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies plain member from deleting agent-authored comment", () => {
|
||||
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
||||
expect(
|
||||
canDeleteComment(c, { userId: BOB, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDeleteRuntime", () => {
|
||||
it("allows the owner", () => {
|
||||
const r = makeRuntime(ALICE);
|
||||
expect(canDeleteRuntime(r, { userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(true);
|
||||
});
|
||||
it("allows workspace admin", () => {
|
||||
const r = makeRuntime(ALICE);
|
||||
expect(canDeleteRuntime(r, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies non-owner non-admin", () => {
|
||||
const r = makeRuntime(ALICE);
|
||||
expect(canDeleteRuntime(r, { userId: BOB, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-level rules", () => {
|
||||
it("only owner can delete workspace", () => {
|
||||
expect(canDeleteWorkspace({ userId: ALICE, role: "owner" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
expect(canDeleteWorkspace({ userId: ALICE, role: "admin" }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canDeleteWorkspace({ userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
it("owner+admin can update settings, member cannot", () => {
|
||||
expect(
|
||||
canUpdateWorkspaceSettings({ userId: ALICE, role: "owner" }).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUpdateWorkspaceSettings({ userId: ALICE, role: "admin" }).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUpdateWorkspaceSettings({ userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
it("manage members same gate as settings", () => {
|
||||
expect(canManageMembers({ userId: ALICE, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
expect(canManageMembers({ userId: ALICE, role: "member" }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canChangeMemberRole", () => {
|
||||
const ctxOwner = { userId: ALICE, role: "owner" as const };
|
||||
const ctxAdmin = { userId: ALICE, role: "admin" as const };
|
||||
const ctxMember = { userId: ALICE, role: "member" as const };
|
||||
|
||||
const targetOwner: Pick<Member, "role"> = { role: "owner" };
|
||||
const targetAdmin: Pick<Member, "role"> = { role: "admin" };
|
||||
const targetMember: Pick<Member, "role"> = { role: "member" };
|
||||
|
||||
it("non-managers cannot change roles", () => {
|
||||
expect(canChangeMemberRole(targetMember, 2, ctxMember).allowed).toBe(false);
|
||||
});
|
||||
it("admin cannot change owner's role", () => {
|
||||
const d = canChangeMemberRole(targetOwner, 2, ctxAdmin);
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_owner_role");
|
||||
});
|
||||
it("admin can change admin/member roles", () => {
|
||||
expect(canChangeMemberRole(targetAdmin, 1, ctxAdmin).allowed).toBe(true);
|
||||
expect(canChangeMemberRole(targetMember, 1, ctxAdmin).allowed).toBe(true);
|
||||
});
|
||||
it("owner cannot demote the last owner", () => {
|
||||
const d = canChangeMemberRole(targetOwner, 1, ctxOwner);
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("last_owner");
|
||||
});
|
||||
it("owner can change owner role when 2+ owners exist", () => {
|
||||
expect(canChangeMemberRole(targetOwner, 2, ctxOwner).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
210
packages/core/permissions/rules.ts
Normal file
210
packages/core/permissions/rules.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
Agent,
|
||||
Comment,
|
||||
Member,
|
||||
MemberRole,
|
||||
RuntimeDevice,
|
||||
Skill,
|
||||
} from "../types";
|
||||
import { ALLOW, deny, type Decision, type PermissionContext } from "./types";
|
||||
|
||||
/**
|
||||
* Pure permission rules — single source of truth that mirrors the Go backend
|
||||
* gates in `server/internal/handler/`. Hooks in `use-resource-permissions.ts`
|
||||
* are thin wrappers that pull `PermissionContext` from auth + member queries
|
||||
* and forward to these.
|
||||
*
|
||||
* Returning a `Decision` (not a boolean) lets every surface — disabled state,
|
||||
* tooltip, banner copy — read the same `reason` and stay consistent without
|
||||
* sprinkling copy through the view layer.
|
||||
*/
|
||||
|
||||
const isAdminLike = (role: MemberRole | null) =>
|
||||
role === "owner" || role === "admin";
|
||||
|
||||
// ---- Agents ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update / archive / restore agent fields. The backend gates archive and
|
||||
* restore identically to edit (`server/internal/handler/agent.go:519-535`),
|
||||
* so callers can use `canEditAgent` for all three.
|
||||
*/
|
||||
export function canEditAgent(agent: Agent, ctx: PermissionContext): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit this agent.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the agent owner and workspace admins can edit this agent.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign an agent to an issue. Workspace-visibility agents are assignable by
|
||||
* any workspace member; private agents are restricted to their owner plus
|
||||
* workspace admins/owners. Mirrors `issue.go:1471-1490`.
|
||||
*/
|
||||
export function canAssignAgentToIssue(
|
||||
agent: Agent,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to assign agents.");
|
||||
}
|
||||
if (agent.visibility === "workspace") {
|
||||
if (ctx.role === null) {
|
||||
return deny("not_member", "Join this workspace to assign agents.");
|
||||
}
|
||||
return ALLOW;
|
||||
}
|
||||
// visibility === "private"
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
||||
return deny(
|
||||
"private_visibility",
|
||||
"Personal agent — only the owner and workspace admins can assign work.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Skills ----------------------------------------------------------------
|
||||
|
||||
export function canEditSkill(skill: Skill, ctx: PermissionContext): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit this skill.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (skill.created_by !== null && skill.created_by === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the creator and workspace admins can edit this skill.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteSkill(skill: Skill, ctx: PermissionContext): Decision {
|
||||
return canEditSkill(skill, ctx);
|
||||
}
|
||||
|
||||
// ---- Comments --------------------------------------------------------------
|
||||
|
||||
export function canEditComment(
|
||||
comment: Comment,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit comments.");
|
||||
}
|
||||
// Only member-authored comments can be edited; agent-authored comments are
|
||||
// immutable from any human's perspective.
|
||||
if (comment.author_type !== "member") {
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Agent-authored comments cannot be edited.",
|
||||
);
|
||||
}
|
||||
if (comment.author_id === ctx.userId) return ALLOW;
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the author and workspace admins can edit this comment.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteComment(
|
||||
comment: Comment,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to delete comments.");
|
||||
}
|
||||
if (comment.author_type === "member" && comment.author_id === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the author and workspace admins can delete this comment.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Runtimes --------------------------------------------------------------
|
||||
|
||||
export function canDeleteRuntime(
|
||||
runtime: RuntimeDevice,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to delete runtimes.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (runtime.owner_id !== null && runtime.owner_id === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the runtime owner and workspace admins can delete this runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Workspace -------------------------------------------------------------
|
||||
|
||||
export function canUpdateWorkspaceSettings(ctx: PermissionContext): Decision {
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_admin_role",
|
||||
"Only workspace owners and admins can update workspace settings.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteWorkspace(ctx: PermissionContext): Decision {
|
||||
if (ctx.role === "owner") return ALLOW;
|
||||
return deny(
|
||||
"not_owner_role",
|
||||
"Only the workspace owner can delete this workspace.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageMembers(ctx: PermissionContext): Decision {
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_admin_role",
|
||||
"Only workspace owners and admins can manage members.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the role-change matrix from `workspace.go:458-530`:
|
||||
* - admins cannot touch the owner role (neither demote owners nor promote)
|
||||
* - the last owner cannot be demoted
|
||||
* - non-managers cannot change roles at all
|
||||
*
|
||||
* `ownerCount` is the number of workspace members currently with role=owner.
|
||||
* Caller derives it locally from the cached member list.
|
||||
*/
|
||||
export function canChangeMemberRole(
|
||||
target: Pick<Member, "role">,
|
||||
ownerCount: number,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
const manage = canManageMembers(ctx);
|
||||
if (!manage.allowed) return manage;
|
||||
|
||||
if (target.role === "owner") {
|
||||
if (ctx.role !== "owner") {
|
||||
return deny(
|
||||
"not_owner_role",
|
||||
"Only the workspace owner can change another owner's role.",
|
||||
);
|
||||
}
|
||||
if (ownerCount <= 1) {
|
||||
return deny(
|
||||
"last_owner",
|
||||
"Promote another member to owner first — a workspace must keep at least one owner.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return ALLOW;
|
||||
}
|
||||
52
packages/core/permissions/types.ts
Normal file
52
packages/core/permissions/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { MemberRole } from "../types";
|
||||
|
||||
/**
|
||||
* Inputs to every permission rule. Stays role-typed so we don't have to thread
|
||||
* `MemberWithUser` (with PII) into pure logic — only what we actually need.
|
||||
*
|
||||
* `userId === null` models the logged-out edge case; `role === null` models the
|
||||
* "not a workspace member" / "member list still loading" case. Both must
|
||||
* gracefully deny without throwing.
|
||||
*/
|
||||
export interface PermissionContext {
|
||||
userId: string | null;
|
||||
role: MemberRole | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable enum of *why* a permission was denied (or allowed). Lets UIs pick
|
||||
* different copy / disabled states / banner variants without parsing the
|
||||
* `message` string. Tests assert on `reason`.
|
||||
*/
|
||||
export type DecisionReason =
|
||||
| "allowed"
|
||||
| "not_authenticated"
|
||||
| "not_member"
|
||||
| "not_owner_role"
|
||||
| "not_admin_role"
|
||||
| "not_resource_owner"
|
||||
| "last_owner"
|
||||
| "private_visibility"
|
||||
| "unknown";
|
||||
|
||||
export interface Decision {
|
||||
allowed: boolean;
|
||||
reason: DecisionReason;
|
||||
/**
|
||||
* Human-readable copy for tooltips / banners. Centralised here so view code
|
||||
* doesn't drift. UI may still wrap it for emphasis but should not invent
|
||||
* its own copy.
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Builder helpers — keeps rules.ts tight. */
|
||||
export const ALLOW: Decision = {
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
message: "",
|
||||
};
|
||||
|
||||
export function deny(reason: DecisionReason, message: string): Decision {
|
||||
return { allowed: false, reason, message };
|
||||
}
|
||||
32
packages/core/permissions/use-current-member.ts
Normal file
32
packages/core/permissions/use-current-member.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "../auth";
|
||||
import type { MemberRole, MemberWithUser } from "../types";
|
||||
import { memberListOptions } from "../workspace/queries";
|
||||
|
||||
/**
|
||||
* Resolves the current user's membership in the given workspace. Single source
|
||||
* of truth for "what role am I" — replaces ad-hoc `members.find(...)` lookups
|
||||
* scattered across the views.
|
||||
*
|
||||
* `wsId` is explicit (not via `useWorkspaceId()` Context) so this hook stays
|
||||
* usable in components that may render before workspace context is wired,
|
||||
* matching the repo rule for workspace-aware hooks.
|
||||
*/
|
||||
export function useCurrentMember(wsId: string): {
|
||||
userId: string | null;
|
||||
role: MemberRole | null;
|
||||
member: MemberWithUser | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { data: members, isLoading } = useQuery(memberListOptions(wsId));
|
||||
const member = members?.find((m) => m.user_id === userId) ?? null;
|
||||
return {
|
||||
userId,
|
||||
role: member?.role ?? null,
|
||||
member,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
65
packages/core/permissions/use-resource-permissions.ts
Normal file
65
packages/core/permissions/use-resource-permissions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Agent, Skill } from "../types";
|
||||
import { useCurrentMember } from "./use-current-member";
|
||||
import {
|
||||
canAssignAgentToIssue,
|
||||
canDeleteSkill,
|
||||
canEditAgent,
|
||||
canEditSkill,
|
||||
} from "./rules";
|
||||
import { deny, type Decision } from "./types";
|
||||
|
||||
const PENDING: Decision = deny("unknown", "");
|
||||
|
||||
/**
|
||||
* Per-resource hook that returns a `Decision` for every relevant capability.
|
||||
* Each hook calls `useCurrentMember()` once and threads the context into the
|
||||
* pure rules in `rules.ts`.
|
||||
*
|
||||
* `wsId` is explicit (not read from `WorkspaceIdProvider`) so the hook stays
|
||||
* usable outside a workspace context — matches the repo rule for
|
||||
* workspace-aware hooks.
|
||||
*
|
||||
* Resource = `null` collapses every Decision to a denied "unknown" — keeps
|
||||
* callers branch-free during loading.
|
||||
*
|
||||
* `canArchive` / `canRestore` / `canManage` are deliberately not exposed:
|
||||
* the backend gates them identically to `canEdit`, so callers can use
|
||||
* `canEdit` everywhere and read better at the call site.
|
||||
*/
|
||||
export function useAgentPermissions(
|
||||
agent: Agent | null,
|
||||
wsId: string,
|
||||
): {
|
||||
canEdit: Decision;
|
||||
canAssign: Decision;
|
||||
} {
|
||||
const { userId, role } = useCurrentMember(wsId);
|
||||
const ctx = { userId, role };
|
||||
if (agent === null) {
|
||||
return { canEdit: PENDING, canAssign: PENDING };
|
||||
}
|
||||
return {
|
||||
canEdit: canEditAgent(agent, ctx),
|
||||
canAssign: canAssignAgentToIssue(agent, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillPermissions(
|
||||
skill: Skill | null,
|
||||
wsId: string,
|
||||
): {
|
||||
canEdit: Decision;
|
||||
canDelete: Decision;
|
||||
} {
|
||||
const { userId, role } = useCurrentMember(wsId);
|
||||
const ctx = { userId, role };
|
||||
if (skill === null) {
|
||||
return { canEdit: PENDING, canDelete: PENDING };
|
||||
}
|
||||
return {
|
||||
canEdit: canEditSkill(skill, ctx),
|
||||
canDelete: canDeleteSkill(skill, ctx),
|
||||
};
|
||||
}
|
||||
54
packages/core/projects/draft-store.ts
Normal file
54
packages/core/projects/draft-store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { ProjectStatus, ProjectPriority } from "../types";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
interface ProjectDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: ProjectStatus;
|
||||
priority: ProjectPriority;
|
||||
leadType?: "member" | "agent";
|
||||
leadId?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: ProjectDraft = {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "planned",
|
||||
priority: "none",
|
||||
leadType: undefined,
|
||||
leadId: undefined,
|
||||
icon: undefined,
|
||||
};
|
||||
|
||||
interface ProjectDraftStore {
|
||||
draft: ProjectDraft;
|
||||
setDraft: (patch: Partial<ProjectDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
export const useProjectDraftStore = create<ProjectDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () =>
|
||||
set({ draft: { ...EMPTY_DRAFT } }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!(draft.title || draft.description);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_project_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());
|
||||
@@ -1,2 +1,9 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
export { useProjectDraftStore } from "./draft-store";
|
||||
export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
} from "./resource-queries";
|
||||
|
||||
87
packages/core/projects/resource-queries.ts
Normal file
87
packages/core/projects/resource-queries.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { projectKeys } from "./queries";
|
||||
import type {
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
ProjectResource,
|
||||
} from "../types";
|
||||
|
||||
export const projectResourceKeys = {
|
||||
list: (wsId: string, projectId: string) =>
|
||||
[...projectKeys.detail(wsId, projectId), "resources"] as const,
|
||||
};
|
||||
|
||||
export function projectResourcesOptions(wsId: string, projectId: string) {
|
||||
return queryOptions({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
queryFn: () => api.listProjectResources(projectId),
|
||||
select: (data) => data.resources,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateProjectResourceRequest) =>
|
||||
api.createProjectResource(projectId, data),
|
||||
onSuccess: (created) => {
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old && !old.resources.some((r) => r.id === created.id)
|
||||
? {
|
||||
...old,
|
||||
resources: [...old.resources, created],
|
||||
total: old.total + 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (resourceId: string) =>
|
||||
api.deleteProjectResource(projectId, resourceId),
|
||||
onMutate: async (resourceId) => {
|
||||
await qc.cancelQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
const prev = qc.getQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
);
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
resources: old.resources.filter(
|
||||
(r: ProjectResource) => r.id !== resourceId,
|
||||
),
|
||||
total: old.total - 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(projectResourceKeys.list(wsId, projectId), ctx.prev);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,12 @@ import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
import { autopilotKeys } from "../autopilots/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
agentTaskSnapshotKeys,
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -45,9 +51,13 @@ import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskQueuedPayload,
|
||||
TaskDispatchPayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
ChatDonePayload,
|
||||
ChatPendingTask,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
|
||||
@@ -143,6 +153,34 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
},
|
||||
// Powers the agent presence cache: any task lifecycle change
|
||||
// (dispatch / completed / failed / cancelled) refreshes the
|
||||
// workspace-wide agent-task-snapshot query so per-agent presence
|
||||
// reflects the change. task:message is NOT in this prefix path — it
|
||||
// stays in specificEvents to avoid an invalidate storm during long runs.
|
||||
task: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
||||
// 30d activity series shares the same lifecycle signal — any task
|
||||
// completion / failure shifts the histogram. (Dispatch alone
|
||||
// doesn't change a completed_at-anchored series, but invalidating
|
||||
// here keeps the WS-handler shape uniform; the resulting refetch
|
||||
// is cheap.) Both the list (trailing 7d slice) and the detail
|
||||
// panel read off this single cache.
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
||||
// 30-day run count likewise increments per task lifecycle event.
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
||||
// Per-agent task list (Activity tab "Recent work"). Prefix match
|
||||
// catches every agent's list — the per-agent detail key sits
|
||||
// under agentTasks/<wsId>/<agentId>.
|
||||
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
||||
// Per-issue task list (issue-detail Execution log). Prefix match
|
||||
// across all issues — keeps the contract "any task: event makes
|
||||
// every list-of-tasks query stale" so cache stays fresh even
|
||||
// when the relevant component isn't currently mounted.
|
||||
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
|
||||
},
|
||||
};
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -167,9 +205,18 @@ export function useRealtimeSync(
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat / task events are handled explicitly below; do not double-invalidate.
|
||||
// Chat events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"task:message", "task:completed", "task:failed",
|
||||
// task:message stays out of the prefix path because it fires per
|
||||
// streamed message during a long run — invalidating the snapshot on
|
||||
// every message would flood the network. Specific chat handlers below
|
||||
// still receive it via ws.on() (a separate subscription channel).
|
||||
"task:message",
|
||||
// task:completed / task:failed deliberately NOT here. They go through
|
||||
// both the task-prefix invalidate (refreshes the agent-task-snapshot
|
||||
// cache) AND the chat-specific ws.on() handlers below. The two
|
||||
// channels are independent — onAny dispatch and ws.on are separate
|
||||
// subscriptions.
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -417,7 +464,7 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
});
|
||||
|
||||
// --- Chat / task events (global, survives chat page unmount) ---
|
||||
// --- Chat / task events (global, survives ChatWindow unmount) ---
|
||||
//
|
||||
// Single source of truth: the Query cache. No Zustand writes here — the
|
||||
// earlier mirror caused a race where the cache and store disagreed
|
||||
@@ -482,6 +529,64 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// Chat task lifecycle writethrough: keep `chatKeys.pendingTask(sessionId)`
|
||||
// synchronized with the server state machine via setQueryData rather than
|
||||
// invalidate-refetch. Same pattern as task:message — the WS payload
|
||||
// carries everything we need, and an HTTP roundtrip just to read what we
|
||||
// already know would add latency to every stage transition.
|
||||
//
|
||||
// task:queued is emitted by EnqueueChatTask. The optimistic seed in
|
||||
// chat-window.tsx may have already populated the cache with a temporary
|
||||
// id; this handler upgrades it to the real task_id (and reaffirms status
|
||||
// when reconnect replays the event for an already-running task).
|
||||
const unsubTaskQueued = ws.on("task:queued", (p) => {
|
||||
const payload = p as TaskQueuedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => ({
|
||||
...(old ?? {}),
|
||||
task_id: payload.task_id,
|
||||
status: "queued",
|
||||
}),
|
||||
);
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
// task:dispatch fires when the daemon claims the queued task. The daemon
|
||||
// immediately follows with StartTask, so dispatched→running is sub-second.
|
||||
// We collapse that window by writing "running" directly — the pill jumps
|
||||
// from "Queued" straight to "Thinking", skipping a meaningless "Starting"
|
||||
// frame. Stage decision in TaskStatusPill maps "running" + empty
|
||||
// taskMessages → "Thinking · Ns".
|
||||
const unsubTaskDispatch = ws.on("task:dispatch", (p) => {
|
||||
const payload = p as TaskDispatchPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => {
|
||||
if (!old || old.task_id !== payload.task_id) return old;
|
||||
return { ...old, status: "running" };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// task:cancelled reaches us when:
|
||||
// 1. handleStop already cleared the cache locally (this is a no-op confirm)
|
||||
// 2. another tab / admin / system cancels — this is the only path that
|
||||
// drops the pending pill in those cases. Without it the pill spins
|
||||
// forever in the second-tab scenario.
|
||||
const unsubTaskCancelled = ws.on("task:cancelled", (p) => {
|
||||
const payload = p as TaskCancelledPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.info("task:cancelled (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
@@ -502,8 +607,14 @@ export function useRealtimeSync(
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
// FailTask writes a failure chat_message (mirroring CompleteTask's
|
||||
// success message), so this path mirrors the task:completed handler:
|
||||
// clear the pending signal AND invalidate the messages list so the
|
||||
// failure bubble shows up without requiring a page refresh. Pre-#1823
|
||||
// this branch only flipped pending — the comment "No new message"
|
||||
// was true then, but FailTask now persists a row.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
@@ -541,6 +652,9 @@ export function useRealtimeSync(
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskQueued();
|
||||
unsubTaskDispatch();
|
||||
unsubTaskCancelled();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
@@ -566,6 +680,9 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
} catch (e) {
|
||||
|
||||
61
packages/core/runtimes/cli-version.ts
Normal file
61
packages/core/runtimes/cli-version.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Frontend mirror of the server's MinQuickCreateCLIVersion gate. The
|
||||
* agent-create flow (Quick Create modal) requires the daemon's bundled
|
||||
* multica CLI to be at least this version — older daemons either
|
||||
* double-create issues on partial CLI failures or mishandle pasted
|
||||
* screenshot URLs (see PR #1851 / MUL-1496).
|
||||
*
|
||||
* Both the frontend pre-validation in the modal and the server's
|
||||
* `/api/issues/quick-create` handler enforce this; the server is the
|
||||
* authoritative trust boundary, the frontend just lets us tell the user
|
||||
* "your daemon needs an upgrade" before they hit submit.
|
||||
*/
|
||||
export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.20";
|
||||
|
||||
export type CliVersionState = "ok" | "too_old" | "missing";
|
||||
|
||||
export interface CliVersionCheck {
|
||||
state: CliVersionState;
|
||||
/** What the daemon reported, or empty if missing/unparsable. */
|
||||
current: string;
|
||||
/** The hard minimum we gate on. */
|
||||
min: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
function parseSemver(raw: string): [number, number, number] | null {
|
||||
const m = SEMVER_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
if (a[0] !== b[0]) return a[0] < b[0];
|
||||
if (a[1] !== b[1]) return a[1] < b[1];
|
||||
return a[2] < b[2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a daemon-reported CLI version string against the minimum. Returns
|
||||
* `"missing"` for empty/unparsable input (fail closed — same policy as the
|
||||
* server) and `"too_old"` for a parsable version below the threshold.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const min = parseSemver(MIN_QUICK_CREATE_CLI_VERSION)!;
|
||||
if (lessThan(parsed, min)) {
|
||||
return { state: "too_old", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
|
||||
/** Pull `cli_version` off a runtime row's loosely-typed metadata bag. */
|
||||
export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefined): string {
|
||||
const v = metadata?.cli_version;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
103
packages/core/runtimes/derive-health.test.ts
Normal file
103
packages/core/runtimes/derive-health.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRuntime } from "../types";
|
||||
import { deriveRuntimeHealth } from "./derive-health";
|
||||
|
||||
const FIXED_NOW = new Date("2026-04-27T12:00:00Z").getTime();
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveRuntimeHealth", () => {
|
||||
it("returns online when status is online (regardless of last_seen_at)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(makeRuntime({ status: "online", last_seen_at: null }), FIXED_NOW),
|
||||
).toBe("online");
|
||||
});
|
||||
|
||||
it("returns recently_lost when offline less than 5 minutes", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - 2 * 60_000).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("recently_lost");
|
||||
});
|
||||
|
||||
it("returns offline when offline between 5 minutes and 6 days", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - 60 * 60_000).toISOString(), // 1 hour
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("returns about_to_gc when offline beyond 6 days (within 1 day of GC)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - 6.5 * 24 * 3600_000).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("about_to_gc");
|
||||
});
|
||||
|
||||
it("treats null last_seen_at as long-offline (about_to_gc)", () => {
|
||||
// last_seen_at = null means lastSeen = 0 (epoch), so offlineFor is huge.
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({ status: "offline", last_seen_at: null }),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("about_to_gc");
|
||||
});
|
||||
|
||||
it("respects the 5-minute boundary (just inside → recently_lost)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - (5 * 60_000 - 1_000)).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("recently_lost");
|
||||
});
|
||||
|
||||
it("respects the 5-minute boundary (just outside → offline)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - (5 * 60_000 + 1_000)).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
});
|
||||
27
packages/core/runtimes/derive-health.ts
Normal file
27
packages/core/runtimes/derive-health.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Pure derivation of a runtime's user-facing "health" state from the raw
|
||||
// server fields (status + last_seen_at). Splitting the offline state into
|
||||
// time-bucketed flavors lets the UI distinguish "just lost — likely
|
||||
// transient" from "long gone — needs attention" with no schema change.
|
||||
|
||||
import type { AgentRuntime } from "../types";
|
||||
import type { RuntimeHealth } from "./types";
|
||||
|
||||
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
||||
// The runtime sweeper GCs runtimes that have been offline for 7 days. We
|
||||
// flag the last 24 hours of that window so users can rescue a runtime
|
||||
// before it disappears silently.
|
||||
const ABOUT_TO_GC_THRESHOLD_MS = 6 * 24 * 3600 * 1000; // 6 days
|
||||
|
||||
export function deriveRuntimeHealth(runtime: AgentRuntime, now: number): RuntimeHealth {
|
||||
if (runtime.status === "online") return "online";
|
||||
|
||||
// No last_seen timestamp ever recorded — treat as long-offline. This is
|
||||
// an unusual case (the back-end always sets last_seen_at on register),
|
||||
// but defending against it keeps the UI from crashing on legacy rows.
|
||||
const lastSeen = runtime.last_seen_at ? new Date(runtime.last_seen_at).getTime() : 0;
|
||||
const offlineFor = now - lastSeen;
|
||||
|
||||
if (offlineFor < FIVE_MINUTES_MS) return "recently_lost";
|
||||
if (offlineFor > ABOUT_TO_GC_THRESHOLD_MS) return "about_to_gc";
|
||||
return "offline";
|
||||
}
|
||||
@@ -3,3 +3,7 @@ export * from "./mutations";
|
||||
export * from "./hooks";
|
||||
export * from "./models";
|
||||
export * from "./local-skills";
|
||||
export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
|
||||
@@ -5,9 +5,47 @@ export const runtimeKeys = {
|
||||
all: (wsId: string) => ["runtimes", wsId] as const,
|
||||
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
|
||||
listMine: (wsId: string) => [...runtimeKeys.all(wsId), "list", "mine"] as const,
|
||||
usage: (rid: string, days: number) =>
|
||||
["runtimes", "usage", rid, days] as const,
|
||||
usageByAgent: (rid: string, days: number) =>
|
||||
["runtimes", "usage", "by-agent", rid, days] as const,
|
||||
usageByHour: (rid: string, days: number) =>
|
||||
["runtimes", "usage", "by-hour", rid, days] as const,
|
||||
latestVersion: () => ["runtimes", "latestVersion"] as const,
|
||||
};
|
||||
|
||||
// Per-runtime usage. Used by the list view (each row pulls its own activity
|
||||
// sparkline + 30d cost) and by the detail page. TanStack Query naturally
|
||||
// deduplicates concurrent calls for the same runtime, so multiple components
|
||||
// observing the same runtimeId share one network request.
|
||||
export function runtimeUsageOptions(runtimeId: string, days: number) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usage(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsage(runtimeId, { days }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-agent token totals for one runtime — drives the "Cost by agent" tab
|
||||
// on the runtime detail page. Server-side aggregation keeps the response
|
||||
// small (one row per agent) regardless of task volume.
|
||||
export function runtimeUsageByAgentOptions(runtimeId: string, days: number) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usageByAgent(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Hourly (0..23) token totals for one runtime — drives the "By hour" tab.
|
||||
export function runtimeUsageByHourOptions(runtimeId: string, days: number) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usageByHour(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimeListOptions(wsId: string, owner?: "me") {
|
||||
return queryOptions({
|
||||
queryKey: owner === "me" ? runtimeKeys.listMine(wsId) : runtimeKeys.list(wsId),
|
||||
|
||||
10
packages/core/runtimes/types.ts
Normal file
10
packages/core/runtimes/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Derived "health" type for runtimes — the user-facing state we display
|
||||
// in lists, cards, and tooltips. The raw server field is binary (online /
|
||||
// offline + last_seen_at); this enum splits the offline state into three
|
||||
// time-bucketed flavors so users can tell "just lost" from "long gone".
|
||||
|
||||
export type RuntimeHealth =
|
||||
| "online" // green — within heartbeat threshold
|
||||
| "recently_lost" // amber — offline < 5 minutes (likely transient)
|
||||
| "offline" // grey — offline 5 minutes ~ 7 days
|
||||
| "about_to_gc"; // dim — within 1 day of the 7-day GC threshold
|
||||
46
packages/core/runtimes/use-runtime-health.ts
Normal file
46
packages/core/runtimes/use-runtime-health.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { runtimeListOptions } from "./queries";
|
||||
import { deriveRuntimeHealth } from "./derive-health";
|
||||
import type { RuntimeHealth } from "./types";
|
||||
|
||||
// Re-render every 30s so transitions like recently_lost → offline (which
|
||||
// happens at the 5-minute mark with no new data) reflect in the UI.
|
||||
const HEALTH_TICK_MS = 30_000;
|
||||
|
||||
function useHealthTick(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), HEALTH_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return tick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived runtime health (online / recently_lost / offline / about_to_gc),
|
||||
* or "loading" while the runtime list is still resolving.
|
||||
*
|
||||
* Accepts wsId as a parameter so the hook works outside WorkspaceIdProvider.
|
||||
*/
|
||||
export function useRuntimeHealth(
|
||||
wsId: string | undefined,
|
||||
runtimeId: string | undefined,
|
||||
): RuntimeHealth | "loading" {
|
||||
const { data: runtimes } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const tick = useHealthTick();
|
||||
|
||||
return useMemo<RuntimeHealth | "loading">(() => {
|
||||
if (!wsId || !runtimeId) return "loading";
|
||||
if (!runtimes) return "loading";
|
||||
const runtime = runtimes.find((r) => r.id === runtimeId);
|
||||
if (!runtime) return "loading";
|
||||
return deriveRuntimeHealth(runtime, Date.now());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, runtimeId, runtimes, tick]);
|
||||
}
|
||||
@@ -23,6 +23,34 @@ export interface RuntimeDevice {
|
||||
|
||||
export type AgentRuntime = RuntimeDevice;
|
||||
|
||||
// Coarse classifier set by the backend when a task transitions to "failed".
|
||||
// Mirrors the migration-055 enum in agent_task_queue.failure_reason. Used by
|
||||
// the agent presence derivation and the UI failure-message lookup.
|
||||
export type TaskFailureReason =
|
||||
| "agent_error"
|
||||
| "timeout"
|
||||
| "runtime_offline"
|
||||
| "runtime_recovery"
|
||||
| "manual";
|
||||
|
||||
// One daily bucket for the Agents-list ACTIVITY sparkline. The back-end
|
||||
// only returns days that had at least one completion; the front-end fills
|
||||
// in missing days with zero when rendering the 7-bucket series. The series
|
||||
// is anchored on completed_at (a task in flight contributes nothing).
|
||||
export interface AgentActivityBucket {
|
||||
agent_id: string;
|
||||
// ISO timestamp at midnight UTC of the day.
|
||||
bucket_at: string;
|
||||
task_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
// 30-day total run count per agent, drives the Agents-list RUNS column.
|
||||
export interface AgentRunCount {
|
||||
agent_id: string;
|
||||
run_count: number;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
@@ -38,11 +66,35 @@ export interface AgentTask {
|
||||
completed_at: string | null;
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
// Empty string when the task is not in a failed state (the backend uses
|
||||
// `omitempty`, so the field may also be missing on non-failed tasks).
|
||||
failure_reason?: TaskFailureReason | "";
|
||||
created_at: string;
|
||||
/** Non-empty when the task was spawned from a chat session. */
|
||||
chat_session_id?: string;
|
||||
/** Non-empty when the task was spawned by an autopilot run. */
|
||||
autopilot_run_id?: string;
|
||||
/** Set when this task was created as an auto-retry of a parent task. */
|
||||
parent_task_id?: string;
|
||||
/** 1-based attempt counter; >1 means this is a retry. */
|
||||
attempt?: number;
|
||||
/** Set when an issue comment triggered this task (@mention or assignee comment). */
|
||||
trigger_comment_id?: string;
|
||||
/**
|
||||
* Canonical short description of what triggered this task — snapshot
|
||||
* taken at creation time. For comment-triggered tasks it's the
|
||||
* comment text (truncated to ~200 chars); for autopilot it's the
|
||||
* autopilot title; NULL for direct assignments and chat tasks.
|
||||
* Persists even if the source comment / autopilot is later edited
|
||||
* or deleted.
|
||||
*/
|
||||
trigger_summary?: string;
|
||||
/**
|
||||
* Server-computed source discriminator used by the activity row to label
|
||||
* tasks that have no linked issue (so e.g. quick-create tasks render
|
||||
* with a meaningful title instead of falling through to "Untracked").
|
||||
*/
|
||||
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
@@ -170,6 +222,33 @@ export interface RuntimeHourlyActivity {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// One (agent, model) row of the "Cost by agent" tab on the runtime detail
|
||||
// page. Model stays on the wire because cost is computed client-side from
|
||||
// a per-model pricing table — the client groups these rows by agent_id and
|
||||
// sums cost per agent across models.
|
||||
export interface RuntimeUsageByAgent {
|
||||
agent_id: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
// One (hour, model) row for the "By hour" tab; hour ∈ 0..23. Hours with
|
||||
// zero activity are omitted by the server; the client fills the gap to
|
||||
// render a continuous axis. Model preserved for client-side cost math.
|
||||
export interface RuntimeUsageByHour {
|
||||
hour: number;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
export type RuntimeUpdateStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
|
||||
@@ -28,18 +28,48 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
task_id: string | null;
|
||||
created_at: string;
|
||||
/**
|
||||
* When set, this is an assistant message synthesized by the server's
|
||||
* FailTask fallback (mirrors the issue path's failure system comment).
|
||||
* `content` carries the raw daemon-reported errMsg; the front-end maps
|
||||
* `failure_reason` (an enum like "agent_error" / "connection_error" /
|
||||
* "timeout") to a user-facing label and renders a destructive bubble.
|
||||
* Null on success messages and on user messages.
|
||||
*/
|
||||
failure_reason?: string | null;
|
||||
/**
|
||||
* Wall-clock duration from `task.created_at` (user hit send) to terminal
|
||||
* state (completed/failed). Set by the server on assistant messages
|
||||
* synthesized by CompleteTask/FailTask. UI renders it as "Replied in
|
||||
* 38s" / "Failed after 12s" beneath the bubble. Null on user messages
|
||||
* and on legacy assistant messages predating migration 063.
|
||||
*/
|
||||
elapsed_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
/**
|
||||
* Server-authoritative task creation time. Optimistic StatusPill seed
|
||||
* uses this as its anchor so the timer starts from the real `0s` —
|
||||
* without it the front-end falls back to its local clock and the
|
||||
* timer "snaps backwards" later when WS events update the cache.
|
||||
*/
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
* All fields are absent when the session has no in-flight task.
|
||||
*
|
||||
* `created_at` is the server-authoritative anchor for the chat StatusPill's
|
||||
* elapsed-seconds timer — the optimistic seed in chat-window.tsx fills in
|
||||
* task_id/status only, then this query catches up with the real created_at
|
||||
* so the timer survives refresh / reopen without "resetting to 0s".
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type WSEventType =
|
||||
| "agent:created"
|
||||
| "agent:archived"
|
||||
| "agent:restored"
|
||||
| "task:queued"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
| "task:completed"
|
||||
@@ -195,6 +196,22 @@ export interface TaskMessagePayload {
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface TaskQueuedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskDispatchPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
runtime_id: string;
|
||||
chat_session_id?: string;
|
||||
}
|
||||
|
||||
export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
|
||||
@@ -16,7 +16,9 @@ export type InboxItemType =
|
||||
| "task_failed"
|
||||
| "agent_blocked"
|
||||
| "agent_completed"
|
||||
| "reaction_added";
|
||||
| "reaction_added"
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
|
||||
@@ -5,6 +5,9 @@ export type {
|
||||
AgentRuntimeMode,
|
||||
AgentVisibility,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
TaskFailureReason,
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
@@ -16,6 +19,8 @@ export type {
|
||||
SetAgentSkillsRequest,
|
||||
RuntimeUsage,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimeUsageByAgent,
|
||||
RuntimeUsageByHour,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
@@ -33,6 +38,7 @@ export type {
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
@@ -42,7 +48,19 @@ export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type {
|
||||
Project,
|
||||
ProjectStatus,
|
||||
ProjectPriority,
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
ProjectResourceType,
|
||||
GithubRepoResourceRef,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
Autopilot,
|
||||
|
||||
15
packages/core/types/notification-preference.ts
Normal file
15
packages/core/types/notification-preference.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type NotificationGroupKey =
|
||||
| "assignments"
|
||||
| "status_changes"
|
||||
| "comments"
|
||||
| "updates"
|
||||
| "agent_activity";
|
||||
|
||||
export type NotificationGroupValue = "all" | "muted";
|
||||
|
||||
export type NotificationPreferences = Partial<Record<NotificationGroupKey, NotificationGroupValue>>;
|
||||
|
||||
export interface NotificationPreferenceResponse {
|
||||
workspace_id: string;
|
||||
preferences: NotificationPreferences;
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export interface CreateProjectRequest {
|
||||
priority?: ProjectPriority;
|
||||
lead_type?: "member" | "agent";
|
||||
lead_id?: string;
|
||||
// Resources to attach in the same transaction as the project. Server returns
|
||||
// 4xx (and rolls back) if any one is invalid or duplicate.
|
||||
resources?: CreateProjectResourceRequest[];
|
||||
}
|
||||
|
||||
export interface UpdateProjectRequest {
|
||||
@@ -42,3 +45,39 @@ export interface ListProjectsResponse {
|
||||
projects: Project[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ProjectResource is a typed pointer from a project to an external resource.
|
||||
// The resource_ref shape depends on resource_type (e.g. github_repo carries
|
||||
// { url, default_branch_hint? }). New types add a case in
|
||||
// validateAndNormalizeResourceRef on the server and a renderer in the UI;
|
||||
// no schema or type changes required.
|
||||
export type ProjectResourceType = "github_repo";
|
||||
|
||||
export interface GithubRepoResourceRef {
|
||||
url: string;
|
||||
default_branch_hint?: string;
|
||||
}
|
||||
|
||||
export interface ProjectResource {
|
||||
id: string;
|
||||
project_id: string;
|
||||
workspace_id: string;
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
label: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProjectResourceRequest {
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
label?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface ListProjectResourcesResponse {
|
||||
resources: ProjectResource[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export type MemberRole = "owner" | "admin" | "member";
|
||||
|
||||
export interface WorkspaceRepo {
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
|
||||
90
packages/ui/components/common/capability-banner.tsx
Normal file
90
packages/ui/components/common/capability-banner.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
type Resource = "agent" | "skill" | "comment" | "runtime" | "workspace";
|
||||
|
||||
type Reason =
|
||||
| "allowed"
|
||||
| "not_authenticated"
|
||||
| "not_member"
|
||||
| "not_owner_role"
|
||||
| "not_admin_role"
|
||||
| "not_resource_owner"
|
||||
| "last_owner"
|
||||
| "private_visibility"
|
||||
| "unknown";
|
||||
|
||||
const RESOURCE_NOUN: Record<Resource, string> = {
|
||||
agent: "agent",
|
||||
skill: "skill",
|
||||
comment: "comment",
|
||||
runtime: "runtime",
|
||||
workspace: "workspace",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only banner for resource detail pages — appears when the current user
|
||||
* cannot edit the resource. Single component owns all the copy variants so
|
||||
* the wording stays consistent across agent, skill, runtime detail pages.
|
||||
*
|
||||
* Returns `null` when the user *can* edit (reason === "allowed") so callers
|
||||
* can mount it unconditionally.
|
||||
*/
|
||||
export function CapabilityBanner({
|
||||
reason,
|
||||
resource,
|
||||
ownerName,
|
||||
className,
|
||||
}: {
|
||||
reason: Reason;
|
||||
resource: Resource;
|
||||
/** Display name of the resource owner / creator. Optional — copy degrades gracefully. */
|
||||
ownerName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (reason === "allowed" || reason === "unknown") return null;
|
||||
|
||||
const noun = RESOURCE_NOUN[resource];
|
||||
const message = getCopy(reason, noun, ownerName);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopy(reason: Reason, noun: string, ownerName?: string): string {
|
||||
switch (reason) {
|
||||
case "not_authenticated":
|
||||
return `Sign in to edit this ${noun}.`;
|
||||
case "not_member":
|
||||
return `Join this workspace to edit this ${noun}.`;
|
||||
case "not_owner_role":
|
||||
return `View only — only the workspace owner can manage this ${noun}.`;
|
||||
case "not_admin_role":
|
||||
return `View only — only workspace owners and admins can manage this ${noun}.`;
|
||||
case "not_resource_owner":
|
||||
if (ownerName) {
|
||||
return `View only — only ${ownerName} and workspace admins can edit this ${noun}.`;
|
||||
}
|
||||
return `View only — only the ${noun} owner and workspace admins can edit this ${noun}.`;
|
||||
case "last_owner":
|
||||
return `A workspace must keep at least one owner — promote another member first.`;
|
||||
case "private_visibility":
|
||||
if (ownerName) {
|
||||
return `Personal ${noun} — only ${ownerName} and workspace admins can use this.`;
|
||||
}
|
||||
return `Personal ${noun} — only the owner and workspace admins can use this.`;
|
||||
case "allowed":
|
||||
case "unknown":
|
||||
return ""; // unreachable; component returned null above
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ function FileUploadButton({
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
btnSize,
|
||||
|
||||
47
packages/ui/components/common/unicode-spinner.tsx
Normal file
47
packages/ui/components/common/unicode-spinner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import spinners, { type BrailleSpinnerName } from "unicode-animations";
|
||||
|
||||
interface Props {
|
||||
name?: BrailleSpinnerName;
|
||||
className?: string;
|
||||
/** Stop advancing frames without unmounting (e.g., when an outer state freezes). */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
// Inline-rendered braille spinner. Each frame is a unicode string from the
|
||||
// `unicode-animations` package; we tick frames on the spinner's own `interval`
|
||||
// and render the current one inside a fixed-width monospace span so different
|
||||
// frames never reflow neighbouring text. Width-jitter is the main reason this
|
||||
// component exists rather than dropping the raw strings into Tailwind classes.
|
||||
export function UnicodeSpinner({ name = "braille", className, paused }: Props) {
|
||||
const spec = spinners[name];
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
setFrame(0);
|
||||
const timer = setInterval(
|
||||
() => setFrame((f) => (f + 1) % spec.frames.length),
|
||||
spec.interval,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [name, paused, spec]);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
display: "inline-block",
|
||||
minWidth: "1ch",
|
||||
textAlign: "center",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{spec.frames[frame]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
106
packages/ui/components/ui/data-table-column-header.tsx
Normal file
106
packages/ui/components/ui/data-table-column-header.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronUp,
|
||||
EyeOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.ComponentProps<typeof DropdownMenuTrigger> {
|
||||
column: Column<TData, TValue>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Sort/hide-aware column header, adapted from Dice UI
|
||||
// (https://diceui.com/r/data-table). Renders the label as plain text when
|
||||
// the column has neither sorting nor hiding enabled (so non-interactive
|
||||
// columns don't expose a useless dropdown). Otherwise wraps the label in
|
||||
// a dropdown-menu trigger that toggles sort direction and hides the
|
||||
// column on demand.
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
label,
|
||||
className,
|
||||
...props
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort() && !column.getCanHide()) {
|
||||
return <div className={cn(className)}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
{column.getCanSort() &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<ChevronDown />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ChevronUp />
|
||||
) : (
|
||||
<ChevronsUpDown />
|
||||
))}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-28">
|
||||
{column.getCanSort() && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={column.getIsSorted() === "asc"}
|
||||
onClick={() => column.toggleSorting(false)}
|
||||
>
|
||||
<ChevronUp />
|
||||
Asc
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={column.getIsSorted() === "desc"}
|
||||
onClick={() => column.toggleSorting(true)}
|
||||
>
|
||||
<ChevronDown />
|
||||
Desc
|
||||
</DropdownMenuCheckboxItem>
|
||||
{column.getIsSorted() && (
|
||||
<DropdownMenuItem
|
||||
className="pl-2 [&_svg]:text-muted-foreground"
|
||||
onClick={() => column.clearSorting()}
|
||||
>
|
||||
<X />
|
||||
Reset
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{column.getCanHide() && (
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={!column.getIsVisible()}
|
||||
onClick={() => column.toggleVisibility(false)}
|
||||
>
|
||||
<EyeOff />
|
||||
Hide
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
319
packages/ui/components/ui/data-table.tsx
Normal file
319
packages/ui/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
type Header as TanstackHeader,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
|
||||
// We deliberately use the lower-level shadcn primitives (TableHeader /
|
||||
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
|
||||
// <Table> component. shadcn's <Table> nests the <table> inside an
|
||||
// `overflow-x-auto` <div>, which would compete with our outer scroll
|
||||
// container and pin the horizontal scrollbar to the bottom of the
|
||||
// table rather than the viewport.
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@multica/ui/components/ui/table";
|
||||
import { getCellStyle } from "@multica/ui/lib/data-table";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface DataTableProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: TanstackTable<TData>;
|
||||
// Optional bar shown below the table when ≥1 row is selected. We
|
||||
// don't currently use selection — kept on the API surface for parity
|
||||
// with Dice UI's component so future row-select features just work.
|
||||
actionBar?: React.ReactNode;
|
||||
// Override for the empty-state cell text.
|
||||
emptyMessage?: React.ReactNode;
|
||||
// Called when the user clicks a row (anywhere outside an interactive
|
||||
// descendant — buttons / dropdowns inside cells should call
|
||||
// event.stopPropagation in their own handlers). Used to navigate to
|
||||
// a detail page on row click without nesting an <a> around <tr>,
|
||||
// which is invalid HTML.
|
||||
onRowClick?: (row: Row<TData>) => void;
|
||||
}
|
||||
|
||||
// Headless data-table shell — adapted from Dice UI's data-table
|
||||
// registry (https://diceui.com/r/data-table). Renders a TanStack Table
|
||||
// instance using shadcn/ui's table primitives.
|
||||
//
|
||||
// Layout behaviour:
|
||||
// - `w-full` + `table-fixed` keeps the table at viewport width and
|
||||
// makes each column's width come from its first row's <th>
|
||||
// inline width. column.size is authoritative for sized columns.
|
||||
// - Columns flagged `meta.grow: true` skip their inline width, so
|
||||
// fixed table-layout assigns them the leftover space until the user
|
||||
// resizes them. Once resized, the explicit width is applied.
|
||||
// - The table's `min-width` is the sum of every column's TanStack
|
||||
// size (`table.getTotalSize()`). That gives grow columns a real
|
||||
// floor — fixed mode ignores cell-level min-width, but it does
|
||||
// respect `min-width` on the table itself. When the container is
|
||||
// wider than min-width the table tracks it; when narrower, the
|
||||
// table pins to min-width and the outer overflow-auto scrolls.
|
||||
export function DataTable<TData>({
|
||||
table,
|
||||
actionBar,
|
||||
emptyMessage = "No results.",
|
||||
onRowClick,
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<TData>) {
|
||||
const [resizingColumnId, setResizingColumnId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
const hasExplicitSize = React.useCallback(
|
||||
(columnId: string) =>
|
||||
Object.prototype.hasOwnProperty.call(columnSizing, columnId),
|
||||
[columnSizing],
|
||||
);
|
||||
|
||||
const setColumnWidth = React.useCallback(
|
||||
(header: TanstackHeader<TData, unknown>, width: number) => {
|
||||
const minSize = header.column.columnDef.minSize ?? 48;
|
||||
const maxSize =
|
||||
header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
|
||||
const next = Math.min(maxSize, Math.max(minSize, Math.round(width)));
|
||||
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[header.column.id]: next,
|
||||
}));
|
||||
},
|
||||
[table],
|
||||
);
|
||||
|
||||
const beginColumnResize = React.useCallback(
|
||||
(
|
||||
header: TanstackHeader<TData, unknown>,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (!header.column.getCanResize()) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const headerCell = event.currentTarget.closest("th");
|
||||
const startWidth =
|
||||
headerCell?.getBoundingClientRect().width ?? header.column.getSize();
|
||||
|
||||
setResizingColumnId(header.column.id);
|
||||
setColumnWidth(header, startWidth);
|
||||
|
||||
const originalCursor = document.body.style.cursor;
|
||||
const originalUserSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
setColumnWidth(header, startWidth + pointerEvent.clientX - startX);
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResize);
|
||||
window.removeEventListener("pointercancel", stopResize);
|
||||
document.body.style.cursor = originalCursor;
|
||||
document.body.style.userSelect = originalUserSelect;
|
||||
setResizingColumnId(null);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResize);
|
||||
window.addEventListener("pointercancel", stopResize);
|
||||
},
|
||||
[setColumnWidth],
|
||||
);
|
||||
|
||||
const handleResizeKeyDown = React.useCallback(
|
||||
(
|
||||
header: TanstackHeader<TData, unknown>,
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const headerCell = event.currentTarget.closest("th");
|
||||
const currentWidth = hasExplicitSize(header.column.id)
|
||||
? header.column.getSize()
|
||||
: (headerCell?.getBoundingClientRect().width ??
|
||||
header.column.getSize());
|
||||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||||
const step = event.shiftKey ? 20 : 8;
|
||||
|
||||
setColumnWidth(header, currentWidth + direction * step);
|
||||
},
|
||||
[hasExplicitSize, setColumnWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-h-0 flex-1 flex-col", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||
<table
|
||||
className="w-full table-fixed caption-bottom text-sm"
|
||||
style={{ minWidth: `${table.getTotalSize()}px` }}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted/30 backdrop-blur">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const columnHasExplicitSize = hasExplicitSize(
|
||||
header.column.id,
|
||||
);
|
||||
const headerLabel =
|
||||
typeof header.column.columnDef.header === "string"
|
||||
? header.column.columnDef.header
|
||||
: header.column.id;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
// Header typography overrides for a "spreadsheet
|
||||
// header" look: smaller, all-caps, wider letter
|
||||
// spacing, muted colour. shadcn's <TableHead>
|
||||
// defaults to text-sm + text-foreground +
|
||||
// font-medium, which reads as too heavy here.
|
||||
// h-8 (32px) tightens the strip vs the default
|
||||
// h-10 (40px).
|
||||
// overflow-hidden caps any cell content that
|
||||
// exceeds column.size. Tooltip / dropdown /
|
||||
// hover-card bodies are portaled, so they are
|
||||
// unaffected.
|
||||
// Pinned header cell uses muted/30 so it blends
|
||||
// into the header strip rather than appearing as
|
||||
// a white block under sticky scroll.
|
||||
className={cn(
|
||||
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
|
||||
isPinned && "bg-muted/30 backdrop-blur",
|
||||
)}
|
||||
style={getCellStyle(header.column, {
|
||||
withBorder: true,
|
||||
hasExplicitSize: columnHasExplicitSize,
|
||||
})}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
{!header.isPlaceholder &&
|
||||
header.column.getCanResize() && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={`Resize ${headerLabel} column`}
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none outline-none",
|
||||
"after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-border after:opacity-0 after:transition-opacity",
|
||||
"hover:after:opacity-100 focus-visible:after:opacity-100",
|
||||
resizingColumnId === header.column.id &&
|
||||
"after:bg-primary after:opacity-100",
|
||||
)}
|
||||
onPointerDown={(event) =>
|
||||
beginColumnResize(header, event)
|
||||
}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
header.column.resetSize();
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleResizeKeyDown(header, event)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={
|
||||
onRowClick ? () => onRowClick(row) : undefined
|
||||
}
|
||||
// `group` lets pinned cells track row hover via
|
||||
// group-hover (their bg is in className, not on the
|
||||
// row, so they stay opaque enough to cover content
|
||||
// scrolling beneath them).
|
||||
className={cn(
|
||||
"group",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const columnHasExplicitSize = hasExplicitSize(
|
||||
cell.column.id,
|
||||
);
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
// px-4 across the board so cell content
|
||||
// aligns with the surrounding toolbar's
|
||||
// px-4. Narrow trailing columns (chevron /
|
||||
// actions) declare a column.size large enough
|
||||
// to fit the icon plus 16+16 padding.
|
||||
// Pinned cells need an opaque bg + group-
|
||||
// hover so they cover content scrolling
|
||||
// beneath them and follow row hover state.
|
||||
className={cn(
|
||||
"overflow-hidden px-4 py-2",
|
||||
isPinned &&
|
||||
"bg-background group-hover:bg-muted/50",
|
||||
)}
|
||||
style={getCellStyle(cell.column, {
|
||||
withBorder: true,
|
||||
hasExplicitSize: columnHasExplicitSize,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getAllColumns().length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
{actionBar &&
|
||||
table.getFilteredSelectedRowModel().rows.length > 0 &&
|
||||
actionBar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user