mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 06:25:56 +02:00
Compare commits
79 Commits
refactor/c
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93d4094419 | ||
|
|
d9e5cf87dd | ||
|
|
13fe614903 | ||
|
|
2305f7d180 | ||
|
|
befde379b5 | ||
|
|
51fdc5aec3 | ||
|
|
32d61d018e | ||
|
|
51bc5a818f | ||
|
|
2dddfaa196 | ||
|
|
cbe7f2c886 | ||
|
|
1d1dedbf6e | ||
|
|
298ed75b1d | ||
|
|
47b5e38dc6 | ||
|
|
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 |
@@ -174,6 +174,22 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
|
||||
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
|
||||
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
|
||||
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
|
||||
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
|
||||
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
|
||||
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
|
||||
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
|
||||
|
||||
#### Workspace garbage collection
|
||||
|
||||
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
|
||||
|
||||
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
|
||||
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
|
||||
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
|
||||
|
||||
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
@@ -181,8 +197,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 +218,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 已存在
|
||||
```
|
||||
12
README.md
12
README.md
@@ -36,6 +36,18 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Why "Multica"?
|
||||
|
||||
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
|
||||
|
||||
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
|
||||
|
||||
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
|
||||
|
||||
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
|
||||
|
||||
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
@@ -36,6 +36,18 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 为什么叫 "Multica"?
|
||||
|
||||
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
|
||||
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统,让多个用户能够共享同一台机器,同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
|
||||
|
||||
我们认为,类似的转折点正在再次出现。几十年来,软件团队一直处于一种单线程的工作模式,一个工程师处理一个任务,一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
|
||||
|
||||
在 Multica 中,agents 是一级团队成员。它们会被分配 issue,汇报进展,提出阻塞,并交付代码,就像人类同事一样。任务分配、活动时间线、任务生命周期,以及运行时基础设施,Multica 从第一天起就是围绕这一理念构建的。
|
||||
|
||||
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents,就能发挥出二十人团队的推进速度。
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ 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";
|
||||
@@ -110,21 +110,58 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// 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.
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
//
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
// missed invites later once they're onboarded.
|
||||
let cancelled = false;
|
||||
void api
|
||||
.listMyInvitations()
|
||||
.then((invites) => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
latestOpen({ type: "invitations" });
|
||||
} else {
|
||||
latestOpen({ type: "onboarding" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
latestOpen({ type: "onboarding" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
open({ type: "new-workspace" });
|
||||
return undefined;
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
|
||||
@@ -65,5 +65,7 @@ function overlayPath(overlay: WindowOverlay): string {
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
case "invitations":
|
||||
return "/invitations";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
@@ -58,6 +59,7 @@ function WindowOverlayInner() {
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
|
||||
@@ -61,6 +61,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/invitations") {
|
||||
overlay.open({ type: "invitations" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { create } from "zustand";
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "invitations" }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
|
||||
@@ -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.
|
||||
28
apps/web/app/(auth)/invitations/page.tsx
Normal file
28
apps/web/app/(auth)/invitations/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
|
||||
export default function InvitationsRoutePage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Unauthenticated users have nowhere meaningful to land here — kick them
|
||||
// through login and bring them back. The login page will eventually run
|
||||
// its own listMyInvitations() check and route them here again.
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace(
|
||||
`${paths.login()}?next=${encodeURIComponent(paths.invitations())}`,
|
||||
);
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitationsPage />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
@@ -27,6 +27,32 @@ import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import Link from "next/link";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
/**
|
||||
* Pick where a logged-in user with no explicit `?next=` should land.
|
||||
* Un-onboarded users with pending invitations on their email get routed to
|
||||
* the batch /invitations page; everyone else falls through to the standard
|
||||
* resolver. A network blip on listMyInvitations is non-fatal — we fall
|
||||
* through rather than trap the user on an error screen.
|
||||
*/
|
||||
async function resolveLoggedInDestination(
|
||||
qc: QueryClient,
|
||||
hasOnboarded: boolean,
|
||||
workspaces: Workspace[],
|
||||
): Promise<string> {
|
||||
if (!hasOnboarded) {
|
||||
try {
|
||||
const invites = await api.listMyInvitations();
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
return paths.invitations();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return resolvePostAuthDestination(workspaces, hasOnboarded);
|
||||
}
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
@@ -72,33 +98,28 @@ function LoginPageContent() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
void resolveLoggedInDestination(qc, hasOnboarded, list).then((dest) =>
|
||||
router.replace(dest),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
const handleSuccess = async () => {
|
||||
// Read the latest user snapshot directly — the closure's `hasOnboarded`
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
router.push(resolvePostAuthDestination(list, onboarded));
|
||||
const dest = await resolveLoggedInDestination(qc, onboarded, list);
|
||||
router.push(dest);
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function OnboardingPage() {
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,7 +40,15 @@ export default function OnboardingPage() {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
if (!workspacesFetched) return;
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
// hasWorkspaces bounce here would kick the user out before Steps 4–5
|
||||
// (runtime / agent / first issue) can run. The new entry-point
|
||||
// judgment in callback / login handles "where should this user go on
|
||||
// login" so OnboardingPage no longer needs to second-guess it.
|
||||
if (hasOnboarded) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
|
||||
@@ -2,13 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockPush,
|
||||
mockSearchParams,
|
||||
mockLoginWithGoogle,
|
||||
mockListWorkspaces,
|
||||
mockListMyInvitations,
|
||||
mockSetQueryData,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
mockListMyInvitations: vi.fn(),
|
||||
mockSetQueryData: vi.fn(),
|
||||
}));
|
||||
|
||||
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
||||
id: "user-1",
|
||||
@@ -28,7 +36,7 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
useQueryClient: () => ({ setQueryData: mockSetQueryData }),
|
||||
}));
|
||||
|
||||
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
||||
@@ -46,12 +54,16 @@ vi.mock("@multica/core/auth", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
workspaceKeys: {
|
||||
list: () => ["workspaces"],
|
||||
myInvitations: () => ["invitations", "mine"],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: mockListWorkspaces,
|
||||
listMyInvitations: mockListMyInvitations,
|
||||
googleLogin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -61,26 +73,78 @@ 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([]);
|
||||
mockListMyInvitations.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());
|
||||
// nextUrl is a fast path — listMyInvitations should not be queried.
|
||||
expect(mockListMyInvitations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
expect(mockListMyInvitations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unonboarded user with pending invitations lands on /invitations", async () => {
|
||||
mockListMyInvitations.mockResolvedValue([
|
||||
{
|
||||
id: "inv-1",
|
||||
workspace_id: "ws-1",
|
||||
workspace_name: "Acme",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
},
|
||||
]);
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded user with workspace lands in that workspace", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
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());
|
||||
});
|
||||
// Already-onboarded users skip the listMyInvitations check; new invites
|
||||
// surface in the sidebar instead of the wall.
|
||||
expect(mockListMyInvitations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
@@ -109,4 +173,12 @@ describe("CallbackPage", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through to /onboarding when listMyInvitations errors", async () => {
|
||||
mockListMyInvitations.mockRejectedValue(new Error("network"));
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,13 +66,42 @@ function CallbackContent() {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
|
||||
// 1. nextUrl wins: a `next=/invite/<id>` always survives the OAuth
|
||||
// round-trip — the user clicked a specific link and we should
|
||||
// honor exactly that destination.
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
|
||||
// 2. Un-onboarded users may have pending invitations on their
|
||||
// email even when no `next=` was carried (came from a fresh
|
||||
// login on app.multica.ai instead of clicking the email link,
|
||||
// or `state` was lost across the round-trip). Look them up by
|
||||
// email and route to the batch /invitations page if any.
|
||||
// Already-onboarded users skip this lookup — their new invites
|
||||
// surface in the sidebar dropdown, not as a forced wall.
|
||||
if (!onboarded) {
|
||||
try {
|
||||
const invites = await api.listMyInvitations();
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
router.push(paths.invitations());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network blip on the invite lookup is non-fatal — fall through
|
||||
// to the normal post-auth destination so the user isn't stuck
|
||||
// on a blank callback screen. Worst case they land on
|
||||
// /onboarding and the sidebar will surface invites later.
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default: hand off to the resolver (onboarding for first-timers,
|
||||
// first workspace for returning users, /workspaces/new for
|
||||
// onboarded users with zero workspaces).
|
||||
router.push(resolvePostAuthDestination(wsList, onboarded));
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
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,52 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.21",
|
||||
date: "2026-04-30",
|
||||
title: "Quick Capture Overhaul, Mermaid Diagrams & Typed Project Resources",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Capture replaces the old New Issue dialog — continuous-create mode, file uploads, and automatic enrichment from pasted URLs",
|
||||
"Mermaid diagrams render inline in markdown, with a fullscreen lightbox for complex graphs",
|
||||
"Projects can bind their own repo, separate from the workspace default",
|
||||
"Permission-aware UI across agents, comments, runtimes, and skills — actions you can't take are no longer offered",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon `/tasks/claim` polling uses a Redis empty-claim fast-path, dropping idle DB load and reclaiming disk on long-open issues",
|
||||
"Multica Agent commits include a `Co-authored-by` trailer for proper Git attribution",
|
||||
"Desktop blocks Cmd+R / Ctrl+R / F5 from reloading the app and shows the real version in dev and Updates settings",
|
||||
],
|
||||
fixes: [
|
||||
"Quick Create no longer invents requirements beyond user input, and subscribes the requester to the issue it creates",
|
||||
"Inbox jumps straight to the targeted comment, and auto-archives when the issue is marked Done from the detail page",
|
||||
"Task rerun starts a fresh session and skips poisoned resume state",
|
||||
"Invitees land on their workspace after sign-in instead of being forced through `/onboarding`",
|
||||
],
|
||||
},
|
||||
{
|
||||
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,52 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.21",
|
||||
date: "2026-04-30",
|
||||
title: "Quick Capture 全面升级、Mermaid 图表与 Typed Project Resources",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Capture 取代旧的 New Issue 弹窗 —— 支持连续创建、文件上传,并能根据粘贴的 URL 自动丰富标题与描述",
|
||||
"Markdown 内联渲染 Mermaid 图表,复杂图支持全屏 lightbox",
|
||||
"Project 支持单独绑定 repo,无需依赖 workspace 默认配置",
|
||||
"Agent / 评论 / Runtime / Skill 全面接入权限感知 UI,没有权限的操作不再展示",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon `/tasks/claim` 轮询走 Redis 空认领 fast-path,空闲态 DB 压力下降,长期 open 的 Issue 自动回收磁盘",
|
||||
"Multica Agent 的 Git 提交自动追加 `Co-authored-by` trailer,归属更清晰",
|
||||
"Desktop 拦截 Cmd+R / Ctrl+R / F5 防止意外刷新,开发模式与 Updates 设置中均展示真实版本号",
|
||||
],
|
||||
fixes: [
|
||||
"Quick Create 不再凭空脑补需求,并自动把发起人订阅到 Issue",
|
||||
"Inbox 点击通知后立即跳到目标评论;从 Issue 详情页 Mark as Done 时自动归档",
|
||||
"Task rerun 启动全新 session,跳过被污染的 resume 状态",
|
||||
"受邀成员登录后路由到所在 workspace,不再强制带去 `/onboarding`",
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -1,802 +0,0 @@
|
||||
# Agent / Runtime 状态系统重设计
|
||||
|
||||
> **文档定位**:这是一份完整的设计 + 实施方案。任何一个新加入的工程师 / 设计师 / 产品,看完这份文档应该能:
|
||||
> - 理解我们要解决的问题、为什么这么解决
|
||||
> - 知道每个阶段做什么、按什么顺序做、产出什么
|
||||
> - 在不读代码的前提下能独立讨论方案
|
||||
>
|
||||
> 本文档是 [agent-status-design-brief.md](./agent-status-design-brief.md) 和 [agent-status-redesign-plan.md](./agent-status-redesign-plan.md) 的合并升级版,达成共识后会取代它们。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [背景与目标](#一背景与目标)
|
||||
2. [核心思想](#二核心思想)
|
||||
3. [状态系统规范](#三状态系统规范)
|
||||
4. [数据架构](#四数据架构)
|
||||
5. [跨平台策略](#五跨平台策略)
|
||||
6. [设计语言](#六设计语言)
|
||||
7. [实施分阶段](#七实施分阶段)
|
||||
8. [验收标准](#八验收标准)
|
||||
9. [边界与不做的事](#九边界与不做的事)
|
||||
10. [风险与注意事项](#十风险与注意事项)
|
||||
11. [参考](#十一参考)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 Multica 的产品定位提醒
|
||||
|
||||
Multica 是 AI-native 的任务管理平台——agent 是和人对等的"同事"。一个工作区里同时有人和 agent 在协作,相互分配任务、评论、订阅。
|
||||
|
||||
理解这个定位很重要,因为它直接决定了状态系统的需求:**用户对 agent 的预期跟"对同事的预期"是相同的**——我希望随时知道这个同事现在能不能接活、在不在线、是不是出问题了。
|
||||
|
||||
### 1.2 当前的核心问题
|
||||
|
||||
**所有界面都在直接展示后端的原始字段,缺少"用户视角"的状态翻译层。**
|
||||
|
||||
具体表现(按用户感知严重度排序):
|
||||
|
||||
1. **Agent 列表的 "Idle" 绿点会骗人**——daemon 已经死了,agent 仍然显示 Idle。用户分配任务后没有任何反馈,长时间困惑"为什么 agent 不动"。
|
||||
2. **Runtime 列表只有一个圆点**——"刚断线 5 分钟"和"3 个月前断线"视觉一模一样,用户判断不出严重程度。
|
||||
3. **Issue 详情页多个 agent 同时工作时只有 1 个可见**——其他 agent 的卡片埋在下方滚动区,没有总览。
|
||||
4. **任务失败时只有红色 X**——`agent_error`(agent 自己挂了)和 `runtime_offline`(daemon 离线)处理方式完全不同,但视觉上不区分。
|
||||
5. **Chat 发消息后只有一个 spinner 转数分钟**——无法区分"排队"、"思考"、"调用工具"、"生成回答"四个阶段。
|
||||
|
||||
### 1.3 根本原因
|
||||
|
||||
后端字段是**任务调度的内部状态**(`agent.status` = idle/working/blocked/error),不是给用户看的。当前前端把后端字段直接渲染给用户,就把"调度内部视角"暴露成了"用户视角"。
|
||||
|
||||
但用户不关心 agent 的内部调度状态,用户关心的是:
|
||||
|
||||
- **能不能用?**(在线 / 离线)
|
||||
- **如果在用,在干什么?**(工作中 / 排队中 / 失败了)
|
||||
- **如果不能用,为什么?**(daemon 离线 / CLI 没装 / 任务超时)
|
||||
|
||||
这些问题的答案,**没有任何一个能从单一字段直接得到**——它们都需要把多个数据源聚合后才能算出来。
|
||||
|
||||
### 1.4 目标
|
||||
|
||||
**做一套"用户视角"的状态系统**,覆盖三类对象:
|
||||
|
||||
- **Agent**:5 态(available / working / pending / failed / offline),跨界面一致
|
||||
- **Runtime**:4 态(online / recently_lost / offline / about_to_gc)
|
||||
- **Task**:阶段化(queued / dispatched / thinking / using_tool / generating / completed / failed)
|
||||
|
||||
完成这套系统后,下列现有界面会自然变好:
|
||||
|
||||
- Agents 列表 / 详情:看到真实可用性
|
||||
- Runtimes 列表 / 详情:看到机器健康度
|
||||
- Issue Detail:多 agent 全景 + 失败原因显示
|
||||
- 跨界面 hover card(issue assignee / autopilot / chat / @mention):状态一致
|
||||
- Chat:分阶段进度
|
||||
|
||||
---
|
||||
|
||||
## 二、核心思想
|
||||
|
||||
### 2.1 一句话
|
||||
|
||||
**把"用户视角的状态"做成前端的派生量**——后端只暴露真相(任务存在、心跳到达),前端按 UI 需要把这些真相聚合成"用户能理解的状态"。
|
||||
|
||||
### 2.2 三个设计原则
|
||||
|
||||
#### 原则 1:派生函数住在前端,不污染后端
|
||||
|
||||
`agent.status` / `runtime.status` 这些后端字段是 **物理事实**:
|
||||
- "task X 现在 running"
|
||||
- "daemon Y 45 秒前发了心跳"
|
||||
|
||||
而 "Available / Working / Pending / Failed / Offline" 这些是 **UI 翻译**:
|
||||
- "FAILED 状态保持 2 分钟" 是设计决策
|
||||
- "蓝色表示 working" 是视觉决策
|
||||
- 不同界面可能要不同视角(issue 里看"任务阶段"、列表里看"是否可分配")
|
||||
|
||||
**UI 翻译应该住在前端,跟着设计需求一起迭代。** 把它放进后端,每改一次都要 migration + WS payload 兼容 + 老客户端处理,迭代周期从分钟变周。
|
||||
|
||||
#### 原则 2:服务器状态住在 TanStack Query cache,不复制进 Zustand
|
||||
|
||||
我们的全局状态有两套:
|
||||
- **Zustand** 管 client state(UI 选中、筛选器、modal 开关)
|
||||
- **TanStack Query cache** 管 server state(来自 API 的所有数据)
|
||||
|
||||
TQ cache 不是组件级缓存,**它本身就是 server state 的全局状态管理**。跨组件共享、按 key 索引、自动去重。
|
||||
|
||||
派生状态是 server data 的纯函数。结果不存——每次组件渲染时按需用 `useMemo` 算一遍。算的成本是几个 filter + some 调用,可忽略。
|
||||
|
||||
#### 原则 3:聚合在前端做,但要避免 N+1
|
||||
|
||||
派生状态需要 3 份原始数据:
|
||||
- agents 列表
|
||||
- runtimes 列表
|
||||
- 当前活跃任务列表(active tasks)
|
||||
|
||||
朴素做法:每个组件 `useAgentTasks(agentId)`——一个 issues 列表 30 个 agent 头像 = 30 次请求。这就是 N+1。
|
||||
|
||||
正解:**进工作区时一次性拉"全工作区的活跃任务"**(数据量天然不大,活跃任务永远是少数),存进 TQ cache。所有组件共享这一份缓存,按 agentId 在内存里 filter——零额外请求。
|
||||
|
||||
这是把"per-agent 的数据需求"转换成"全工作区的集合数据需求"。集合数据天然只需要 1 次请求。
|
||||
|
||||
---
|
||||
|
||||
## 三、状态系统规范
|
||||
|
||||
### 3.1 Agent 五态
|
||||
|
||||
| 状态 | 颜色 | 用户语义 | 出现条件 |
|
||||
|---|---|---|---|
|
||||
| **Available** | 🟢 绿 | 在线空闲,可以接活 | runtime 在线 + 没有活跃任务 |
|
||||
| **Working** | 🔵 蓝 | 正在干活 | runtime 在线 + 至少一个任务在执行 |
|
||||
| **Pending** | 🟡 黄 | 任务排着但没在跑 | runtime 在线 + 0 个执行中 + ≥1 个排队 |
|
||||
| **Failed** | 🔴 红 | 最近一次失败 | 最近 2 分钟内有任务失败 |
|
||||
| **Offline** | ⚫ 灰 | Daemon 离线,不可用 | runtime 离线(包含 CLI 未安装) |
|
||||
|
||||
**复合维度**:当 agent 是 Working 但同时有任务排队,主状态保持 Working,旁边带 `+N` 角标("还有 N 个排队")。
|
||||
|
||||
**派生规则**(按优先级匹配,命中即返回):
|
||||
|
||||
```ts
|
||||
type AgentPresence = "available" | "working" | "pending" | "failed" | "offline"
|
||||
|
||||
function deriveAgentPresence(input: {
|
||||
agent: Agent
|
||||
runtime: AgentRuntime
|
||||
recentTasks: AgentTask[] // 该 agent 最近 N 个任务
|
||||
now: number // 当前时间戳
|
||||
}): AgentPresence {
|
||||
// 1. Runtime 离线(含 CLI 未安装)→ offline
|
||||
if (input.runtime.status === "offline") return "offline"
|
||||
|
||||
// 2. 最近窗口内有 failed task → failed
|
||||
const recentFailed = input.recentTasks.find(
|
||||
t => t.status === "failed" &&
|
||||
(input.now - new Date(t.completed_at).getTime()) < FAILED_WINDOW_MS
|
||||
)
|
||||
if (recentFailed) return "failed"
|
||||
|
||||
// 3. 有 running task → working
|
||||
if (input.recentTasks.some(t => t.status === "running")) return "working"
|
||||
|
||||
// 4. 有 queued/dispatched task → pending
|
||||
if (input.recentTasks.some(t => t.status === "queued" || t.status === "dispatched")) {
|
||||
return "pending"
|
||||
}
|
||||
|
||||
// 5. Otherwise → available
|
||||
return "available"
|
||||
}
|
||||
```
|
||||
|
||||
**复合维度的派生**:
|
||||
|
||||
```ts
|
||||
function deriveAgentPresenceDetail(...): {
|
||||
presence: AgentPresence
|
||||
runningCount: number
|
||||
queuedCount: number
|
||||
failureReason?: TaskFailureReason // 仅 presence === "failed" 时有值
|
||||
}
|
||||
```
|
||||
|
||||
**待确认常量**:
|
||||
|
||||
- `FAILED_WINDOW_MS = 2 * 60 * 1000`(2 分钟)。
|
||||
- 短到避免污染(任务失败的红点不会一直黏着)
|
||||
- 长到让用户能看见(不会还没看就消失)
|
||||
- **未来可能扩展**:2 分钟内强提示(红色动效),之后降级为 tooltip 的"最近失败"摘要——这样既不刺眼,又不丢信息。本期先用固定 2 分钟。
|
||||
|
||||
### 3.2 Runtime 四态
|
||||
|
||||
| 状态 | 触发条件 | 用户语义 |
|
||||
|---|---|---|
|
||||
| **Online** | 最近 45 秒内有心跳 | 健康,能接任务 |
|
||||
| **Recently Lost** | 离线但 < 5 分钟 | 可能短暂网络抖动 |
|
||||
| **Offline** | 离线 5 分钟 ~ 7 天 | 长期离线,需要排查 |
|
||||
| **About to GC** | 离线接近 7 天阈值 | 系统将自动清理 |
|
||||
|
||||
```ts
|
||||
function deriveRuntimeHealth(runtime: AgentRuntime, now: number): RuntimeHealth {
|
||||
if (runtime.status === "online") return "online"
|
||||
|
||||
const lastSeen = runtime.last_seen_at
|
||||
? new Date(runtime.last_seen_at).getTime()
|
||||
: 0
|
||||
const offlineFor = now - lastSeen
|
||||
|
||||
if (offlineFor < 5 * 60 * 1000) return "recently_lost"
|
||||
if (offlineFor > 6 * 24 * 3600 * 1000) return "about_to_gc" // 7d - 1d
|
||||
return "offline"
|
||||
}
|
||||
```
|
||||
|
||||
> **关于 CLI 未安装等"runtime 在线但跑不了"的场景**:归并到 `offline`,tooltip 写明具体原因("CLI 未安装"、"Daemon 启动中")。不为此引入第六态——状态空间膨胀代价高,分辨率应该用 tooltip / detail 而不是顶层枚举。
|
||||
|
||||
### 3.3 Task 阶段化
|
||||
|
||||
用于 Issue Detail 和 Chat 显示当前任务在哪一步:
|
||||
|
||||
```ts
|
||||
type TaskStage =
|
||||
| "queued"
|
||||
| "dispatched" // dispatched 但进程没起来
|
||||
| "thinking" // running,最后一条 message 是 thinking
|
||||
| "using_tool" // running,最后一条是 tool_use / tool_result
|
||||
| "generating" // running,最后一条是 text
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
```
|
||||
|
||||
派生函数读 task 状态 + 最后一条 message 的 type 即可决定阶段。
|
||||
|
||||
### 3.4 失败原因映射
|
||||
|
||||
`task.failure_reason` 来自后端 migration 055,5 个值映射到中文标签:
|
||||
|
||||
```ts
|
||||
const FAILURE_REASON_LABEL: Record<TaskFailureReason, string> = {
|
||||
agent_error: "Agent 执行报错",
|
||||
timeout: "执行超时",
|
||||
runtime_offline: "Daemon 离线",
|
||||
runtime_recovery: "Daemon 重启回收",
|
||||
manual: "用户取消",
|
||||
}
|
||||
```
|
||||
|
||||
每种原因要给用户对应的处理建议(具体文案待设计师定)。
|
||||
|
||||
> ⚠️ **当前差异**:后端 schema 已有 `failure_reason`(migration 055),但前端 `packages/core/types/agent.ts` 的 `AgentTask` 接口**未暴露此字段**。阶段 0 必须同步:
|
||||
> - 前端类型补 `failure_reason: TaskFailureReason | null`
|
||||
> - 检查后端 `ListAgentTasks` / 新增的 `ListActiveTasksByWorkspace` 是否 SELECT 了这个字段
|
||||
|
||||
---
|
||||
|
||||
## 四、数据架构
|
||||
|
||||
### 4.1 数据流
|
||||
|
||||
```
|
||||
后端真相
|
||||
┌────────────┬─────────────┬──────────────┐
|
||||
│ agents │ runtimes │ active_tasks│
|
||||
│ (HTTP) │ (HTTP) │ (HTTP) │
|
||||
└─────┬──────┴──────┬──────┴───────┬──────┘
|
||||
│ │ │
|
||||
└─────────────┴──────────────┘
|
||||
▼
|
||||
TanStack Query cache
|
||||
(全局共享)
|
||||
▼
|
||||
派生函数(纯函数)
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
AgentPresence RuntimeHealth
|
||||
│ │
|
||||
▼ ▼
|
||||
组件渲染(5 态视觉) 组件渲染(4 态视觉)
|
||||
|
||||
──────────────────────────────────────────────
|
||||
|
||||
实时更新
|
||||
|
||||
后端 WS 事件 ──→ 前端 invalidate query ──→ 重拉 ──→ 派生重算 ──→ UI 更新
|
||||
|
||||
桌面端额外:本机 IPC ──→ setQueryData 直接预填 ──→ 亚秒级响应
|
||||
```
|
||||
|
||||
### 4.2 三个 query
|
||||
|
||||
```ts
|
||||
// 进工作区时一次性拉
|
||||
useQuery(['ws', wsId, 'agents']) // listAgents
|
||||
useQuery(['ws', wsId, 'runtimes']) // listRuntimes
|
||||
useQuery(['ws', wsId, 'active-tasks']) // ★ 新增:getActiveTasksForWorkspace
|
||||
```
|
||||
|
||||
**`active-tasks` 是新增 query**——返回当前工作区所有 status ∈ {queued, dispatched, running} 的任务。这份数据天然小(活跃任务不会很多),值得做成"全工作区一次拉"。
|
||||
|
||||
> ⚠️ **当前差异**:后端**没有此 endpoint**。现有只有 `listAgentTasks(agentId)`(per-agent)和 `getActiveTasksForIssue(issueId)`(per-issue)。阶段 0 必须新增 `GET /api/workspaces/:slug/active-tasks`——它仍然返回原始 task 列表,不做任何派生,**不违反"零后端聚合"原则**。
|
||||
|
||||
#### Runtime → Agents 是 reverse query
|
||||
|
||||
后端没有 "runtime 服务的 agent 列表" API。Agent 持有 `runtime_id` 外键,但 Runtime 没有反向关联。
|
||||
|
||||
**前端处理**:从已缓存的 `agents` 列表 `filter(a => a.runtime_id === rtId)` 即可。这不算 N+1(agents 列表本来就要拉),是免费的 join。
|
||||
|
||||
### 4.3 WS 事件接线表
|
||||
|
||||
| WS 事件 | 触发的 invalidate |
|
||||
|---|---|
|
||||
| `agent:created` / `agent:archived` / `agent:updated` | `['ws', wsId, 'agents']` |
|
||||
| `agent:status` | `['ws', wsId, 'agents']`(即使我们不用这个值,缓存 fresh 仍要) |
|
||||
| `daemon:register` | `['ws', wsId, 'runtimes']` |
|
||||
| `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` | `['ws', wsId, 'active-tasks']` |
|
||||
| `task:progress` | (考虑节流,避免高频任务刷新缓存) |
|
||||
| `daemon:heartbeat` | **故意忽略** —— 后端发送但前端不订阅,避免每 15 秒过度 refetch。后果:runtime online → recently_lost 切换最坏延迟 75 秒(45s sweeper + 30s 间隔)。设计上接受这个延迟。 |
|
||||
|
||||
**关键不变量**:每个派生函数依赖的字段都必须有对应 WS 事件覆盖。任何字段没事件覆盖,状态会卡住。每次新增派生维度都要回头检查这张表。
|
||||
|
||||
### 4.4 桌面端 IPC 桥接
|
||||
|
||||
桌面端通过 Electron IPC 直接读本机 daemon。这份数据:
|
||||
- 比 server WS **快 75 秒**(IPC 亚秒,server sweep 最坏 75s)
|
||||
- 包含 server 看不到的 `starting` / `stopping` / `cli_not_found` 等中间态
|
||||
|
||||
**不修改派生函数签名**——桌面端把 IPC 数据用 `setQueryData` 直接写进 runtime cache:
|
||||
|
||||
```ts
|
||||
// 仅桌面端:apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
|
||||
window.electron.onDaemonStatus((status) => {
|
||||
queryClient.setQueryData(
|
||||
['ws', wsId, 'runtimes'],
|
||||
(old) => old?.map(rt =>
|
||||
rt.id === status.runtimeId ? mergeDaemonStatus(rt, status) : rt
|
||||
)
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
派生函数完全不知道数据从哪来——它只读 cache。**桌面端自动获得亚秒级体验,零派生函数改动**。
|
||||
|
||||
---
|
||||
|
||||
## 五、跨平台策略
|
||||
|
||||
### 5.1 状态系统是平台无关的
|
||||
|
||||
派生函数、5 态/4 态规范、UI 视觉——**两端共享同一套**。设计稿只画一份。
|
||||
|
||||
### 5.2 数据源平台相关
|
||||
|
||||
| 平台 | Runtime 数据来源 | 状态变化感知延迟 |
|
||||
|---|---|---|
|
||||
| Web | server WS / HTTP | 最坏 75 秒 |
|
||||
| Desktop(本机 daemon) | IPC + server | < 1 秒 |
|
||||
| Desktop(别人的 daemon) | server WS / HTTP | 跟 Web 一样 |
|
||||
|
||||
数据源不同不影响 UI——派生函数读 cache,cache 是"哪边更新就更新"。
|
||||
|
||||
### 5.3 操作能力平台相关
|
||||
|
||||
| 操作 | Web | Desktop(自己机器) | Desktop(别人机器) |
|
||||
|---|---|---|---|
|
||||
| 看状态 | ✅ | ✅ | ✅ |
|
||||
| 重启 daemon | ❌ | ✅ | ❌ |
|
||||
| 看 daemon logs | ❌ | ✅ | ❌ |
|
||||
| 看 CLI 安装详情 | ❌ | ✅ | ❌ |
|
||||
|
||||
实现方式:组件按 `isLocalDaemon && isOwner` 条件渲染按钮。**不需要写进派生函数 / 状态系统**,是 UI 局部决策。
|
||||
|
||||
### 5.4 Daemon 卡片视觉对齐
|
||||
|
||||
桌面端 settings 里的"本机 daemon 卡片"必须跟云端 runtime 列表项视觉一致。同一台机器同一个概念,不能两套设计。
|
||||
|
||||
---
|
||||
|
||||
## 六、设计语言
|
||||
|
||||
### 6.1 直接复用 Skills 界面(PR #1607、#1614、#1618、#1610)
|
||||
|
||||
Skills 界面已经在 2026-04 完成重设计,是当前产品的视觉锚点。Agents / Runtimes 直接照搬其规则:
|
||||
|
||||
1. **统一页头** `PageHeader`(h-12 + mobile sidebar trigger)
|
||||
2. **响应式网格列表**:`grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_minmax(0,6rem)_auto]`
|
||||
3. **每行三层信息**:主标题 → 描述(line-clamp-1 muted)→ 元数据(xs muted)
|
||||
4. **关联对象用头像堆栈**:最多 3 + `+N`,size=22 + `ring-2 ring-background` + `-space-x-1.5`
|
||||
5. **卡片化列表 + 卡片内工具栏**:搜索和 scope tab 在 `CardToolbar`(h-12),不在页面级
|
||||
6. **创建用多步 dialog**:chooser → 表单,可回退;宽度按方法切换,300ms 过渡
|
||||
7. **空状态分文案**:图标 + 标题 + 三行说明 + 清晰 CTA
|
||||
8. **长列表 `useScrollFade`**:上下边缘淡出
|
||||
9. **头像统一 `ActorAvatar`**:传 `size`,自动支持 agent / 人
|
||||
10. **权限检查 hook 化**:`useCanEdit...`,UI 提前隐藏/禁用
|
||||
|
||||
### 6.2 状态视觉的三个层级
|
||||
|
||||
每种派生状态在三个层级要保持一致:
|
||||
|
||||
- **Dot**(圆点):列表项、头像旁,最紧凑
|
||||
- **Badge**(徽章):详情页头部、卡片角落,带图标 + 文字
|
||||
- **Tooltip / Hover Card**:鼠标悬停展开完整信息
|
||||
|
||||
**跨界面一致性**:同一个 agent 无论出现在哪(agents 列表 / issue assignee picker / autopilot 编辑 / chat 选择面板 / 评论 @),状态视觉必须完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 七、实施分阶段
|
||||
|
||||
### 阶段 0 — 数据层地基(无 UI 改动)
|
||||
|
||||
**目标**:派生函数 + cache + WS + IPC 桥接全部就位。UI 暂不动。完成后所有 UI 阶段都能放心假设"派生状态可用、零额外请求"。
|
||||
|
||||
#### 后端工作(Go)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `server/pkg/db/queries/agent_task.sql` | 新增 sqlc query:`ListActiveTasksByWorkspace`,SELECT 所有字段含 `failure_reason`,过滤 status ∈ {queued, dispatched, running} |
|
||||
| `server/pkg/db/agent_task.sql.go` | `make sqlc` 自动生成 |
|
||||
| `server/internal/handler/agent.go`(或新建 task handler) | `ListActiveTasksByWorkspace` handler,权限校验:用户必须是 workspace member |
|
||||
| `server/cmd/server/router.go` | 注册路由 `GET /api/workspaces/{slug}/active-tasks` |
|
||||
| **核查**:现有 `ListAgentTasks` query | 确认 SELECT `failure_reason` 字段;如未 SELECT,补上 |
|
||||
|
||||
#### 前端类型补全
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/types/agent.ts` | `AgentTask` 接口加 `failure_reason: TaskFailureReason \| null`;新增 `TaskFailureReason = "agent_error" \| "timeout" \| "runtime_offline" \| "runtime_recovery" \| "manual"` |
|
||||
|
||||
#### 前端 API client
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/api/client.ts` | 新增方法 `getActiveTasksForWorkspace(wsSlug): Promise<AgentTask[]>` |
|
||||
|
||||
#### 前端派生函数 + 类型
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `packages/core/agents/types.ts`(如不存在则新建) | `AgentPresence` / `AgentPresenceDetail` / `RuntimeHealth` 等类型 |
|
||||
| `packages/core/agents/derive-presence.ts` | `deriveAgentPresence` / `deriveAgentPresenceDetail` 纯函数 |
|
||||
| `packages/core/agents/derive-presence.test.ts` | 5 态全分支 + 边界 case(runtime null / tasks 空 / 时钟边界) |
|
||||
| `packages/core/runtimes/derive-health.ts` | `deriveRuntimeHealth` |
|
||||
| `packages/core/runtimes/derive-health.test.ts` | 4 态全分支 |
|
||||
|
||||
#### 前端 query + hook
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `packages/core/agents/active-tasks-query.ts` | `activeTasksOptions(wsId)` query options |
|
||||
| `packages/core/agents/use-agent-presence.ts` | `useAgentPresence(agentId)` hook:读 3 份 cache → 派生 |
|
||||
| `packages/core/runtimes/use-runtime-health.ts` | `useRuntimeHealth(runtimeId)` hook |
|
||||
| `packages/core/runtimes/use-runtime-agents.ts` | `useRuntimeAgents(runtimeId)` hook:从 agents cache filter 出绑定的 agents |
|
||||
|
||||
#### 前端 WS 接线
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/realtime/agent-runtime-sync.ts` | 新增专用 sync。订阅 `agent:*` / `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` / `daemon:register` → invalidate 对应 query。**显式不订阅 `daemon:heartbeat`**(接受 75 秒延迟) |
|
||||
| `packages/core/realtime/use-realtime-sync.ts`(如已有全局 hook) | 集成新 sync |
|
||||
|
||||
#### 桌面端 IPC 桥接
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts` | 监听 `window.daemonAPI.onStatus(...)`,用 `queryClient.setQueryData` 把本机 daemon status merge 进对应 runtime cache |
|
||||
|
||||
#### 完成标准
|
||||
|
||||
- [ ] 后端 `GET /api/workspaces/:slug/active-tasks` 通 curl 测试,返回 active tasks 列表
|
||||
- [ ] `deriveAgentPresence` / `deriveRuntimeHealth` 单测全部通过
|
||||
- [ ] 控制台调用 `useActiveTasks(wsId)` 能拿到全工作区活跃任务
|
||||
- [ ] 控制台调用 `useAgentPresence(agentId)` 能拿到正确的 5 态状态
|
||||
- [ ] WS 接线表里所有事件都能正确 invalidate(手测覆盖)
|
||||
- [ ] 关本机 daemon 后桌面端 runtime cache **1 秒内**变 offline
|
||||
- [ ] 不动任何 UI 文件——这阶段 zero UI delta
|
||||
|
||||
### 阶段 1 — Agents + Runtimes 列表页
|
||||
|
||||
**目标**:两个列表用上派生状态,互相能看到对方。
|
||||
|
||||
#### 设计师产出(先于代码)
|
||||
|
||||
- 5 态 dot / badge / tooltip 三层视觉规范
|
||||
- Working + 排队角标的复合视觉
|
||||
- Failed 状态的 2 分钟时间窗口动效
|
||||
- Runtime 4 态的视觉差异(不能再是同一个浅灰圆点)
|
||||
|
||||
#### 改造文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/views/agents/components/agent-list-item.tsx` | 替换 `statusConfig[agent.status]` 为 `useAgentPresence(agentId)` |
|
||||
| `packages/views/agents/components/agents-page.tsx` | 接 WS 订阅 |
|
||||
| `packages/views/agents/config.ts` | 删除 `statusConfig`,新建 `presenceConfig` |
|
||||
| `packages/views/runtimes/components/runtime-list.tsx` | 用派生 4 态;展示 last_seen、关联 agent 数、当前任务数 |
|
||||
| `packages/views/runtimes/components/runtimes-page.tsx` | 接 WS 订阅 |
|
||||
| `apps/desktop/.../local-daemon-card.tsx` | 视觉对齐云端 runtime 卡片 |
|
||||
|
||||
### 阶段 2 — Agents + Runtimes 详情页
|
||||
|
||||
**目标**:详情页头部、profile card 状态联动;Runtime token usage 信息架构整理。
|
||||
|
||||
#### 改造文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/views/agents/components/agent-detail.tsx` | 头部 status badge 用派生 |
|
||||
| `packages/views/agents/components/agent-profile-card.tsx` | 状态行和 runtime 行联动;展示当前任务数 + 最近失败原因 |
|
||||
| `packages/views/runtimes/components/runtime-detail.tsx` | Token usage 主次重排:核心指标置顶,5 个图表折叠 / 下沉 |
|
||||
| `packages/views/runtimes/components/usage-section.tsx` | API 调用按时间窗口拉(不再总是 90 天) |
|
||||
|
||||
### 阶段 3 — Issue Detail 任务展示
|
||||
|
||||
**目标**:多 agent 全景视图;任务阶段化;失败原因显式。
|
||||
|
||||
#### 新增文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `packages/core/agents/derive-task-stage.ts` | `deriveTaskStage` |
|
||||
| `packages/core/agents/derive-task-stage.test.ts` | 单测 |
|
||||
| `packages/views/issues/components/agent-task-row.tsx` | 单 agent 单任务一行 |
|
||||
|
||||
#### 改造文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/views/issues/components/agent-live-card.tsx` | 从"sticky 一个 + 折叠列表"改为"每个 agent 一行" |
|
||||
| `packages/views/issues/components/agent-transcript-dialog.tsx` | 失败时展示 failure_reason |
|
||||
|
||||
### 阶段 4 — 跨界面 Hover Card
|
||||
|
||||
**目标**:所有 agent 头像出现的位置都用统一的 hover card。
|
||||
|
||||
#### Hover Card 必须显示的内容(按重要度排序)
|
||||
|
||||
1. 派生 5 态状态
|
||||
2. Runtime 健康(在线性 + last_seen 相对时间)
|
||||
3. 当前任务(N running / M queued)
|
||||
4. 最近失败(如果有):原因 + 时间
|
||||
5. Agent 名称 + description
|
||||
6. 关联 skills(前 3 个 + `+N`)
|
||||
7. Owner
|
||||
|
||||
#### 必须接入的位置
|
||||
|
||||
| 位置 | 当前状态 |
|
||||
|---|---|
|
||||
| Agents 列表 / 详情 | ✅ 已有 |
|
||||
| Issue Assignee Picker | ❌ 仅头像无状态 |
|
||||
| Issue Detail 头部 assignee | ❌ 仅头像无状态 |
|
||||
| Issue 列表 / 看板的分配头像 | ❌ 仅头像 |
|
||||
| Autopilot 列表 / 编辑(assignee) | ❌ 仅头像 |
|
||||
| Project lead picker | ❌ 仅头像 |
|
||||
| Chat 选择 agent 面板 | ❌ 待确认 |
|
||||
| 评论里的 @agent | ❌ 仅头像 |
|
||||
|
||||
#### 实施
|
||||
|
||||
`ActorAvatar` 组件挂载 hover card——一处改动,上面所有位置自动获得统一卡片。
|
||||
|
||||
**N+1 风险已经被阶段 0 的"全工作区 active-tasks"消除**——hover card 只读 cache,零额外请求。
|
||||
|
||||
### 阶段 5 — Chat 状态分阶段(独立 PR)
|
||||
|
||||
工作量较大,跟流式渲染相关,单独排期。
|
||||
|
||||
#### 改造文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/views/chat/components/chat-message-list.tsx` | `AssistantMessage` 用 `deriveTaskStage` 替代单 spinner |
|
||||
| `packages/views/chat/components/chat-page.tsx` | WS 断线重连后的消息回拉 fallback |
|
||||
|
||||
#### 必须解决
|
||||
|
||||
- 取代单 spinner,按阶段显示
|
||||
- Failed task 显示原因
|
||||
- WS 断线重连后能拉回历史消息
|
||||
|
||||
#### 加分项
|
||||
|
||||
- Typing indicator(generating 阶段的逐字感)
|
||||
- 全局任务进度 FAB
|
||||
- Stop 按钮的明确反馈
|
||||
|
||||
---
|
||||
|
||||
## 八、验收标准
|
||||
|
||||
### 阶段 0
|
||||
|
||||
- [ ] `deriveAgentPresence` / `deriveRuntimeHealth` 单测覆盖所有分支 + 边界 case(runtime null / tasks 空 / 时钟边界)
|
||||
- [ ] 控制台调用 `useActiveTasks(wsId)` 能拿到数据
|
||||
- [ ] WS 事件接线表里每个事件都能正确 invalidate(手测)
|
||||
- [ ] 桌面端关本机 daemon 后 runtime cache 在 1s 内变 offline
|
||||
|
||||
### 阶段 1
|
||||
|
||||
- [ ] Daemon 关闭后,Agent 列表项 75 秒内变成 Offline 灰点
|
||||
- [ ] Agent 跑任务时,列表项变成 Working 蓝点;排队 N 个时带 `+N` 角标
|
||||
- [ ] Agent 任务失败后,列表项 2 分钟内显示 Failed 红点 + tooltip 含失败原因,2 分钟后自动恢复
|
||||
- [ ] Runtime 列表能区分 Online / Recently Lost / Offline / About to GC 四态
|
||||
- [ ] Runtime 列表行展示关联 agent 数 + 当前任务数
|
||||
- [ ] 桌面端本机 daemon 卡片视觉跟云端 runtime 列表项一致
|
||||
- [ ] 全局 grep `agent.status` 在 views 层无引用
|
||||
|
||||
### 阶段 2
|
||||
|
||||
- [ ] Agent 详情头部状态跟列表一致
|
||||
- [ ] Profile card 状态行 + runtime 行不再自相矛盾
|
||||
- [ ] Runtime 详情页能在不展开图表的前提下看到本期成本
|
||||
- [ ] Token usage API 按选中的时间窗口拉(不再总是 90 天)
|
||||
|
||||
### 阶段 3
|
||||
|
||||
- [ ] 同一 issue 多 agent 工作时,每个 agent 一行实时状态
|
||||
- [ ] queued / dispatched / running 三态视觉差异清晰
|
||||
- [ ] 任务失败时,行内显示中文 failure reason + 处理建议
|
||||
- [ ] 不支持 live log 的 provider 显式说明"等任务完成后查看结果"
|
||||
|
||||
### 阶段 4
|
||||
|
||||
- [ ] 所有展示 agent 头像的位置 hover 都能看到完整状态卡
|
||||
- [ ] 渲染 30+ agent 头像的页面,hover 不触发任何新 HTTP 请求
|
||||
|
||||
### 阶段 5
|
||||
|
||||
- [ ] Chat 中能看到任务在哪个阶段
|
||||
- [ ] WS 断线后重连能补齐历史消息
|
||||
- [ ] Failed task 显示原因
|
||||
|
||||
---
|
||||
|
||||
## 九、边界与不做的事
|
||||
|
||||
明确**不在本次范围内**:
|
||||
|
||||
1. **不改后端 schema**——`agent.status` / `blocked` / `error` 字段保留(迁移风险大,无收益)
|
||||
2. **不让后端做派生**——所有派生在前端做(迭代速度 + UI 解耦)
|
||||
3. **不新增 WS 事件类型**——只订阅现有但未用的(如 `agent:status`)
|
||||
4. **不动 `agent_runtime.status` 的 online/offline 二态**——前端从这两态 + `last_seen_at` 派生四态
|
||||
5. **不重构 Skills 界面**——它已经是参考样板
|
||||
6. **Phase 5 不做"逐字流式渲染"**——后端 stream-json 是批量推送,typing indicator 是视觉技巧
|
||||
7. **不做"agent 健康综合评分"**——只暴露原始信号,不算综合分
|
||||
8. **不为 CLI 未安装等场景引入第六态**——归到 offline 的 tooltip 子分类
|
||||
9. **不在 Zustand 里存派生结果**——server data 不能复制进 store
|
||||
|
||||
---
|
||||
|
||||
## 十、风险与注意事项
|
||||
|
||||
### 10.1 WS 事件覆盖完整性
|
||||
|
||||
派生函数的"实时性"靠的是依赖 query 都被 WS 正确 invalidate。一个事件没接好,状态就会卡住。
|
||||
|
||||
**缓解**:阶段 0 的接线表是必须维护的"契约"。每次新增派生维度,回头检查这张表,确保新依赖的字段有事件覆盖。
|
||||
|
||||
### 10.2 时钟一致性
|
||||
|
||||
Failed 状态依赖客户端时间和 `completed_at` 的差。客户端时钟漂移会让 2 分钟窗口不准。
|
||||
|
||||
**缓解**:2 分钟 ± 30 秒不影响判断,可接受。需要时可用 server 在 WS 心跳里携带的时间作为参考。
|
||||
|
||||
### 10.3 旧字段引用残留
|
||||
|
||||
`statusConfig` 删除后,遗漏的引用会运行时错误。
|
||||
|
||||
**缓解**:
|
||||
- TypeScript 严格模式 + 改类型
|
||||
- 全局 grep 验证
|
||||
- 长期防御:在 `packages/core/agents/types.ts` 把 `agent.status` 从外部消费的 Agent 类型里 omit 掉,仅保留在 RawAgent 内部类型里给 API 层用
|
||||
|
||||
### 10.4 跨界面状态一致性
|
||||
|
||||
不同地方调用同一个 agent 的派生函数,必须结果一致。
|
||||
|
||||
**缓解**:所有调用方走 `useAgentPresence(agentId)` 这个唯一 hook,不允许直接调派生函数。Hook 集中管理输入数据收集。
|
||||
|
||||
### 10.5 active-tasks 数据量
|
||||
|
||||
如果工作区某天有 1000+ 活跃任务,全工作区一次拉的设计会受影响。
|
||||
|
||||
**缓解**:
|
||||
- 当前活跃任务有天然上限(受 `max_concurrent_tasks` × agent 数量约束)
|
||||
- 监控加上:cache 大小超过阈值时上报
|
||||
- 必要时再考虑 windowing(最近 N 个)或 server 端聚合
|
||||
|
||||
---
|
||||
|
||||
## 十一、参考
|
||||
|
||||
- [产品全景文档](./product-overview.md) —— Agent / Runtime / Daemon 的产品定位
|
||||
- [Skills 界面源代码](../packages/views/skills/) —— 设计语言样板
|
||||
- 关键 PR:#1607(Skills 重设计)、#1614(Card + PageHeader)、#1618(描述恢复)、#1610(Dialog 闪烁修复)
|
||||
- 后端关键代码:
|
||||
- `server/internal/service/task.go` —— `agent.status` 更新逻辑(`ReconcileAgentStatus`)
|
||||
- `server/cmd/server/runtime_sweeper.go` —— Runtime 心跳 / sweeper 时间常量
|
||||
- `server/migrations/055_task_lease_and_retry.up.sql` —— Task `failure_reason` 五态
|
||||
- `server/migrations/037_fix_pending_task_unique_index.up.sql` —— 一个 issue 多 agent 处理的设计依据
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:当前现状清单(保留作为重设计前的存档)
|
||||
|
||||
> 这部分原本在 design-brief.md 里,迁移过来作为重设计前的现状记录。设计师在画稿前可以贴上当前界面截图作为对照。
|
||||
|
||||
### A.1 Agent 字段可见性
|
||||
|
||||
| 字段 | 当前状态 | 备注 |
|
||||
|---|---|---|
|
||||
| `name` / `avatar_url` / `description` | ✅ | 列表 + 详情都展示 |
|
||||
| `archived_at` | ✅ | 列表项灰显 + 详情头部 banner |
|
||||
| `status`(idle/working/...) | ✅ 但语义错误 | **要替换成派生 5 态** |
|
||||
| `runtime_mode`(local/cloud) | ✅ 图标 | 列表项右侧 Cloud / Monitor 图标 |
|
||||
| `instructions` | ✅ | Instructions tab |
|
||||
| `custom_env` / `custom_env_redacted` | ✅ | Env tab |
|
||||
| `custom_args` | ✅ | Custom Args tab |
|
||||
| `visibility`(workspace / private) | ✅ | Settings tab |
|
||||
| `max_concurrent_tasks` | ✅ | Settings tab |
|
||||
| `model` | ✅ | Settings tab |
|
||||
| `runtime_id` 关联 | ✅ | Settings tab |
|
||||
| `skills` | ✅ | Skills tab + profile card 前 3 个 |
|
||||
| `owner_id` | ✅ | Profile card |
|
||||
| `created_at` / `updated_at` | 🆕 | 后端有,UI 完全没展示 |
|
||||
| `archived_by` | 🆕 | 后端有,UI 隐藏 |
|
||||
|
||||
### A.2 Runtime 字段可见性
|
||||
|
||||
| 字段 | 当前状态 | 备注 |
|
||||
|---|---|---|
|
||||
| `name` | ✅ | 列表 + 详情头部 |
|
||||
| `provider` | ✅ Logo | 9 种 |
|
||||
| `runtime_mode` | ✅ 文字 | `RuntimeModeIcon` 组件存在但从未被调用 |
|
||||
| `status`(online/offline) | ✅ 圆点 + 徽章 | 离线圆点浅色主题下几乎不可见 |
|
||||
| `last_seen_at` | ✅ 仅详情页 | **列表完全看不到** |
|
||||
| `device_info` | ✅ 详情页 | 没有人类可读化 |
|
||||
| `daemon_id` | ✅ mono 字体 | 不可复制 |
|
||||
| `metadata.cli_version` | ✅ | CLI 更新部分 |
|
||||
| `metadata.launched_by` | ✅ | "Managed by Desktop" |
|
||||
| `owner_id` | ✅ | 头像 + 名字 |
|
||||
| `created_at` / `updated_at` | ✅ | ISO 时间戳 |
|
||||
|
||||
### A.3 桌面端 IPC 数据
|
||||
|
||||
```
|
||||
DaemonStatus (本地 IPC)
|
||||
├─ state: running / stopped / starting / stopping / installing_cli / cli_not_found
|
||||
├─ pid, uptime
|
||||
├─ daemonId, deviceName, serverUrl
|
||||
├─ agents: 当前运行的 agent IDs
|
||||
├─ workspaceCount
|
||||
└─ profile
|
||||
```
|
||||
|
||||
### A.4 截图占位区
|
||||
|
||||
设计师拿到这份文档后,请把以下界面的当前截图贴在对应位置:
|
||||
|
||||
- **Agents 界面**:列表页 / 详情页(每 tab 一张)/ 创建对话框
|
||||
- **Runtimes 界面**:列表页 / 详情页(含 5 个图表)/ 桌面端 daemon 卡片
|
||||
- **Issue Detail**:无任务执行时 / 单 agent 执行中 / 多 agent 并发 / 全屏 transcript dialog
|
||||
|
||||
### A.5 Token Usage 5 个图表的数据细节
|
||||
|
||||
Runtime 详情页底部 token usage 部分,5 个图表 + 1 张表格全部展开,没有主次。阶段 2 改造时按下表理解每个图表的数据契约:
|
||||
|
||||
| 图表 | 数据源 | 时间粒度 | 维度 | 度量 |
|
||||
|---|---|---|---|---|
|
||||
| **Activity Heatmap** | `getRuntimeUsage?days=90` | 日 | date | 4 级强度,按 token 总量百分位分级 |
|
||||
| **Hourly Activity** | `getRuntimeTaskActivity` | 小时(0-23) | hour | 任务数 |
|
||||
| **Daily Token Chart** | 同 Heatmap,客户端聚合 | 日 | date | input/output/cacheRead/cacheWrite 总和 |
|
||||
| **Daily Cost Chart** | 同上 + 客户端定价计算 | 日 | date | 美元成本,按 model pricing 表 |
|
||||
| **Model Distribution** | 同上聚合 by model | 全周期 | model | tokens 占比 + cost |
|
||||
|
||||
**当前实现的问题**:API 总是取 90 天数据,客户端做 7d/30d 过滤——浪费服务端资源,首次加载慢。改造时按选中窗口拉。
|
||||
|
||||
### A.6 Issue Detail 任务展示的当前实现
|
||||
|
||||
主要组件:`packages/views/issues/components/agent-live-card.tsx`
|
||||
|
||||
#### 已有能力
|
||||
|
||||
- 多 agent 并发执行同一 issue 时,第一个卡片是 sticky(顶部固定),其他在下方滚动
|
||||
- 每个 task 卡片可展开 timeline,显示 tool_use / tool_result / thinking / text / error 五种消息类型
|
||||
- 实时滚动:WS 事件 `task:message` 到达时追加 timeline
|
||||
- 工具调用计数 badge
|
||||
- Stop 按钮可取消任务
|
||||
- 全屏 transcript dialog(支持事件类型筛选 + 复制)
|
||||
- 已完成/失败/取消的任务进入 TaskRunHistory 折叠区
|
||||
|
||||
#### 数据来源
|
||||
|
||||
| 数据 | API |
|
||||
|---|---|
|
||||
| 当前 issue 的活跃 task 列表 | `getActiveTasksForIssue(issueId)` |
|
||||
| 每个 task 的历史消息 | `listTaskMessages(taskId)` |
|
||||
| 实时消息流 | WS `task:message` |
|
||||
| 状态变化 | WS `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` |
|
||||
| 取消任务 | `cancelTask(issueId, taskId)` |
|
||||
|
||||
阶段 3 在此基础上重构成"每个 agent 一行的全景视图"。
|
||||
@@ -1,530 +0,0 @@
|
||||
# Agents & Runtimes 界面重设计 · 设计师 Brief
|
||||
|
||||
> **文档定位**:这份文档专门给负责重设计 Agents 和 Runtimes 界面的 UI/UX 设计师。
|
||||
>
|
||||
> 看完这份文档,你应该能:
|
||||
> - 理解我们在解决什么问题、目标体感是什么
|
||||
> - 知道每个界面有哪些数据可用、哪些是新展示的、哪些需要工程补
|
||||
> - 知道有哪些现有交互不能动(避免破坏用户已建立的习惯)
|
||||
> - 知道可以参考哪些已经做完的设计(Skills 是样板)
|
||||
> - 直接进入设计稿环节
|
||||
>
|
||||
> **必读伴侣文档**:[Agent / Runtime 状态系统重设计](./agent-runtime-status-redesign.md) —— 完整的工程方案、状态规范、实施阶段。本 brief 是它的"设计师视角切片"。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [一句话目标](#一一句话目标)
|
||||
2. [必读:状态视觉规范](#二必读状态视觉规范)
|
||||
3. [Agents 界面](#三agents-界面)
|
||||
4. [Runtimes 界面](#四runtimes-界面)
|
||||
5. [跨界面统一:Agent Hover Card](#五跨界面统一agent-hover-card)
|
||||
6. [跨平台差异处理](#六跨平台差异处理)
|
||||
7. [设计语言参考:Skills 界面](#七设计语言参考skills-界面)
|
||||
8. [工程会同步交付的能力](#八工程会同步交付的能力)
|
||||
9. [设计师产出清单](#九设计师产出清单)
|
||||
10. [附录:截图占位区](#十附录截图占位区)
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话目标
|
||||
|
||||
**当前所有界面都在直接展示后端字段,缺少"用户视角"的状态翻译层。**
|
||||
|
||||
用户看到 `Idle` / `Online` / spinner,但这些词没有回答"agent 现在能不能用 / 在做什么 / 出问题了没"。我们要做的是一套**用户视角的状态系统**:让用户一眼就知道一个 agent / runtime 的健康状况,跨界面一致。
|
||||
|
||||
---
|
||||
|
||||
## 二、必读:状态视觉规范
|
||||
|
||||
这套规范是所有界面的共同基础。**先输出这套规范,再画具体界面**。
|
||||
|
||||
### 2.1 Agent 五态
|
||||
|
||||
| 状态 | 颜色 | 用户语义 | 出现条件 |
|
||||
|---|---|---|---|
|
||||
| **Available** | 🟢 绿 | 在线空闲,可以接活 | runtime 在线 + 没有活跃任务 |
|
||||
| **Working** | 🔵 蓝(品牌色) | 正在干活 | runtime 在线 + 至少一个任务在执行 |
|
||||
| **Pending** | 🟡 黄 | 任务排着但没在跑 | runtime 在线 + 0 个执行中 + ≥1 个排队 |
|
||||
| **Failed** | 🔴 红 | 最近一次失败 | 最近 2 分钟内有任务失败 |
|
||||
| **Offline** | ⚫ 灰 | Daemon 离线,不可用 | runtime 离线 |
|
||||
|
||||
**复合维度**:当 agent 是 Working 但同时有任务排队,主状态保持 Working,旁边带 `+N` 角标("还有 N 个排队")。
|
||||
|
||||
**Failed 状态特别说明**:失败显示**保持 2 分钟**,之后自动恢复(避免红点黏太久)。设计上要表达"这是临时强提示"。
|
||||
|
||||
### 2.2 Runtime 四态
|
||||
|
||||
| 状态 | 触发条件 | 用户语义 |
|
||||
|---|---|---|
|
||||
| **Online** | 最近 45 秒内有心跳 | 健康 |
|
||||
| **Recently Lost** | 离线但 < 5 分钟 | 可能短暂网络抖动 |
|
||||
| **Offline** | 离线 5 分钟 ~ 7 天 | 长期离线,需排查 |
|
||||
| **About to GC** | 离线接近 7 天阈值 | 系统将自动清理 |
|
||||
|
||||
> **关于 CLI 未安装等"runtime 在线但跑不了"的场景**:归并到 Offline,tooltip 写明具体原因("CLI 未安装"、"Daemon 启动中")。**不要为这些子情况新设状态色**,色彩枚举太多反而失去信号。
|
||||
|
||||
### 2.3 视觉表达分三层
|
||||
|
||||
每种状态在以下三个层级要保持一致:
|
||||
|
||||
- **Dot**(圆点):列表项、头像旁的小圆点,最紧凑场景
|
||||
- **Badge**(徽章):详情页头部、卡片角落,带图标 + 文字
|
||||
- **Tooltip / Hover Card**:鼠标悬停时展开完整信息
|
||||
|
||||
**跨界面一致性**:同一个 agent,无论出现在哪(agents 列表 / issue assignee picker / autopilot 编辑 / chat 选择面板 / 评论 @),状态视觉**必须完全一致**。这是这次设计能立住的关键。
|
||||
|
||||
---
|
||||
|
||||
## 三、Agents 界面
|
||||
|
||||
### 3.1 当前界面长什么样
|
||||
|
||||
主要文件:
|
||||
- `packages/views/agents/components/agents-page.tsx` — 列表页容器
|
||||
- `packages/views/agents/components/agent-list-item.tsx` — 列表项
|
||||
- `packages/views/agents/components/agent-detail.tsx` — 详情页(含 tabs:Instructions / Skills / Tasks / Environment / Custom Args / Settings)
|
||||
- `packages/views/agents/components/agent-profile-card.tsx` — 详情顶部的 profile 卡片
|
||||
- `packages/views/agents/components/create-agent-dialog.tsx` — 创建对话框
|
||||
|
||||
**当前的视觉语言跟 Skills 重设计前一样——这次目标是迁到 Skills 风格。**
|
||||
|
||||
### 3.2 当前的核心痛点
|
||||
|
||||
按用户感知严重度排列:
|
||||
|
||||
1. **列表上的 "Idle" 绿点会骗人**——daemon 已经死了,agent 仍然显示 Idle。用户分配任务后没有任何反馈。**根因**:`agent-list-item.tsx` 完全忽略 runtime 在线性,只读 `agent.status` 这个后端字段。
|
||||
2. **`agent-profile-card` 状态行和 runtime 行不联动**——状态显 Idle,runtime 显 Offline,自相矛盾。
|
||||
3. **跨界面状态展示不一致**——issue assignee picker、autopilot picker、chat 选择 agent 时**完全不显示状态**,用户做选择时不知道哪个能用。
|
||||
4. **创建 agent 时无法预知能否立即跑起来**——dialog 显示了 runtime 在线点,但不知道 model 是否合法、初始化会不会失败。
|
||||
5. **Archived agent 和 active agent 混在同一列表**——切换视图全靠手动按钮,不够清晰。
|
||||
6. **零 WS 订阅**——离线/上线必须手动刷新页面才能感知。
|
||||
|
||||
### 3.3 重设计目标
|
||||
|
||||
#### 必须达成
|
||||
|
||||
- 列表项一眼能看出 agent 是否真的可用(融合 runtime 在线性的派生 5 态)
|
||||
- Hover card 内必须看到:派生状态、当前任务数、runtime 健康、最近失败原因
|
||||
- 跨界面一致:所有显示 agent 头像的位置都用同一个 hover card(详见第 5 节)
|
||||
- 创建/编辑 agent 时,能预看"如果保存,agent 会变成什么状态"
|
||||
- 实时更新——状态变化 75 秒内反映到 UI
|
||||
|
||||
#### 加分项
|
||||
|
||||
- Archived agent 独立 tab/折叠区,不再和 active 混在一起
|
||||
- 列表支持按"最近活动时间"排序
|
||||
- 支持按状态筛选("显示所有 Failed"、"显示所有 Pending")
|
||||
|
||||
### 3.4 可用数据清单(设计稿可以放心假设可用)
|
||||
|
||||
> **图例**:✅ = 当前已展示;🆕 = 已可用但当前 UI 没用;🔧 = 工程会在阶段 0 补上;📡 = 实时事件可用
|
||||
|
||||
#### Agent 主体字段(来自 `Agent` type)
|
||||
|
||||
| 字段 | 类型 | 当前状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `name` / `avatar_url` / `description` | string | ✅ | 已展示 |
|
||||
| `archived_at` / `archived_by` | string | ✅ / 🆕 | 列表灰显;archived_by 后端有但 UI 隐藏 |
|
||||
| `runtime_mode` | "local" / "cloud" | ✅ 图标 | Cloud / Monitor 图标区分 |
|
||||
| `instructions` | string | ✅ | Instructions tab |
|
||||
| `custom_env` / `custom_env_redacted` | KV / bool | ✅ | Env tab,权限控制隐藏值 |
|
||||
| `custom_args` | string[] | ✅ | Custom Args tab |
|
||||
| `visibility` | "workspace" / "private" | ✅ | Settings tab |
|
||||
| `max_concurrent_tasks` | number | ✅ | Settings tab,默认 6 |
|
||||
| `model` | string | ✅ | Settings tab |
|
||||
| `runtime_id` | string | ✅ 选择器 | Settings tab |
|
||||
| `skills` | Skill[] | ✅ | Skills tab + profile card 前 3 个 |
|
||||
| `owner_id` | string | ✅ | Profile card 显示名字 |
|
||||
| `created_at` / `updated_at` | string | 🆕 | **后端有,UI 完全没展示** —— 可以做"最近创建"、"最近修改"标签 |
|
||||
| ~~`status`~~(idle/working/blocked/error/offline) | enum | ✅ 但**已废弃**展示 | 后端字段保留但 UI 完全不读 |
|
||||
|
||||
#### 派生数据(工程在阶段 0 提供,设计稿可放心引用)
|
||||
|
||||
| 派生信息 | 来源 |
|
||||
|---|---|
|
||||
| **Agent 派生 5 态状态** | 🔧 由 agent + runtime + active tasks 派生 |
|
||||
| **当前 running 任务数** | 🔧 派生 |
|
||||
| **当前 queued 任务数** | 🔧 派生 |
|
||||
| **最近一次失败的原因**(5 种 enum) | 🔧 派生 |
|
||||
| **关联 runtime 健康状态** | 🔧 派生 |
|
||||
| **runtime last_seen 相对时间** | 🔧 已有工具函数 |
|
||||
|
||||
**失败原因 5 个枚举(中文文案待你定)**:
|
||||
- `agent_error` — Agent 执行报错
|
||||
- `timeout` — 执行超时
|
||||
- `runtime_offline` — Daemon 离线
|
||||
- `runtime_recovery` — Daemon 重启回收
|
||||
- `manual` — 用户取消
|
||||
|
||||
每种原因用户的处理方式不同,UI 应给出对应建议(你来设计文案)。
|
||||
|
||||
#### 实时事件(WS)
|
||||
|
||||
| 事件 | 状态 | 用途 |
|
||||
|---|---|---|
|
||||
| `agent:status` | 📡 后端发,工程接 | agent 字段变化 |
|
||||
| `agent:created` / `agent:archived` / `agent:restored` | 📡 工程接 | 列表增删 |
|
||||
| `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` | 📡 工程接 | 状态派生关键信号 |
|
||||
| `daemon:register` | 📡 工程接 | runtime 上下线 |
|
||||
|
||||
设计上**不需要为"加载/loading"过度设计**——状态变化是实时的,几乎不会出现"等数据"的 loading 态。
|
||||
|
||||
### 3.5 不能动的现有交互(必须保留)
|
||||
|
||||
- 创建 agent dialog 的 chooser → 表单两步流(见 Skills 的 `create-skill-dialog`)
|
||||
- 各 tab 的编辑能力:Instructions / Env / Custom Args / Settings 全部可编辑
|
||||
- Archive / Restore 操作
|
||||
- Tasks tab:展示该 agent 的历史 task 列表(按状态分组:活跃 → 完成)
|
||||
- Skills tab:可挂载/卸载 skills
|
||||
|
||||
### 3.6 关键问题留给你定的
|
||||
|
||||
1. **Archived agent 怎么收纳**:独立 tab、折叠区、还是 segment 切换?
|
||||
2. **状态筛选**是 chips 还是 dropdown?
|
||||
3. **Failed 红点的 2 分钟动效**:脉冲?颜色渐变?
|
||||
4. **复合状态(Working + 排队 N)**:角标位置(dot 旁 / 头像下角 / Badge 内嵌)?
|
||||
5. **创建 dialog 的"预览状态"**:怎么不打扰主流程的同时让用户知道"这个 agent 创建出来会是什么色"?
|
||||
|
||||
---
|
||||
|
||||
## 四、Runtimes 界面
|
||||
|
||||
### 4.1 当前界面长什么样
|
||||
|
||||
主要文件:
|
||||
- `packages/views/runtimes/components/runtimes-page.tsx` — 容器,含 owner filter(mine/all)
|
||||
- `packages/views/runtimes/components/runtime-list.tsx` — 列表
|
||||
- `packages/views/runtimes/components/runtime-detail.tsx` — 详情头部
|
||||
- `packages/views/runtimes/components/usage-section.tsx` — Token usage 主区
|
||||
- `packages/views/runtimes/components/charts/` — 5 个图表组件
|
||||
- `packages/views/runtimes/components/update-section.tsx` — CLI 更新流程
|
||||
- `apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx` — 桌面端独有:本机 daemon 卡片(通过 IPC,独立于 server runtime 列表)
|
||||
|
||||
### 4.2 当前的核心痛点
|
||||
|
||||
按严重度排列:
|
||||
|
||||
1. **离线圆点几乎不可见**——浅色主题下 `bg-muted-foreground/40` 视觉上消失。
|
||||
2. **列表无 last_seen**——无法区分"刚断线 5 分钟"和"3 个月前断线"。
|
||||
3. **看不到 runtime 服务了哪些 agent / 当前有几个 task 在跑**——runtime 在用户心智里成了"孤岛"。
|
||||
4. **7 天 GC 阈值无任何 UI 提示**——runtime 突然消失,用户不知道为什么。
|
||||
5. **桌面端 daemon 卡片和云端 runtime 卡片视觉分裂**——同一台机器同一概念,两套设计。
|
||||
6. **Token usage 信息过载**——5 个图表 + 1 张表全部展开,普通用户找不到"本月花了多少钱"。
|
||||
7. **无 ping / 诊断按钮**——遇到断线没法主动验证。
|
||||
8. **`RuntimeModeIcon` 死代码**——本地 vs 云端在列表项里没有图标区分。
|
||||
|
||||
### 4.3 重设计目标
|
||||
|
||||
#### 必须达成
|
||||
|
||||
- 列表项一眼区分四态:Online / Recently Lost / Offline / About to GC
|
||||
- 列表项展示**关联 agent 数量** + **当前任务数**(runtime 不再是孤岛)
|
||||
- 桌面端 daemon 卡片和云端 runtime 卡片用统一视觉语言
|
||||
- Token usage 区分主次:核心指标(本期成本、token 总量)放顶部,详细图表折叠或下沉
|
||||
|
||||
#### 加分项
|
||||
|
||||
- Runtime 健康综合评分(在线 + 心跳新鲜度 + 任务负载)
|
||||
- 7 天 GC 倒计时提示
|
||||
- Ping / 诊断按钮
|
||||
- 高使用量 runtime 的视觉强调(成本警告、利用率热图 sparkline)
|
||||
- Local vs Cloud 图标区分(启用废弃组件 `RuntimeModeIcon`)
|
||||
|
||||
### 4.4 可用数据清单
|
||||
|
||||
#### Runtime 主体字段(来自 `RuntimeDevice` type)
|
||||
|
||||
| 字段 | 类型 | 当前状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `name` | string | ✅ | 列表 + 详情头部 |
|
||||
| `provider` | string | ✅ Logo | 9 种:claude / codex / opencode / openclaw / hermes / gemini / pi / cursor 等 |
|
||||
| `runtime_mode` | "local" / "cloud" | ✅ 文字 | 列表项里没图标(死代码) |
|
||||
| `status` | "online" / "offline" | ✅ 圆点 | 浅色主题下离线几乎不可见 |
|
||||
| `last_seen_at` | string | ✅ 仅详情页 | **列表完全看不到** |
|
||||
| `device_info` | string | ✅ 详情页 | 显示原始字符串如 `darwin-arm64`,无人类可读化 |
|
||||
| `daemon_id` | string | ✅ mono 字体 | 不可复制 / 不可点击 |
|
||||
| `metadata.cli_version` | string | ✅ | CLI 更新部分用 |
|
||||
| `metadata.launched_by` | string | ✅ | 桌面端启动时显示 "Managed by Desktop" |
|
||||
| `owner_id` | string | ✅ | 头像 + 名字 |
|
||||
| `created_at` / `updated_at` | string | ✅ | 详情页底部 ISO 时间戳 |
|
||||
|
||||
#### 派生数据(工程阶段 0 提供)
|
||||
|
||||
| 派生信息 | 来源 |
|
||||
|---|---|
|
||||
| **Runtime 4 态健康** | 🔧 由 status + last_seen_at 派生 |
|
||||
| **服务的 agent 列表 + 数量** | 🔧 前端 join(agent 持有 runtime_id,列表数据已在 cache) |
|
||||
| **当前在跑 task 数** | 🔧 前端 filter active-tasks |
|
||||
| **last_seen 相对时间字符串**("5 minutes ago") | 🔧 工具函数已有 |
|
||||
|
||||
#### Token Usage 5 个图表的数据契约
|
||||
|
||||
| 图表 | 数据源 | 时间粒度 | 维度 | 度量 |
|
||||
|---|---|---|---|---|
|
||||
| **Activity Heatmap** | `getRuntimeUsage?days=90` | 日 | date | 4 级强度,按 token 总量百分位分级 |
|
||||
| **Hourly Activity** | `getRuntimeTaskActivity` | 小时(0-23) | hour | 任务数 |
|
||||
| **Daily Token Chart** | 同 Heatmap,客户端聚合 | 日 | date | input/output/cacheRead/cacheWrite 总和 |
|
||||
| **Daily Cost Chart** | 同上 + 客户端定价 | 日 | date | 美元成本 |
|
||||
| **Model Distribution** | 同上聚合 by model | 全周期 | model | tokens 占比 + cost |
|
||||
|
||||
**当前实现的问题**:API 总是取 90 天数据,客户端做 7d/30d 过滤——浪费服务端资源。改造时按选中窗口拉。
|
||||
|
||||
#### 实时事件
|
||||
|
||||
| 事件 | 状态 |
|
||||
|---|---|
|
||||
| `daemon:register` | 📡 已订阅,触发列表刷新 |
|
||||
| `daemon:heartbeat` | 📡 后端发但前端**故意忽略**(防过度刷新)。设计时假设状态变化最坏 75 秒内可见 |
|
||||
|
||||
#### 桌面端独有的本机 IPC 数据(仅桌面端可见)
|
||||
|
||||
```
|
||||
DaemonStatus (本机 IPC,亚秒级实时)
|
||||
├─ state: running / stopped / starting / stopping / installing_cli / cli_not_found
|
||||
├─ pid, uptime, daemonId, deviceName, serverUrl
|
||||
├─ agents: 当前运行的 agent IDs
|
||||
├─ workspaceCount
|
||||
└─ profile
|
||||
```
|
||||
|
||||
工程会把这份数据自动喂进 cache,**设计上不要为"桌面端有更多数据"做特殊视觉**——派生状态对设计师而言是统一的,只是桌面端响应更快。但桌面端**对自己的本机 daemon 有更多操作能力**(见第 6 节)。
|
||||
|
||||
### 4.5 不能动的现有交互(必须保留)
|
||||
|
||||
- Owner filter(mine/all toggle)
|
||||
- Delete runtime 操作(含权限检查)
|
||||
- CLI 更新流程(`update-section.tsx`):检查更新、触发更新、查看更新状态
|
||||
- 5 个图表的数据展示(信息架构可以重排,但数据本身要保留)
|
||||
- 桌面端:start/stop/restart 本机 daemon 的按钮
|
||||
|
||||
### 4.6 关键问题留给你定的
|
||||
|
||||
1. **列表项到底放多少信息**:last_seen + agent count + active tasks 放哪?避免拥挤
|
||||
2. **桌面端 daemon 卡片和云端 runtime 卡片"视觉对齐"的尺度**:100% 同模板?还是同卡片框 + 内容差异化?
|
||||
3. **Token usage 主次怎么排**:本期成本数字 + 单图表 + "查看更多" 折叠?或者 dashboard 化?
|
||||
4. **About to GC 怎么提示**:横幅?badge?倒计时?
|
||||
5. **Ping / 诊断按钮的位置**:详情页头部?右上角菜单?
|
||||
6. **关联 agent 列表展示**:堆叠头像?文字 "3 agents"?还是子区块?
|
||||
|
||||
---
|
||||
|
||||
## 五、跨界面统一:Agent Hover Card
|
||||
|
||||
### 5.1 现有组件
|
||||
|
||||
`packages/views/agents/components/agent-profile-card.tsx`——已经存在,但只在 Agents 主页 hover 时出现。
|
||||
|
||||
这次会把它升级成**统一 hover card**,挂到所有展示 agent 头像的地方。
|
||||
|
||||
### 5.2 必须出现的位置
|
||||
|
||||
| 位置 | 当前状态 |
|
||||
|---|---|
|
||||
| Agents 列表 / 详情 | ✅ 已有 |
|
||||
| Issue Assignee Picker | ❌ 仅头像无状态 |
|
||||
| Issue Detail 头部 assignee | ❌ 仅头像无状态 |
|
||||
| Issue 列表 / 看板的分配头像 | ❌ 仅头像 |
|
||||
| Autopilot 列表 / 编辑(assignee) | ❌ 仅头像 |
|
||||
| Project lead picker | ❌ 仅头像 |
|
||||
| Chat 选择 agent 面板 | ❌ 待确认 |
|
||||
| 评论里的 @agent | ❌ 仅头像 |
|
||||
|
||||
### 5.3 卡片必须显示什么(按重要度)
|
||||
|
||||
1. **派生 5 态状态**(不是 `agent.status` 原始值)
|
||||
2. **Runtime 健康**:在线性 + last_seen 相对时间
|
||||
3. **当前任务**:N running / M queued
|
||||
4. **最近失败**(如果有):原因 + 时间
|
||||
5. **Agent 名称 + description**
|
||||
6. **关联 skills**(前 3 个 + `+N`)
|
||||
7. **Owner**
|
||||
|
||||
### 5.4 设计要点
|
||||
|
||||
- 卡片宽度跨多种使用场景要适配(issue 列表很窄、设置页很宽)
|
||||
- 触发延迟(hover delay)跟 Skills 已有的卡片保持一致
|
||||
- 暗色主题下信息层级要清晰
|
||||
|
||||
---
|
||||
|
||||
## 六、跨平台差异处理
|
||||
|
||||
### 6.1 状态视觉是平台无关的
|
||||
|
||||
派生 5 态 / 4 态、视觉规范、hover card——**两端共享同一套设计**。设计稿只画一份。
|
||||
|
||||
### 6.2 数据响应速度差异(不影响视觉)
|
||||
|
||||
| 平台 | Runtime 状态变化感知延迟 |
|
||||
|---|---|
|
||||
| Web | 最坏 75 秒 |
|
||||
| Desktop(看自己机器) | < 1 秒(IPC) |
|
||||
| Desktop(看别人机器) | 最坏 75 秒(跟 Web 一样) |
|
||||
|
||||
设计上不需要透出"快慢"——用户感知不到这是 IPC 还是 server。
|
||||
|
||||
### 6.3 操作能力差异(影响按钮可见性)
|
||||
|
||||
| 操作 | Web | Desktop(自己机器) | Desktop(别人机器) |
|
||||
|---|---|---|---|
|
||||
| 看状态 | ✅ | ✅ | ✅ |
|
||||
| 重启 daemon | ❌ | ✅ | ❌ |
|
||||
| 看 daemon logs | ❌ | ✅ | ❌ |
|
||||
| 看 CLI 安装详情 | ❌ | ✅ | ❌ |
|
||||
|
||||
**设计要点**:操作按钮在不该有权限的位置应该**直接隐藏**,不要灰显(避免视觉噪音)。
|
||||
|
||||
### 6.4 桌面端独有的"本机 daemon 卡片"
|
||||
|
||||
当前桌面端有一个独立卡片显示本机 daemon。重设计后:
|
||||
- 视觉上跟云端 runtime 列表项**用同一套视觉语言**
|
||||
- 但承载更多本地操作(重启、看日志、安装 CLI、profile 切换)
|
||||
- 位置:列表顶部 sticky / 列表头部突出 / 右侧独立 panel —— 由你定
|
||||
|
||||
---
|
||||
|
||||
## 七、设计语言参考:Skills 界面
|
||||
|
||||
### 7.1 Skills 是这次重设计的视觉锚点
|
||||
|
||||
Skills 界面已经在 2026-04 完成重设计(PR #1607、#1614、#1618、#1610)。这次 Agents 和 Runtimes **直接照搬 Skills 的视觉语言**,保持产品体感一致。
|
||||
|
||||
参考目录:`packages/views/skills/`
|
||||
|
||||
### 7.2 必须复用的 10 条规则
|
||||
|
||||
1. **统一页头** `PageHeader`(h-12 + mobile sidebar trigger)
|
||||
2. **响应式网格列表**:`grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_minmax(0,6rem)_auto]`,不用 flexbox
|
||||
3. **每行三层信息**:主标题(font-medium)→ 描述(line-clamp-1 muted)→ 元数据(xs muted)
|
||||
4. **关联对象用头像堆栈**:最多 3 + `+N`,size=22 + `ring-2 ring-background` + `-space-x-1.5`
|
||||
5. **卡片化列表 + 卡片内工具栏**:搜索和 scope tab 在 `CardToolbar`(h-12),不在页面级
|
||||
6. **创建用多步 dialog**:chooser → 表单可回退;Dialog 宽度按方法切换(manual/url 用 `!max-w-md`,runtime 用 `!max-w-2xl`),300ms 平滑过渡
|
||||
7. **空状态 / 筛选无结果分别有详细文案**:图标 + 标题 + 三行说明 + 清晰 CTA
|
||||
8. **长列表加 `useScrollFade`**:滚动容器上下边缘淡出
|
||||
9. **头像统一用 `ActorAvatar`**:传 `size`,自动支持 agent / 人员
|
||||
10. **权限检查 hook 化**:`useCanEdit...`,UI 提前隐藏/禁用操作按钮
|
||||
|
||||
---
|
||||
|
||||
## 八、工程会同步交付的能力
|
||||
|
||||
阶段 0(数据层地基)完成后,**设计稿可以放心假设以下能力都到位**:
|
||||
|
||||
- 任意位置都能拿到一个 agent 的派生 5 态状态
|
||||
- 任意位置都能拿到 agent 的当前任务数(running / queued)
|
||||
- 任意位置都能拿到 agent 关联的 runtime 4 态健康
|
||||
- 任意位置都能拿到 runtime 服务的 agent 列表 + 数量
|
||||
- 任意位置都能拿到 runtime 当前任务数
|
||||
- 状态变化是实时的(订阅 WS 事件后会自动更新)
|
||||
- 桌面端会自动获得"亚秒级"响应——不需要为此画两套稿
|
||||
|
||||
### 已有 API(不需要新加,可放心引用)
|
||||
|
||||
- `listAgents` / `getAgent` / `createAgent` / `updateAgent` / `archiveAgent` / `restoreAgent`
|
||||
- `listAgentTasks(agentId)` — 单 agent 历史任务
|
||||
- `listRuntimes` / `deleteRuntime`
|
||||
- `getRuntimeUsage(runtimeId, { days })` — token 用量
|
||||
- `getRuntimeTaskActivity(runtimeId)` — 小时级活动
|
||||
|
||||
### 工程要在阶段 0 补的(设计稿可以假设有,但要知道这是新增)
|
||||
|
||||
- **后端**:`GET /api/workspaces/:slug/active-tasks` — 全工作区活跃任务一次拉
|
||||
- **后端**:诊断 / Ping API(如果你的设计稿用到,工程要评估优先级)
|
||||
- **前端类型**:`AgentTask.failure_reason` 字段(5 枚举:agent_error / timeout / runtime_offline / runtime_recovery / manual)暴露到前端类型
|
||||
- **前端**:派生函数(`deriveAgentPresence` / `deriveRuntimeHealth`)+ 全工作区 active-tasks query + WS 接线
|
||||
|
||||
### 工程不会做的(设计稿不要假设有)
|
||||
|
||||
- 不引入"agent 健康综合评分"——只暴露原始信号
|
||||
- 不做"从历史 task 自动推断 agent 类型"等 AI 派生
|
||||
- 不为 runtime 引入新状态色(稳定在 4 态)
|
||||
- 不做后端聚合 API(除了 active-tasks 这个补全)
|
||||
|
||||
---
|
||||
|
||||
## 九、设计师产出清单
|
||||
|
||||
按优先级排列,**P0 必须先于 P1**。
|
||||
|
||||
### P0 — 状态视觉规范(基础,所有界面共用)
|
||||
|
||||
- 5 态颜色 token(Available / Working / Pending / Failed / Offline)
|
||||
- 4 态颜色 token(Online / Recently Lost / Offline / About to GC)
|
||||
- Dot / Badge / Tooltip 三层视觉规范
|
||||
- 复合维度(Working + 排队角标)的视觉表达
|
||||
- Failed 状态的 2 分钟时间窗口动效(强提示 + 自动消失)
|
||||
|
||||
### P0 — Agents 界面
|
||||
|
||||
- 列表页(含派生状态、关联 runtime 健康、最近活动时间)
|
||||
- 详情页头部 + Profile Card(状态行联动)
|
||||
- 创建对话框(保留两步流,加入 runtime 在线状态预览)
|
||||
|
||||
### P0 — Runtimes 界面
|
||||
|
||||
- 列表页(暴露 last_seen、关联 agents、当前 task 数)
|
||||
- 详情页头部(4 态 badge、device info 人类可读化)
|
||||
- Token usage 信息架构重整:核心指标置顶,详细图表下沉/折叠
|
||||
|
||||
### P1 — Hover Card 跨界面统一
|
||||
|
||||
- 一个适配多场景宽度的卡片设计
|
||||
- 7 项内容的信息层级
|
||||
- hover 触发交互(与 Skills 一致)
|
||||
|
||||
### P1 — 桌面端本机 daemon 卡片
|
||||
|
||||
- 视觉对齐云端 runtime 卡片
|
||||
- 本机操作按钮(重启 / 日志 / CLI 安装)的位置
|
||||
- Profile 切换(如果做多 profile)
|
||||
|
||||
### P2 — 加分项
|
||||
|
||||
- Runtime 健康综合评分(视觉化)
|
||||
- 7 天 GC 倒计时
|
||||
- 高使用量 runtime 的成本警告
|
||||
- Local vs Cloud 图标区分
|
||||
- Agent archived 独立 tab/折叠区
|
||||
- 列表按"最近活动"排序、按状态筛选
|
||||
|
||||
---
|
||||
|
||||
## 十、附录:截图占位区
|
||||
|
||||
> 设计师拿到这份文档后,请把以下三个界面的当前截图贴在对应位置,作为重设计前的现状记录,方便对比。
|
||||
|
||||
### 10.1 Agents 界面
|
||||
|
||||
- 列表页截图:__待贴__
|
||||
- 详情页截图(每个 tab 一张:Instructions / Skills / Tasks / Environment / Custom Args / Settings):__待贴__
|
||||
- Profile Card 截图:__待贴__
|
||||
- 创建对话框截图(chooser + form 两步):__待贴__
|
||||
- Hover card 当前样式截图:__待贴__
|
||||
|
||||
### 10.2 Runtimes 界面
|
||||
|
||||
- 列表页截图(mine / all 两态):__待贴__
|
||||
- 详情页截图(含 5 个图表全部展开):__待贴__
|
||||
- 桌面端 daemon 卡片截图:__待贴__
|
||||
- CLI 更新流程截图:__待贴__
|
||||
|
||||
### 10.3 跨界面 agent 头像出现的位置
|
||||
|
||||
- Issue Assignee Picker:__待贴__
|
||||
- Issue Detail 头部 assignee:__待贴__
|
||||
- Issue 列表 / 看板:__待贴__
|
||||
- Autopilot 列表 / 编辑:__待贴__
|
||||
- Project lead picker:__待贴__
|
||||
- Chat agent 选择面板:__待贴__
|
||||
- 评论 @agent:__待贴__
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [Agent / Runtime 状态系统重设计(主文档)](./agent-runtime-status-redesign.md) — 完整工程方案、状态规范、实施阶段
|
||||
- [产品全景文档](./product-overview.md) — 理解 agent / runtime / daemon 在整个产品里的位置
|
||||
- [Skills 界面源代码](../packages/views/skills/) — 直接参考的设计语言样板
|
||||
- 相关 PR:#1607(Skills 重设计)、#1614(Card + PageHeader)、#1618(描述恢复)、#1610(Dialog 闪烁修复)
|
||||
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;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, AgentRuntime, AgentTask, TaskFailureReason } from "../types";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import {
|
||||
buildPresenceMap,
|
||||
deriveAgentAvailability,
|
||||
deriveAgentPresenceDetail,
|
||||
deriveLastTaskState,
|
||||
deriveWorkload,
|
||||
deriveWorkloadDetail,
|
||||
} from "./derive-presence";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
@@ -80,8 +81,7 @@ function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
|
||||
|
||||
describe("deriveAgentAvailability", () => {
|
||||
// Reachability dimension only — runtime + clock decide it; tasks are
|
||||
// irrelevant. The whole point of splitting from LastTaskState is that
|
||||
// these tests can ignore task fixtures entirely.
|
||||
// irrelevant to this axis.
|
||||
|
||||
it("returns online when runtime is fresh-online", () => {
|
||||
expect(deriveAgentAvailability(makeRuntime(), NOW)).toBe("online");
|
||||
@@ -120,128 +120,111 @@ describe("deriveAgentAvailability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveLastTaskState", () => {
|
||||
// Task dimension only — runtime status is not consulted.
|
||||
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 = deriveLastTaskState([]);
|
||||
expect(r.state).toBe("idle");
|
||||
const r = deriveWorkloadDetail([]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns running when at least one task is running", () => {
|
||||
const r = deriveLastTaskState([makeTask({ status: "running" })]);
|
||||
expect(r.state).toBe("running");
|
||||
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 running when only queued / dispatched tasks exist (no running yet)", () => {
|
||||
const r = deriveLastTaskState([
|
||||
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.state).toBe("running");
|
||||
expect(r.workload).toBe("queued");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns running even when an older terminal exists (active wins over historical)", () => {
|
||||
const r = deriveLastTaskState([
|
||||
makeTask({
|
||||
id: "old-failed",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T10:00:00Z",
|
||||
}),
|
||||
makeTask({ id: "new-running", status: "running" }),
|
||||
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.state).toBe("running");
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns the latest terminal state when no tasks are active (latest = failed)", () => {
|
||||
const r = deriveLastTaskState([
|
||||
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: "old",
|
||||
id: "t-failed",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-completed",
|
||||
status: "completed",
|
||||
completed_at: "2026-04-27T10:00:00Z",
|
||||
completed_at: "2026-04-27T11:00:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "new",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
]);
|
||||
expect(r.state).toBe("failed");
|
||||
expect(r.lastTaskCompletedAt).toBe("2026-04-27T11:30:00Z");
|
||||
});
|
||||
|
||||
it("returns the latest terminal state when no tasks are active (latest = completed)", () => {
|
||||
const r = deriveLastTaskState([
|
||||
makeTask({
|
||||
id: "old",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T10:00:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "new",
|
||||
status: "completed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
]);
|
||||
expect(r.state).toBe("completed");
|
||||
});
|
||||
|
||||
it("surfaces failure_reason on a failed latest terminal", () => {
|
||||
const reason: TaskFailureReason = "runtime_offline";
|
||||
const r = deriveLastTaskState([
|
||||
makeTask({
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
failure_reason: reason,
|
||||
}),
|
||||
]);
|
||||
expect(r.state).toBe("failed");
|
||||
expect(r.failureReason).toBe(reason);
|
||||
});
|
||||
|
||||
it("leaves failureReason undefined when the failed terminal has empty failure_reason", () => {
|
||||
const r = deriveLastTaskState([
|
||||
makeTask({
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
failure_reason: "",
|
||||
}),
|
||||
]);
|
||||
expect(r.state).toBe("failed");
|
||||
expect(r.failureReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns cancelled when the latest terminal is cancelled", () => {
|
||||
// Under the new model cancelled is a real state — the dot is
|
||||
// availability-driven so honestly surfacing it doesn't lie.
|
||||
const r = deriveLastTaskState([
|
||||
makeTask({
|
||||
id: "t-cancelled",
|
||||
status: "cancelled",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
completed_at: "2026-04-27T10:30:00Z",
|
||||
}),
|
||||
]);
|
||||
expect(r.state).toBe("cancelled");
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores terminals without completed_at (treated as not-terminal)", () => {
|
||||
// Defensive: a malformed row (no completed_at) shouldn't derail the
|
||||
// latest-terminal scan. With nothing else in flight, idle.
|
||||
const r = deriveLastTaskState([makeTask({ status: "failed", completed_at: null })]);
|
||||
expect(r.state).toBe("idle");
|
||||
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 (the old "unstable
|
||||
// overrides failed" rule is gone — they coexist now).
|
||||
// detail object exposes both. No cross-axis override — workload never
|
||||
// colours the dot, availability never overrides workload.
|
||||
|
||||
it("composes online + running for the common busy case", () => {
|
||||
it("composes online + working for the common busy case", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
@@ -252,53 +235,53 @@ describe("deriveAgentPresenceDetail", () => {
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.lastTask).toBe("running");
|
||||
expect(detail.workload).toBe("working");
|
||||
expect(detail.runningCount).toBe(1);
|
||||
expect(detail.queuedCount).toBe(1);
|
||||
expect(detail.capacity).toBe(6);
|
||||
});
|
||||
|
||||
it("composes online + failed — agent is reachable but last task failed (no longer sticky red dot)", () => {
|
||||
// The whole motivation for the split: this combination was previously
|
||||
// collapsed to a single red "failed" state, hiding the fact that the
|
||||
// runtime is fine. Now the two dimensions are visible separately.
|
||||
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(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [
|
||||
makeTask({
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
failure_reason: "agent_error",
|
||||
}),
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.lastTask).toBe("failed");
|
||||
expect(detail.failureReason).toBe("agent_error");
|
||||
expect(detail.lastTaskCompletedAt).toBe("2026-04-27T11:30:00Z");
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("queued");
|
||||
expect(detail.runningCount).toBe(0);
|
||||
expect(detail.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("composes unstable + running — runtime hiccup with queued tasks still in flight", () => {
|
||||
// Previously "unstable" overrode "working"; now both signals are
|
||||
// surfaced. The UI shows amber dot AND running chip — user sees both
|
||||
// "connection issue" and "queue is paused".
|
||||
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: "queued" })],
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("unstable");
|
||||
expect(detail.lastTask).toBe("running");
|
||||
expect(detail.queuedCount).toBe(1);
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("composes offline + idle for a brand-new agent on a dead runtime", () => {
|
||||
it("composes offline + idle for an unreachable agent with no tasks pending", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
@@ -309,34 +292,34 @@ describe("deriveAgentPresenceDetail", () => {
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.lastTask).toBe("idle");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("handles a missing runtime by reporting offline + the task-driven last state", () => {
|
||||
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: "completed",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.lastTask).toBe("completed");
|
||||
});
|
||||
|
||||
it("leaves failureReason / lastTaskCompletedAt undefined when not relevant", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.failureReason).toBeUndefined();
|
||||
expect(detail.lastTaskCompletedAt).toBeUndefined();
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("mirrors agent.max_concurrent_tasks into capacity", () => {
|
||||
@@ -359,21 +342,16 @@ describe("buildPresenceMap", () => {
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "running" }),
|
||||
makeTask({
|
||||
id: "t2",
|
||||
agent_id: "b",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
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?.lastTask).toBe("running");
|
||||
expect(a?.workload).toBe("working");
|
||||
expect(b?.availability).toBe("online");
|
||||
expect(b?.lastTask).toBe("failed");
|
||||
expect(b?.workload).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns offline availability for agents whose runtime_id has no matching runtime", () => {
|
||||
@@ -386,8 +364,8 @@ describe("buildPresenceMap", () => {
|
||||
});
|
||||
const o = map.get("orphan");
|
||||
expect(o?.availability).toBe("offline");
|
||||
// Task dimension still resolves independently — running task counts.
|
||||
expect(o?.lastTask).toBe("running");
|
||||
// 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", () => {
|
||||
@@ -406,19 +384,35 @@ describe("buildPresenceMap", () => {
|
||||
],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "queued" }),
|
||||
makeTask({
|
||||
id: "t2",
|
||||
agent_id: "b",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:00:00Z",
|
||||
}),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "running" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.availability).toBe("unstable");
|
||||
expect(map.get("b")?.availability).toBe("unstable");
|
||||
// Last-task remains independent: a is running (queued), b is failed.
|
||||
expect(map.get("a")?.lastTask).toBe("running");
|
||||
expect(map.get("b")?.lastTask).toBe("failed");
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
// dimensions:
|
||||
//
|
||||
// 1. AgentAvailability — derived from runtime reachability only.
|
||||
// 2. LastTaskState — derived from the task snapshot only.
|
||||
// 2. Workload — derived from the task counts only.
|
||||
//
|
||||
// They are computed independently and assembled into AgentPresenceDetail.
|
||||
// No cross-dimension override logic — that was the source of the previous
|
||||
// model's "sticky red dot" confusion.
|
||||
// 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, TaskFailureReason } from "../types";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import type {
|
||||
AgentAvailability,
|
||||
AgentPresenceDetail,
|
||||
LastTaskState,
|
||||
Workload,
|
||||
} from "./types";
|
||||
|
||||
// AgentAvailability mirrors RuntimeHealth's reachability buckets but folds
|
||||
@@ -33,76 +34,44 @@ export function deriveAgentAvailability(
|
||||
return "offline"; // offline | about_to_gc collapse here
|
||||
}
|
||||
|
||||
interface LastTaskResult {
|
||||
state: LastTaskState;
|
||||
// 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;
|
||||
failureReason?: TaskFailureReason;
|
||||
lastTaskCompletedAt?: string;
|
||||
}): Workload {
|
||||
if (counts.runningCount > 0) return "working";
|
||||
if (counts.queuedCount > 0) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
// Single pass: count actives + track latest terminal by completed_at. A
|
||||
// running OR queued task means the agent is currently busy ("running"
|
||||
// state); only when nothing is in flight do we fall through to the latest
|
||||
// terminal (which can be completed / failed / cancelled). With no terminal
|
||||
// history at all, we report `idle`.
|
||||
//
|
||||
// Cancelled is no longer filtered out — under the new model the dot is
|
||||
// availability-driven so honestly surfacing "cancelled" doesn't risk
|
||||
// lying about whether the agent works. The previous "exclude cancelled
|
||||
// to keep red sticky" hack is gone.
|
||||
export function deriveLastTaskState(tasks: readonly AgentTask[]): LastTaskResult {
|
||||
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;
|
||||
let latestTerminal: AgentTask | null = null;
|
||||
let latestTerminalAt = -Infinity;
|
||||
|
||||
for (const t of tasks) {
|
||||
if (t.status === "running") {
|
||||
runningCount += 1;
|
||||
} else if (t.status === "queued" || t.status === "dispatched") {
|
||||
queuedCount += 1;
|
||||
} else if (t.completed_at) {
|
||||
const ts = new Date(t.completed_at).getTime();
|
||||
if (!Number.isNaN(ts) && ts > latestTerminalAt) {
|
||||
latestTerminalAt = ts;
|
||||
latestTerminal = t;
|
||||
}
|
||||
}
|
||||
// Terminal statuses (completed / failed / cancelled) intentionally
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
}
|
||||
|
||||
if (runningCount + queuedCount > 0) {
|
||||
return { state: "running", runningCount, queuedCount };
|
||||
}
|
||||
|
||||
if (!latestTerminal) {
|
||||
return { state: "idle", runningCount: 0, queuedCount: 0 };
|
||||
}
|
||||
|
||||
const completedAt = latestTerminal.completed_at ?? undefined;
|
||||
if (latestTerminal.status === "failed") {
|
||||
return {
|
||||
state: "failed",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
failureReason: latestTerminal.failure_reason || undefined,
|
||||
lastTaskCompletedAt: completedAt,
|
||||
};
|
||||
}
|
||||
if (latestTerminal.status === "cancelled") {
|
||||
return {
|
||||
state: "cancelled",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
lastTaskCompletedAt: completedAt,
|
||||
};
|
||||
}
|
||||
// completed
|
||||
return {
|
||||
state: "completed",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
lastTaskCompletedAt: completedAt,
|
||||
workload: deriveWorkload({ runningCount, queuedCount }),
|
||||
runningCount,
|
||||
queuedCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,16 +88,14 @@ interface DerivePresenceInput {
|
||||
|
||||
export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPresenceDetail {
|
||||
const availability = deriveAgentAvailability(input.runtime, input.now);
|
||||
const last = deriveLastTaskState(input.tasks);
|
||||
const detail = deriveWorkloadDetail(input.tasks);
|
||||
|
||||
return {
|
||||
availability,
|
||||
lastTask: last.state,
|
||||
runningCount: last.runningCount,
|
||||
queuedCount: last.queuedCount,
|
||||
workload: detail.workload,
|
||||
runningCount: detail.runningCount,
|
||||
queuedCount: detail.queuedCount,
|
||||
capacity: input.agent.max_concurrent_tasks,
|
||||
failureReason: last.failureReason,
|
||||
lastTaskCompletedAt: last.lastTaskCompletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,9 +105,10 @@ export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPres
|
||||
export function buildPresenceMap(args: {
|
||||
agents: readonly Agent[];
|
||||
runtimes: readonly AgentRuntime[];
|
||||
// The workspace agent task snapshot: every active task + each agent's
|
||||
// The workspace agent task snapshot: every active task plus each agent's
|
||||
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
|
||||
// — no pre-filtering needed.
|
||||
// — no pre-filtering needed. Terminal rows are silently ignored by
|
||||
// deriveWorkloadDetail (workload is current-state only).
|
||||
snapshot: readonly AgentTask[];
|
||||
now: number;
|
||||
}): Map<string, AgentPresenceDetail> {
|
||||
|
||||
@@ -4,3 +4,6 @@ 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";
|
||||
|
||||
@@ -3,23 +3,25 @@
|
||||
// front-end from raw server data (agent + runtime + recent tasks); the
|
||||
// back-end never knows about these enums.
|
||||
//
|
||||
// Two orthogonal dimensions, derived independently:
|
||||
// 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. LastTaskState — "What was the last thing this agent did?"
|
||||
// Depends only on the workspace task snapshot. Surfaced as text + icon
|
||||
// on focused surfaces (hover card, agent detail, agent list, runtime
|
||||
// detail). Never colours the dot.
|
||||
//
|
||||
// The previous single 5-state union conflated the two: a runtime-healthy
|
||||
// agent whose last task failed would show a red dot indistinguishable from
|
||||
// a daemon-dead agent. Splitting them lets each signal be unambiguous.
|
||||
|
||||
import type { TaskFailureReason } from "../types";
|
||||
// 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
|
||||
@@ -30,40 +32,31 @@ export type AgentAvailability =
|
||||
| "unstable" // 🟡 runtime recently_lost (< 5 min) — transient
|
||||
| "offline"; // ⚫ runtime long offline / missing / never registered
|
||||
|
||||
// Last-task dimension. Active and terminal merged into one enum because
|
||||
// only one applies at a time: while there's any in-flight task the state
|
||||
// is `running`; once everything terminates we read the latest outcome;
|
||||
// with no history at all, `idle`.
|
||||
// Current task load on this agent. Three states — never historical,
|
||||
// never an error predictor (Inbox + Recent Work handle that):
|
||||
//
|
||||
// `running` covers both `running` and `queued/dispatched` tasks because
|
||||
// from the user's perspective "agent is busy" is the same answer; the
|
||||
// running/queued counts on the detail object preserve the breakdown.
|
||||
// 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.
|
||||
//
|
||||
// `cancelled` is included as a discrete state (vs. folding into the
|
||||
// previous filter that excluded cancelled from terminal selection). With
|
||||
// the dot no longer colour-coded by task state, surfacing "cancelled"
|
||||
// honestly is fine — it doesn't risk lying about availability.
|
||||
export type LastTaskState =
|
||||
| "running" // ≥1 task running or queued right now
|
||||
| "completed" // latest terminal: completed
|
||||
| "failed" // latest terminal: failed
|
||||
| "cancelled" // latest terminal: cancelled
|
||||
| "idle"; // no active task and no terminal history
|
||||
// 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;
|
||||
lastTask: LastTaskState;
|
||||
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;
|
||||
// Set only when lastTask === "failed". The label lookup happens at the
|
||||
// UI layer; deriving exposes the raw classifier so the UI can choose copy.
|
||||
failureReason?: TaskFailureReason;
|
||||
// Wall-clock timestamp of the latest terminal task. Set whenever
|
||||
// lastTask is one of completed / failed / cancelled. Used to render
|
||||
// "Last run: failed · 12 min ago" copy. Undefined for `running` (no
|
||||
// terminal yet) and `idle` (no history).
|
||||
lastTaskCompletedAt?: string;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useWorkspacePresenceMap(wsId: string | undefined): {
|
||||
// skeleton spinning forever.
|
||||
const MISSING_AGENT_DETAIL: AgentPresenceDetail = {
|
||||
availability: "offline",
|
||||
lastTask: "idle",
|
||||
workload: "idle",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
capacity: 0,
|
||||
|
||||
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";
|
||||
}
|
||||
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];
|
||||
}
|
||||
@@ -55,6 +55,9 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
UpdateLabelRequest,
|
||||
@@ -75,6 +78,8 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -783,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;
|
||||
@@ -1060,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`);
|
||||
|
||||
@@ -16,6 +16,14 @@ 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);
|
||||
@@ -43,7 +51,7 @@ 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 = 420;
|
||||
export const CHAT_DEFAULT_W = 380;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
@@ -118,8 +126,14 @@ 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: false,
|
||||
isOpen: initialIsOpen,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
@@ -130,11 +144,13 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
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) => {
|
||||
|
||||
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<
|
||||
|
||||
@@ -15,6 +15,8 @@ import { defaultStorage } from "../../platform/storage";
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
@@ -22,6 +24,8 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,8 @@ export type OnboardingCompletionPath =
|
||||
| "full" // Reached Step 5 (first_issue) with a runtime connected
|
||||
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
|
||||
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
|
||||
| "skip_existing"; // "I've done this before" from Welcome
|
||||
| "skip_existing" // "I've done this before" from Welcome
|
||||
| "invite_accept"; // Accepted at least one invite from /invitations
|
||||
|
||||
export type TeamSize = "solo" | "team" | "other";
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -46,6 +49,8 @@
|
||||
"./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",
|
||||
|
||||
@@ -43,6 +43,7 @@ export const paths = {
|
||||
login: () => "/login",
|
||||
newWorkspace: () => "/workspaces/new",
|
||||
invite: (id: string) => `/invite/${encode(id)}`,
|
||||
invitations: () => "/invitations",
|
||||
onboarding: () => "/onboarding",
|
||||
authCallback: () => "/auth/callback",
|
||||
root: () => "/",
|
||||
@@ -54,7 +55,7 @@ export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
|
||||
// A path is global if it equals or begins with any of these.
|
||||
// Note: `/workspaces/` (trailing slash) is the prefix — `workspaces` is reserved,
|
||||
// so any path starting with `/workspaces/...` is system-owned, not user-owned.
|
||||
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/onboarding", "/auth/", "/logout", "/signup"];
|
||||
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/invitations", "/onboarding", "/auth/", "/logout", "/signup"];
|
||||
|
||||
export function isGlobalPath(path: string): boolean {
|
||||
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
|
||||
@@ -20,6 +20,7 @@ export const RESERVED_SLUGS = new Set([
|
||||
"oauth",
|
||||
"callback",
|
||||
"invite",
|
||||
"invitations",
|
||||
"verify",
|
||||
"reset",
|
||||
"password",
|
||||
|
||||
@@ -19,14 +19,16 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("not onboarded → /onboarding regardless of workspaces", () => {
|
||||
it("!onboarded → /onboarding regardless of workspace count", () => {
|
||||
// Un-onboarded users are routed back to the onboarding flow. The
|
||||
// "un-onboarded but in workspace" state is now physically impossible
|
||||
// (backend invariant + migration 065 backfill), but the resolver still
|
||||
// does the right thing if it ever appears: send the user to onboarding
|
||||
// rather than dropping them into a workspace with `onboarded_at` null.
|
||||
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", () => {
|
||||
|
||||
@@ -7,6 +7,18 @@ import { paths } from "./paths";
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
*
|
||||
* `onboarded_at` is the single source of truth for whether the user has
|
||||
* passed first-contact. Backend transactions (CreateWorkspace,
|
||||
* AcceptInvitation) atomically set this field whenever a user joins a
|
||||
* `member` row, so "has workspace but !onboarded" is now a
|
||||
* physically impossible state — see migration 065 for the existing-data
|
||||
* backfill that closed the door retroactively.
|
||||
*
|
||||
* Callers that need invitation-aware routing (callback / login) handle the
|
||||
* "un-onboarded with pending invites" branch themselves before calling
|
||||
* this resolver — this resolver only deals with the post-invite-check
|
||||
* destination.
|
||||
*/
|
||||
export function resolvePostAuthDestination(
|
||||
workspaces: Workspace[],
|
||||
@@ -16,7 +28,10 @@ export function resolvePostAuthDestination(
|
||||
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 paths.newWorkspace();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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,7 +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 } from "../agents/queries";
|
||||
import {
|
||||
agentTaskSnapshotKeys,
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -46,9 +51,13 @@ import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskQueuedPayload,
|
||||
TaskDispatchPayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
ChatDonePayload,
|
||||
ChatPendingTask,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
|
||||
@@ -162,6 +171,15 @@ export function useRealtimeSync(
|
||||
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"] });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -511,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
|
||||
@@ -531,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();
|
||||
});
|
||||
@@ -570,6 +652,9 @@ export function useRealtimeSync(
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskQueued();
|
||||
unsubTaskDispatch();
|
||||
unsubTaskCancelled();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
|
||||
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 : "";
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./local-skills";
|
||||
export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
|
||||
@@ -74,6 +74,27 @@ export interface AgentTask {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,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";
|
||||
@@ -47,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>
|
||||
);
|
||||
}
|
||||
@@ -21,18 +21,33 @@ function HoverCardContent({
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onAuxClick,
|
||||
onDoubleClick,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PreviewCardPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
// Stop click events from bubbling out of the popover. Base UI portals the
|
||||
// popup to <body> so the DOM is detached, but React's synthetic event
|
||||
// Stop interaction events from bubbling out of the popup. Base UI portals
|
||||
// the popup to <body> so the DOM is detached, but React's synthetic event
|
||||
// system still bubbles through the React component tree — without this,
|
||||
// clicks on links / buttons inside the card would also fire onClick on
|
||||
// any ancestor link the trigger was nested in (e.g. issue list rows).
|
||||
// Consumer-supplied onClick is forwarded after the stop.
|
||||
// events on the popup would also fire on any ancestor of the trigger
|
||||
// (e.g. a clickable issue list row, a wrapping <a>).
|
||||
//
|
||||
// We stop the safe set: click / contextmenu / auxclick / dblclick.
|
||||
// We deliberately do NOT stop pointerdown / mousedown — Base UI's
|
||||
// outside-click dismiss listens to pointerdown on document and uses an
|
||||
// "inside React tree" check to decide whether to close. Stopping
|
||||
// pointerdown inside the popup would make the dismiss handler wrongly
|
||||
// think the click happened outside, requiring two clicks to close
|
||||
// (mirrors radix-ui/primitives#2782).
|
||||
const stop = <E extends React.SyntheticEvent>(forwarded?: (e: E) => void) =>
|
||||
(e: E) => {
|
||||
e.stopPropagation()
|
||||
forwarded?.(e)
|
||||
}
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
@@ -44,10 +59,10 @@ function HoverCardContent({
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
}}
|
||||
onClick={stop(onClick)}
|
||||
onContextMenu={stop(onContextMenu)}
|
||||
onAuxClick={stop(onAuxClick)}
|
||||
onDoubleClick={stop(onDoubleClick)}
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
|
||||
@@ -5,11 +5,16 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
// Use `resolvedTheme` (the concrete "light" / "dark" value) instead of
|
||||
// `theme` (which can be "system"). When we forward "system", sonner reads
|
||||
// `prefers-color-scheme` itself, and the Electron renderer's media query
|
||||
// can disagree with next-themes' `html.dark` class — that's why the toast
|
||||
// sometimes rendered light on a dark UI.
|
||||
const { resolvedTheme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={resolvedTheme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
|
||||
57
packages/ui/lib/data-table.ts
Normal file
57
packages/ui/lib/data-table.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Column, RowData } from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
|
||||
// Extend TanStack Table's ColumnMeta with a `grow` flag. TanStack merges
|
||||
// a default `size: 150` into every columnDef, so "no explicit size" can't
|
||||
// be detected by inspecting columnDef.size (it's always a number). Setting
|
||||
// `meta: { grow: true }` is the official extension point: DataTable skips
|
||||
// the inline width for these columns until the user explicitly resizes them,
|
||||
// then the resized width wins.
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
grow?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Combined sizing + pinning style for a `<th>` / `<td>` cell. Width is
|
||||
// emitted unless the column is flagged `meta.grow` (those rely on
|
||||
// fixed-layout's leftover-space distribution). Pinned columns get
|
||||
// sticky positioning — see notes below.
|
||||
//
|
||||
// Background is intentionally NOT set inline — the upstream Dice UI
|
||||
// version writes `background: var(--background)` here, which can't
|
||||
// react to `:hover`. Consumers set bg via Tailwind classes paired with
|
||||
// `group-hover:`.
|
||||
export function getCellStyle<TData>(
|
||||
column: Column<TData>,
|
||||
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
|
||||
): React.CSSProperties {
|
||||
const grow = column.columnDef.meta?.grow;
|
||||
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
|
||||
|
||||
const isPinned = column.getIsPinned();
|
||||
if (!isPinned) {
|
||||
return width !== undefined ? { width } : {};
|
||||
}
|
||||
|
||||
const withBorder = options?.withBorder ?? false;
|
||||
const isLastLeftPinned =
|
||||
isPinned === "left" && column.getIsLastColumn("left");
|
||||
const isFirstRightPinned =
|
||||
isPinned === "right" && column.getIsFirstColumn("right");
|
||||
|
||||
return {
|
||||
width,
|
||||
position: "sticky",
|
||||
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
|
||||
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
|
||||
zIndex: 1,
|
||||
boxShadow: withBorder
|
||||
? isLastLeftPinned
|
||||
? "-4px 0 4px -4px var(--border) inset"
|
||||
: isFirstRightPinned
|
||||
? "4px 0 4px -4px var(--border) inset"
|
||||
: undefined
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -16,12 +16,14 @@
|
||||
"./markdown/mentions": "./markdown/mentions.ts",
|
||||
"./hooks/*": "./hooks/*.ts",
|
||||
"./lib/utils": "./lib/utils.ts",
|
||||
"./lib/data-table": "./lib/data-table.ts",
|
||||
"./styles/tokens.css": "./styles/tokens.css",
|
||||
"./styles/base.css": "./styles/base.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@tanstack/react-table": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -46,6 +48,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-animations": "catalog:",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -83,6 +83,37 @@
|
||||
animation: chat-impulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ChatGPT-style "thinking" shimmer for inline text — a soft light sweep
|
||||
* runs across the glyphs, signalling "the agent is doing something" without
|
||||
* a separate spinner. Pure CSS: linear-gradient clipped to the text shape,
|
||||
* the gradient slid across via background-position. Uses the same muted →
|
||||
* foreground tokens chat copy normally uses, so the effect adapts to light
|
||||
* and dark mode without per-mode overrides.
|
||||
*
|
||||
* Apply to a <span> wrapping the label only — not the whole pill, since
|
||||
* the timer counter and Cancel button shouldn't shimmer. */
|
||||
@keyframes chat-text-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.animate-chat-text-shimmer {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--muted-foreground) 0%,
|
||||
var(--muted-foreground) 35%,
|
||||
var(--foreground) 50%,
|
||||
var(--muted-foreground) 65%,
|
||||
var(--muted-foreground) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: chat-text-shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
@@ -118,4 +149,14 @@
|
||||
* Progressive enhancement: browsers that don't support it simply ignore the rule. */
|
||||
text-autospace: ideograph-alpha ideograph-numeric;
|
||||
}
|
||||
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="image"]):not([type="radio"]):not([type="range"]):not([type="reset"]):not([type="submit"]),
|
||||
textarea,
|
||||
select,
|
||||
[contenteditable]:not([contenteditable="false"]) {
|
||||
/* iOS Safari zooms the page when focused editable text is below 16px. */
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
377
packages/views/agents/components/agent-columns.tsx
Normal file
377
packages/views/agents/components/agent-columns.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud, Lock, Monitor } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import {
|
||||
type AgentActivity,
|
||||
type AgentPresenceDetail,
|
||||
summarizeActivityWindow,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { availabilityConfig, workloadConfig } from "../presence";
|
||||
import { AgentRowActions } from "./agent-row-actions";
|
||||
import { Sparkline } from "./sparkline";
|
||||
|
||||
// Per-row data shape. We assemble agent + runtime + presence + activity +
|
||||
// run count into one struct at the page level so the column cells just
|
||||
// read off `row.original` without each pulling its own queries.
|
||||
export interface AgentRow {
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
activity: AgentActivity | null | undefined;
|
||||
runCount: number;
|
||||
// Inline owner avatar — non-null when the page wants to attribute the
|
||||
// agent to a teammate (typically All scope on someone else's agent).
|
||||
ownerIdToShow: string | null;
|
||||
// True when the current user owns this agent (drives the "You" badge).
|
||||
isOwnedByMe: boolean;
|
||||
// True when the current user can archive / cancel-tasks on this agent.
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
// Sized columns render at exactly `size` in fixed table-layout mode —
|
||||
// column.size doubles as the cell's effective max-width: truncatable
|
||||
// cells with `truncate` inside hit ellipsis at the column edge.
|
||||
//
|
||||
// The Agent and Runtime columns have `meta.grow: true` so DataTable skips
|
||||
// their inline widths until the user resizes them. Fixed table-layout splits
|
||||
// the leftover space between them, which keeps Agent from monopolising wide
|
||||
// viewports while still giving both columns a real floor.
|
||||
//
|
||||
// The grow columns also keep their `size` values even though those widths
|
||||
// are skipped for initial rendering. TanStack folds them into
|
||||
// `table.getTotalSize()`, which DataTable applies as the table's `min-width`.
|
||||
// That's how the grow columns get real floors: when the viewport drops below
|
||||
// the summed column sizes, the table refuses to shrink further and the
|
||||
// container scrolls instead.
|
||||
const COL_WIDTHS = {
|
||||
agent: 240,
|
||||
status: 120,
|
||||
workload: 140,
|
||||
runtime: 200,
|
||||
activity: 100,
|
||||
runs: 64,
|
||||
// 60 = 16 left padding + 28 kebab + 16 right padding. Keeps the
|
||||
// kebab's right edge 16px from the card so it lines up with the
|
||||
// toolbar's px-4 right inset.
|
||||
actions: 60,
|
||||
} as const;
|
||||
|
||||
export function createAgentColumns({
|
||||
onDuplicate,
|
||||
}: {
|
||||
onDuplicate: (agent: Agent) => void;
|
||||
}): ColumnDef<AgentRow>[] {
|
||||
return [
|
||||
{
|
||||
id: "agent",
|
||||
header: "Agent",
|
||||
size: COL_WIDTHS.agent,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => <AgentNameCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
size: COL_WIDTHS.status,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.agent.archived_at) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
return <AvailabilityCell presence={row.original.presence} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workload",
|
||||
header: "Workload",
|
||||
size: COL_WIDTHS.workload,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.agent.archived_at) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
return <WorkloadCell presence={row.original.presence} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "runtime",
|
||||
header: "Runtime",
|
||||
size: COL_WIDTHS.runtime,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => <RuntimeCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
header: "Activity (7d)",
|
||||
size: COL_WIDTHS.activity,
|
||||
cell: ({ row }) => <ActivityCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "runs",
|
||||
header: () => <div className="text-right">Runs</div>,
|
||||
size: COL_WIDTHS.runs,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{row.original.runCount == null
|
||||
? "—"
|
||||
: row.original.runCount.toLocaleString()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
size: COL_WIDTHS.actions,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
// The kebab dropdown owns its own click target. Stop the row
|
||||
// click handler from firing as a side-effect.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AgentRowActions
|
||||
agent={row.original.agent}
|
||||
presence={row.original.presence}
|
||||
canManage={row.original.canManage}
|
||||
onDuplicate={onDuplicate}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cell renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
const { agent, ownerIdToShow, isOwnedByMe } = row;
|
||||
const isArchived = !!agent.archived_at;
|
||||
const isPrivate = agent.visibility === "private";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={28}
|
||||
className={`shrink-0 rounded-md ${isArchived ? "opacity-50 grayscale" : ""}`}
|
||||
showStatusDot
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`min-w-0 truncate font-medium ${
|
||||
isArchived ? "text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{agent.name}
|
||||
</span>
|
||||
{isPrivate && !isArchived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{VISIBILITY_TOOLTIP.private}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isOwnedByMe && !ownerIdToShow && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
{ownerIdToShow && (
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
actorId={ownerIdToShow}
|
||||
size={14}
|
||||
/>
|
||||
)}
|
||||
{isArchived && (
|
||||
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 truncate text-xs ${
|
||||
agent.description
|
||||
? "text-muted-foreground"
|
||||
: "italic text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{agent.description || "No description"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AvailabilityCell({
|
||||
presence,
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
if (!presence) {
|
||||
return (
|
||||
<span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />
|
||||
);
|
||||
}
|
||||
const av = availabilityConfig[presence.availability];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadCell({
|
||||
presence,
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
if (!presence) {
|
||||
return (
|
||||
<span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />
|
||||
);
|
||||
}
|
||||
// All three workload states render with the same shape (icon + label +
|
||||
// optional counts). Idle agents show "Idle" rather than a bare em-dash
|
||||
// — that hyphen used to mean both "no presence data" and "agent is
|
||||
// idle", which conflated two distinct things. Em-dash is now reserved
|
||||
// for archived rows / undefined presence (handled at the column level).
|
||||
const wl = workloadConfig[presence.workload];
|
||||
const isWorking = presence.workload === "working";
|
||||
const isQueued = presence.workload === "queued";
|
||||
// Queued's amber from workloadConfig is the severe tone for "stuck on
|
||||
// offline runtime". On an online runtime queued is just a brief race
|
||||
// between enqueue and daemon claim, where amber misreads as a warning.
|
||||
// Compose with availability so the colour matches the actual signal.
|
||||
const queuedTone =
|
||||
presence.availability === "online" ? "text-muted-foreground" : wl.textClass;
|
||||
const labelTone = isQueued ? queuedTone : wl.textClass;
|
||||
// Working: show running/capacity, optionally with +Nq when overflow.
|
||||
// Queued (= nothing running, things waiting — typically a stuck-on-
|
||||
// offline-runtime signal): show the queued count directly so the user
|
||||
// sees "Queued · 2" instead of misleading "Running 0/3 +2q".
|
||||
// Idle: no counts — the label alone carries the meaning.
|
||||
const counts = isWorking
|
||||
? presence.queuedCount > 0
|
||||
? `${presence.runningCount}/${presence.capacity} +${presence.queuedCount}q`
|
||||
: `${presence.runningCount}/${presence.capacity}`
|
||||
: isQueued
|
||||
? `${presence.queuedCount}`
|
||||
: null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
{/* Icon only renders for working/queued — those carry visual meaning
|
||||
(spinner = in motion, clock = waiting). Idle adding an icon read
|
||||
as a warning marker, which is the wrong signal. */}
|
||||
{presence.workload !== "idle" && (
|
||||
<wl.icon
|
||||
className={`h-3 w-3 shrink-0 ${labelTone} ${isWorking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`shrink-0 ${labelTone}`}>{wl.label}</span>
|
||||
{counts && (
|
||||
<span className="truncate text-muted-foreground">{counts}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeCell({ row }: { row: AgentRow }) {
|
||||
const { agent, runtime } = row;
|
||||
const isCloud = agent.runtime_mode === "cloud";
|
||||
const RuntimeIcon = isCloud ? Cloud : Monitor;
|
||||
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<RuntimeIcon className="h-3 w-3 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="block min-w-0 truncate">{runtimeLabel}</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{runtimeLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityCell({ row }: { row: AgentRow }) {
|
||||
const { agent, activity } = row;
|
||||
if (agent.archived_at) {
|
||||
return <span className="text-xs text-muted-foreground/50">—</span>;
|
||||
}
|
||||
if (!activity) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block animate-pulse rounded bg-muted/60"
|
||||
style={{ width: 64, height: 20 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const summary = summarizeActivityWindow(activity, 7);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="inline-flex cursor-default items-center">
|
||||
<Sparkline buckets={summary.buckets} width={64} height={20} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<ActivityTooltipBody activity={activity} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTooltipBody({ activity }: { activity: AgentActivity }) {
|
||||
const summary = summarizeActivityWindow(activity, 7);
|
||||
const { totalRuns, totalFailed } = summary;
|
||||
const { daysSinceCreated } = activity;
|
||||
|
||||
const isPartial = daysSinceCreated < 7;
|
||||
const headerText = isPartial
|
||||
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
|
||||
: "Last 7 days";
|
||||
|
||||
let bodyText: string;
|
||||
if (totalRuns === 0) {
|
||||
bodyText = "No activity";
|
||||
} else {
|
||||
const failedFragment =
|
||||
totalFailed > 0
|
||||
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
|
||||
: "";
|
||||
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{headerText}
|
||||
</span>
|
||||
<span className="text-xs">{bodyText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,23 @@ import type {
|
||||
AgentRuntime,
|
||||
MemberWithUser,
|
||||
} from "@multica/core/types";
|
||||
import type { AgentPresenceDetail } from "@multica/core/agents";
|
||||
import {
|
||||
AGENT_DESCRIPTION_MAX_LENGTH,
|
||||
type AgentPresenceDetail,
|
||||
} from "@multica/core/agents";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -27,6 +37,7 @@ import {
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { PropRow } from "../../common/prop-row";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { CharCounter } from "./char-counter";
|
||||
import { ConcurrencyPicker } from "./inspector/concurrency-picker";
|
||||
import { ModelPicker } from "./inspector/model-picker";
|
||||
import { RuntimePicker } from "./inspector/runtime-picker";
|
||||
@@ -44,6 +55,15 @@ interface InspectorProps {
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
/**
|
||||
* Computed by the parent via `useAgentPermissions(agent).canEdit.allowed`.
|
||||
* When false the inspector renders all editable surfaces as static
|
||||
* read-only displays — pickers become text/badges, name/description lose
|
||||
* their pencil affordance, the avatar is no longer clickable, and the
|
||||
* "Attach skill" trigger is hidden. Mirrors the backend gate at
|
||||
* `server/internal/handler/agent.go:519-535`.
|
||||
*/
|
||||
canEdit: boolean;
|
||||
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -66,6 +86,7 @@ export function AgentDetailInspector({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: InspectorProps) {
|
||||
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
|
||||
@@ -75,16 +96,18 @@ export function AgentDetailInspector({
|
||||
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
|
||||
<AvatarEditor agent={agent} onUpdate={update} />
|
||||
<NameAndDescription agent={agent} onUpdate={update} />
|
||||
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
|
||||
<NameAndDescription
|
||||
agent={agent}
|
||||
canEdit={canEdit}
|
||||
onUpdate={update}
|
||||
/>
|
||||
<PresenceBadge presence={presence} />
|
||||
</div>
|
||||
|
||||
{/* Properties — editable. Row hover is OFF here on purpose: each chip
|
||||
(RuntimePicker, ModelPicker, …) carries its own border + hover-bg
|
||||
treatment that already telegraphs "this is a button". A second
|
||||
row-wide hover layer on top would just smudge the chip boundary
|
||||
and make it harder, not easier, to see what's clickable. */}
|
||||
{/* Properties — editable when canEdit. When the current user lacks
|
||||
permission, each picker self-renders a static read-only display so
|
||||
the value is visible but not interactive. */}
|
||||
<Section label="Properties">
|
||||
<PropRow label="Runtime" interactive={false}>
|
||||
<RuntimePicker
|
||||
@@ -92,6 +115,7 @@ export function AgentDetailInspector({
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
canEdit={canEdit}
|
||||
onChange={(id) => update({ runtime_id: id })}
|
||||
/>
|
||||
</PropRow>
|
||||
@@ -100,18 +124,21 @@ export function AgentDetailInspector({
|
||||
runtimeId={agent.runtime_id}
|
||||
runtimeOnline={!!isOnline}
|
||||
value={agent.model ?? ""}
|
||||
canEdit={canEdit}
|
||||
onChange={(m) => update({ model: m })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Visibility" interactive={false}>
|
||||
<VisibilityPicker
|
||||
value={agent.visibility}
|
||||
canEdit={canEdit}
|
||||
onChange={(v) => update({ visibility: v })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Concurrency" interactive={false}>
|
||||
<ConcurrencyPicker
|
||||
value={agent.max_concurrent_tasks}
|
||||
canEdit={canEdit}
|
||||
onChange={(n) => update({ max_concurrent_tasks: n })}
|
||||
/>
|
||||
</PropRow>
|
||||
@@ -162,7 +189,7 @@ export function AgentDetailInspector({
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
<SkillAttach agent={agent} />
|
||||
<SkillAttach agent={agent} canEdit={canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -181,11 +208,13 @@ function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 border-b px-5 py-4">
|
||||
<div className="mb-1 px-2 -mx-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<div className="border-b px-5 py-4">
|
||||
<div className="mb-1 -mx-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -196,14 +225,29 @@ function Section({
|
||||
|
||||
function AvatarEditor({
|
||||
agent,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={56}
|
||||
className="rounded-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -256,11 +300,32 @@ function AvatarEditor({
|
||||
|
||||
function NameAndDescription({
|
||||
agent,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold leading-tight">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.description ? (
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<InlineEditPopover
|
||||
@@ -283,34 +348,139 @@ function NameAndDescription({
|
||||
)}
|
||||
</InlineEditPopover>
|
||||
|
||||
<InlineEditPopover
|
||||
<DescriptionEditor
|
||||
value={agent.description ?? ""}
|
||||
onSave={(v) => onUpdate({ description: v })}
|
||||
kind="textarea"
|
||||
title="Edit description"
|
||||
placeholder="What does this agent do?"
|
||||
>
|
||||
{(triggerProps) => (
|
||||
<button
|
||||
type="button"
|
||||
{...triggerProps}
|
||||
className="group -mx-1 inline-flex items-start gap-1.5 self-start rounded px-1 text-left text-xs leading-relaxed transition-colors hover:bg-accent/50"
|
||||
>
|
||||
{agent.description ? (
|
||||
<span className="text-muted-foreground">{agent.description}</span>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground/50">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
<Pencil className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</InlineEditPopover>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Description editor — modal because the description benefits from a roomy
|
||||
// composition surface (the inline popover was 288 px wide × 3 rows, too
|
||||
// cramped to read or edit anything substantial). Name stays in the inline
|
||||
// popover above: a single line is the right shape for it.
|
||||
//
|
||||
// The editor body is split into a child component that mounts only while
|
||||
// the dialog is open. That way the draft state is initialised from `value`
|
||||
// at mount time and never reset by an external update mid-edit — closing
|
||||
// the dialog unmounts the body, reopening starts fresh with the latest
|
||||
// value. This is the React-recommended replacement for the
|
||||
// `useEffect(reset, [value])` anti-pattern (see "You Might Not Need an
|
||||
// Effect" — Resetting state with a key / mount).
|
||||
function DescriptionEditor({
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (next: string) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group -mx-1 inline-flex items-start gap-1.5 self-start rounded px-1 text-left text-xs leading-relaxed transition-colors hover:bg-accent/50"
|
||||
>
|
||||
{value ? (
|
||||
<span className="text-muted-foreground">{value}</span>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground/50">No description</span>
|
||||
)}
|
||||
<Pencil className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
{open && (
|
||||
<DescriptionEditorBody
|
||||
initialValue={value}
|
||||
onSave={onSave}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionEditorBody({
|
||||
initialValue,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initialValue: string;
|
||||
onSave: (next: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const length = [...draft].length;
|
||||
const overLimit = length > AGENT_DESCRIPTION_MAX_LENGTH;
|
||||
const dirty = draft !== initialValue;
|
||||
|
||||
const commit = async () => {
|
||||
if (overLimit || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(draft);
|
||||
onClose();
|
||||
} catch {
|
||||
// toast handled by parent's onUpdate
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit description</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="What does this agent do?"
|
||||
rows={6}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-input"
|
||||
/>
|
||||
<CharCounter length={length} max={AGENT_DESCRIPTION_MAX_LENGTH} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving || overLimit || !dirty}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Generic single-field popover editor used for name / description. Keeps the
|
||||
// trigger styling fully in the caller's hands by using a render prop.
|
||||
function InlineEditPopover({
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { useAgentPermissions } from "@multica/core/permissions";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -74,6 +76,12 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
// Permission hook MUST be called unconditionally — its `agent | null`
|
||||
// signature handles the not-found / loading case internally so the early
|
||||
// returns below don't violate the rules of hooks. Backend gates archive
|
||||
// and restore identically to edit, so a single `canEdit` covers them all.
|
||||
const { canEdit } = useAgentPermissions(agent, wsId);
|
||||
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
@@ -163,23 +171,36 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
agent={agent}
|
||||
presence={presence}
|
||||
backHref={paths.agents()}
|
||||
canArchive={canEdit.allowed}
|
||||
onArchive={() => setConfirmArchive(true)}
|
||||
/>
|
||||
|
||||
{!canEdit.allowed && (
|
||||
<div className="px-6 pt-3">
|
||||
<CapabilityBanner
|
||||
reason={canEdit.reason}
|
||||
resource="agent"
|
||||
ownerName={owner?.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isArchived && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/50 px-6 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
This agent is archived. It cannot be assigned or mentioned.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
{canEdit.allowed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,6 +213,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
canEdit={canEdit.allowed}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
|
||||
@@ -254,11 +276,13 @@ function DetailHeader({
|
||||
agent,
|
||||
presence,
|
||||
backHref,
|
||||
canArchive,
|
||||
onArchive,
|
||||
}: {
|
||||
agent: Agent;
|
||||
presence: AgentPresenceDetail | null;
|
||||
backHref: string;
|
||||
canArchive: boolean;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const isArchived = !!agent.archived_at;
|
||||
@@ -290,7 +314,7 @@ function DetailHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isArchived && (
|
||||
{!isArchived && canArchive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" />}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud, Lock, Monitor } from "lucide-react";
|
||||
import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import {
|
||||
type AgentActivity,
|
||||
type AgentPresenceDetail,
|
||||
summarizeActivityWindow,
|
||||
} from "@multica/core/agents";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { availabilityConfig, taskStateConfig } from "../presence";
|
||||
import { AgentPresenceIndicator } from "./agent-presence-indicator";
|
||||
import { AgentRowActions } from "./agent-row-actions";
|
||||
import { Sparkline } from "./sparkline";
|
||||
|
||||
// Shared grid template used by both the list header and every row, so the
|
||||
// sticky header columns stay aligned with the rows below it.
|
||||
//
|
||||
// Why grid (not <Table table-fixed>): table-fixed forces every column to a
|
||||
// declared width, which means the Status column was always reserving space
|
||||
// for the worst case ("Working · 0 / 6 · +5 queued"), even when no agent in
|
||||
// the workspace was in that state. Switching to grid + `max-content` lets the
|
||||
// column shrink automatically when the longest cell is just "Available", and
|
||||
// only widen when there's an agent that actually needs the room. The freed
|
||||
// space flows into the Agent column (the primary content), via its `1.6fr`
|
||||
// share — same ratio Skills uses, so the two list pages read as one family.
|
||||
//
|
||||
// Leading-avatar column (1.75rem) is a dedicated grid track so the header's
|
||||
// "Agent" label sits on the same x as the row's name text. Without this the
|
||||
// avatar (28px) + the internal flex gap pushes the name 40px right of the
|
||||
// header label, which reads as misalignment. Same pattern is used in Runtimes
|
||||
// (icon-box and Health dot extracted into their own tracks).
|
||||
//
|
||||
// Responsive strategy mirrors the previous table:
|
||||
// <md → [avatar] Agent · Status (compact dot only) · Actions
|
||||
// md+ → adds Last run + Runtime + Runs
|
||||
// lg+ → adds Activity sparkline
|
||||
// Cells use `hidden md:block` / `hidden lg:block` so they participate in the
|
||||
// grid only at their breakpoint; the grid template at each tier matches the
|
||||
// number of visible cells exactly.
|
||||
//
|
||||
// Two presence columns at md+ (Status + Last run) instead of one merged
|
||||
// cell — splitting them lets the user scan each axis independently. The
|
||||
// dot column is the same 3-color availability everywhere; the Last run
|
||||
// column shows the task icon + label (running/completed/failed/etc).
|
||||
export const AGENT_LIST_GRID =
|
||||
"grid items-center gap-4 " +
|
||||
"grid-cols-[1.75rem_minmax(0,1fr)_max-content_2.5rem] " +
|
||||
"md:grid-cols-[1.75rem_minmax(0,1.4fr)_5rem_minmax(0,max-content)_minmax(0,0.8fr)_4rem_2.5rem] " +
|
||||
"lg:grid-cols-[1.75rem_minmax(0,1.5fr)_5rem_minmax(0,max-content)_minmax(0,0.8fr)_5rem_4rem_2.5rem]";
|
||||
|
||||
interface AgentListItemProps {
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
// 30d activity series for this agent. Page derives once for the whole
|
||||
// workspace and passes a row-specific slice here, so the row component
|
||||
// doesn't subscribe to its own query (avoids N timers / N subscriptions).
|
||||
// The list only surfaces the trailing 7 days; we still take the 30d
|
||||
// shape so the cache stays aligned with the agent detail panel.
|
||||
activity: AgentActivity | null | undefined;
|
||||
// 30-day cumulative run count for the RUNS column. Same single-source pattern.
|
||||
runCount: number | null | undefined;
|
||||
// Inline owner avatar — non-null when the page wants to attribute the
|
||||
// agent to a teammate (typically All scope on someone else's agent).
|
||||
// The page does the "scope === all && owner !== me" decision so the row
|
||||
// stays pure presentation.
|
||||
ownerIdToShow: string | null;
|
||||
// True when the current user can archive / cancel-tasks on this agent.
|
||||
// Mirrors the back-end's canManageAgent check; the row uses it to gate
|
||||
// entries in the actions dropdown.
|
||||
canManage: boolean;
|
||||
// Page-level callback to open Create dialog with this agent as a
|
||||
// template (Duplicate action).
|
||||
onDuplicate: (agent: Agent) => void;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function AgentListItem({
|
||||
agent,
|
||||
runtime,
|
||||
presence,
|
||||
activity,
|
||||
runCount,
|
||||
ownerIdToShow,
|
||||
canManage,
|
||||
onDuplicate,
|
||||
href,
|
||||
}: AgentListItemProps) {
|
||||
const isArchived = !!agent.archived_at;
|
||||
const isPrivate = agent.visibility === "private";
|
||||
const isCloud = agent.runtime_mode === "cloud";
|
||||
const RuntimeIcon = isCloud ? Cloud : Monitor;
|
||||
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
href={href}
|
||||
className={`${AGENT_LIST_GRID} group border-b px-4 py-3 text-sm transition-colors last:border-b-0 hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none`}
|
||||
>
|
||||
{/* Avatar — dedicated leading column so the header label "Agent"
|
||||
aligns with the name text below it. */}
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={28}
|
||||
className={`rounded-md ${isArchived ? "opacity-50 grayscale" : ""}`}
|
||||
showStatusDot
|
||||
/>
|
||||
|
||||
{/* Agent — primary text column, eats remaining space. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`truncate font-medium ${
|
||||
isArchived ? "text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{agent.name}
|
||||
</span>
|
||||
{/* Lock = private visibility — back-end rejects assignment by
|
||||
non-owners, so flag it visually. We deliberately do NOT
|
||||
filter private agents out of the list (mirrors server's
|
||||
ListAgents behaviour); the icon warns the viewer that
|
||||
picking this in a picker will fail. */}
|
||||
{isPrivate && !isArchived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Private — only the owner can assign work
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Owner attribution — only set in "All" scope when the agent
|
||||
isn't yours. Tiny avatar (14px) keeps it lightweight; the
|
||||
hover card on the agent's main avatar already covers the
|
||||
full owner detail. */}
|
||||
{ownerIdToShow && (
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
actorId={ownerIdToShow}
|
||||
size={14}
|
||||
/>
|
||||
)}
|
||||
{isArchived && (
|
||||
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 line-clamp-1 text-xs ${
|
||||
agent.description
|
||||
? "text-muted-foreground"
|
||||
: "italic text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{agent.description || "No description"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status — availability dimension only. Compact dot under md;
|
||||
dot + label at md+. Always 3 colours. */}
|
||||
<div className="flex items-center">
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="md:hidden">
|
||||
<AgentPresenceIndicator detail={presence} compact />
|
||||
</span>
|
||||
<span className="hidden md:inline-flex">
|
||||
<AvailabilityCell presence={presence} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last run — md+. Task icon + label + (running counts | reason | time).
|
||||
Dedicated column so the user can scan "what just happened" without
|
||||
merging it into the availability cell. */}
|
||||
<div className="hidden items-center md:flex">
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
<LastRunCell presence={presence} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Runtime — md+. Sans-font label fits ~25% more chars per pixel than
|
||||
the previous mono treatment, so most hostnames no longer truncate. */}
|
||||
<div className="hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground md:flex">
|
||||
<RuntimeIcon className="h-3 w-3 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="min-w-0 truncate">{runtimeLabel}</span>}
|
||||
/>
|
||||
<TooltipContent>{runtimeLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Activity (7d) — lg+. The 7d sparkline is sliced off the 30d
|
||||
workspace cache here (single source of truth shared with the
|
||||
detail panel); no extra request. */}
|
||||
<div className="hidden lg:block">
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground/50">—</span>
|
||||
) : !activity ? (
|
||||
<span
|
||||
className="inline-block animate-pulse rounded bg-muted/60"
|
||||
style={{ width: 64, height: 20 }}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="inline-flex cursor-default items-center">
|
||||
<Sparkline
|
||||
buckets={summarizeActivityWindow(activity, 7).buckets}
|
||||
width={64}
|
||||
height={20}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<ActivityTooltip activity={activity} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Runs (30d) — md+. tabular-nums + right-align keeps the digit column
|
||||
visually clean across orders of magnitude. */}
|
||||
<div className="hidden text-right font-mono text-xs tabular-nums text-muted-foreground md:block">
|
||||
{runCount == null ? "—" : runCount.toLocaleString()}
|
||||
</div>
|
||||
|
||||
{/* Actions — kebab dropdown, always visible. Empty cell is preserved
|
||||
(the column has fixed width) when the user has no operable
|
||||
actions, so column alignment stays stable across rows. */}
|
||||
<div className="flex justify-end">
|
||||
<AgentRowActions
|
||||
agent={agent}
|
||||
presence={presence}
|
||||
canManage={canManage}
|
||||
onDuplicate={onDuplicate}
|
||||
/>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
// Availability cell — dot + label, colour from availabilityConfig. Three
|
||||
// states only; the colour reflects "can the agent take work right now".
|
||||
function AvailabilityCell({
|
||||
presence,
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
if (!presence) {
|
||||
return <span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />;
|
||||
}
|
||||
const av = availabilityConfig[presence.availability];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Last-run cell — task icon + label, with running counts only when active.
|
||||
// No timestamp: it eats horizontal space and the relative age of a
|
||||
// completed task isn't actionable in a scan. Hidden when idle so brand-
|
||||
// new agents show "—" instead of "Idle" everywhere.
|
||||
function LastRunCell({
|
||||
presence,
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
if (!presence) {
|
||||
return <span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />;
|
||||
}
|
||||
if (presence.lastTask === "idle") {
|
||||
return <span className="text-xs text-muted-foreground/50">—</span>;
|
||||
}
|
||||
const ts = taskStateConfig[presence.lastTask];
|
||||
const isRunning = presence.lastTask === "running";
|
||||
const counts =
|
||||
isRunning && presence.queuedCount > 0
|
||||
? `${presence.runningCount}/${presence.capacity} +${presence.queuedCount}q`
|
||||
: isRunning
|
||||
? `${presence.runningCount}/${presence.capacity}`
|
||||
: null;
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-xs">
|
||||
<ts.icon className={`h-3 w-3 shrink-0 ${ts.textClass}`} />
|
||||
<span className={`shrink-0 ${ts.textClass}`}>{ts.label}</span>
|
||||
{counts && (
|
||||
<span className="truncate text-muted-foreground">{counts}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip body for the activity sparkline. Header = window label, body =
|
||||
* the actual counts (rolled up from the same 7-day slice the bars
|
||||
* render). Two short lines so the eye lands on the numbers immediately
|
||||
* on hover.
|
||||
*/
|
||||
function ActivityTooltip({ activity }: { activity: AgentActivity }) {
|
||||
const summary = summarizeActivityWindow(activity, 7);
|
||||
const { totalRuns, totalFailed } = summary;
|
||||
const { daysSinceCreated } = activity;
|
||||
|
||||
// Header: when the agent is younger than the rendered window, label by
|
||||
// age so an empty sparkline reads as "new agent" not "broken agent".
|
||||
const isPartial = daysSinceCreated < 7;
|
||||
const headerText = isPartial
|
||||
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
|
||||
: "Last 7 days";
|
||||
|
||||
let bodyText: string;
|
||||
if (totalRuns === 0) {
|
||||
bodyText = "No activity";
|
||||
} else {
|
||||
const failedFragment =
|
||||
totalFailed > 0
|
||||
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
|
||||
: "";
|
||||
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{headerText}
|
||||
</span>
|
||||
<span className="text-xs">{bodyText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import type { AgentPresenceDetail } from "@multica/core/agents";
|
||||
import { availabilityConfig, taskStateConfig } from "../presence";
|
||||
import { availabilityConfig, workloadConfig } from "../presence";
|
||||
|
||||
interface PresenceIndicatorProps {
|
||||
// null/undefined = still loading. Caller passes the detail computed at
|
||||
@@ -10,16 +10,16 @@ interface PresenceIndicatorProps {
|
||||
// views). Keeping this as a prop avoids per-row hook subscriptions in
|
||||
// long lists.
|
||||
detail: AgentPresenceDetail | null | undefined;
|
||||
// Compact = dot only, no label / no last-task chip. Used in dense rows.
|
||||
// Compact = dot only, no label / no workload chip. Used in dense rows.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an agent's two-dimension presence: an availability dot + an
|
||||
* optional last-task chip. The dot's colour reads only from the
|
||||
* optional workload chip. The dot's colour reads only from the
|
||||
* availability dimension (3 colours), so a runtime-healthy agent whose
|
||||
* last task failed shows a green dot + a red "Failed" chip — the dot
|
||||
* stops being sticky-red.
|
||||
* last task failed shows a green dot — workload no longer carries
|
||||
* historical state at all.
|
||||
*
|
||||
* Compact mode collapses to dot-only — used in dense surfaces where the
|
||||
* full chip would crowd the row.
|
||||
@@ -42,15 +42,24 @@ export function AgentPresenceIndicator({
|
||||
}
|
||||
|
||||
const av = availabilityConfig[detail.availability];
|
||||
const ts = taskStateConfig[detail.lastTask];
|
||||
const isRunning = detail.lastTask === "running";
|
||||
const showQueueBadge = isRunning && detail.queuedCount > 0;
|
||||
const wl = workloadConfig[detail.workload];
|
||||
const isWorking = detail.workload === "working";
|
||||
const isQueued = detail.workload === "queued";
|
||||
const showQueueBadge = isWorking && detail.queuedCount > 0;
|
||||
// Queued's amber comes from workloadConfig as the *severe* tone — meant
|
||||
// for "stuck on offline runtime", which is the dominant cause. But on a
|
||||
// healthy runtime, queued is just a brief race between enqueue and the
|
||||
// daemon's claim, and amber there reads as a warning that isn't there.
|
||||
// Compose with availability: online ⇒ muted (transient), otherwise ⇒
|
||||
// keep amber (genuine stuck signal).
|
||||
const queuedTone =
|
||||
detail.availability === "online" ? "text-muted-foreground" : wl.textClass;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center"
|
||||
title={`${av.label}${detail.lastTask !== "idle" ? ` · ${ts.label}` : ""}`}
|
||||
title={`${av.label}${detail.workload !== "idle" ? ` · ${wl.label}` : ""}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
</span>
|
||||
@@ -65,24 +74,39 @@ export function AgentPresenceIndicator({
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</span>
|
||||
|
||||
{/* Last task — separator + label, with running counts when active.
|
||||
Hidden for `idle` to keep brand-new agents clean. */}
|
||||
{detail.lastTask !== "idle" && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className={`text-xs ${ts.textClass}`}>{ts.label}</span>
|
||||
{isRunning && (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{detail.runningCount} / {detail.capacity}
|
||||
</span>
|
||||
)}
|
||||
{showQueueBadge && (
|
||||
<span className="rounded-md bg-muted px-1 py-0 text-xs font-medium text-muted-foreground">
|
||||
+{detail.queuedCount} queued
|
||||
</span>
|
||||
)}
|
||||
{/* Workload — separator + label, with counts when working/queued.
|
||||
All three workload states render here for symmetry: idle gets
|
||||
its own "Idle" label so the difference between "no presence
|
||||
data" (no chip at all) and "agent is idle" (explicit Idle chip)
|
||||
is visible. */}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isQueued ? queuedTone : wl.textClass
|
||||
}`}
|
||||
>
|
||||
{wl.label}
|
||||
</span>
|
||||
)}
|
||||
{isWorking && (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{detail.runningCount} / {detail.capacity}
|
||||
</span>
|
||||
)}
|
||||
{showQueueBadge && (
|
||||
<span className="rounded-md bg-muted px-1 py-0 text-xs font-medium text-muted-foreground">
|
||||
+{detail.queuedCount} queued
|
||||
</span>
|
||||
)}
|
||||
{/* Queued (no running) — show the queued count directly, since
|
||||
there's no running/capacity ratio to anchor on. Honestly
|
||||
surfaces "stuck" on offline runtimes. */}
|
||||
{isQueued && (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{detail.queuedCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { HealthIcon } from "../../runtimes/components/shared";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { VisibilityBadge } from "./visibility-badge";
|
||||
|
||||
interface AgentProfileCardProps {
|
||||
agentId: string;
|
||||
@@ -81,6 +82,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-semibold">{agent.name}</p>
|
||||
{!isArchived && <VisibilityBadge value={agent.visibility} compact />}
|
||||
{isArchived && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
Archived
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import type { Agent, AgentRuntime, CreateAgentRequest } from "@multica/core/types";
|
||||
import {
|
||||
type AgentAvailability,
|
||||
type LastTaskState,
|
||||
agentRunCounts30dOptions,
|
||||
summarizeActivityWindow,
|
||||
useWorkspaceActivityMap,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { canAssignAgentToIssue } from "@multica/core/permissions";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
agentListOptions,
|
||||
@@ -38,56 +39,27 @@ import {
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { DataTable } from "@multica/ui/components/ui/data-table";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import {
|
||||
availabilityConfig,
|
||||
availabilityOrder,
|
||||
lastTaskOrder,
|
||||
taskStateConfig,
|
||||
} from "../presence";
|
||||
import { availabilityConfig, availabilityOrder } from "../presence";
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
import { AGENT_LIST_GRID, AgentListItem } from "./agent-list-item";
|
||||
import { type AgentRow, createAgentColumns } from "./agent-columns";
|
||||
|
||||
// Filter axes layered top → bottom by frequency:
|
||||
// Filter axes:
|
||||
//
|
||||
// View = which dataset are we looking at (Active vs Archived).
|
||||
// Archived is low-frequency, so it is NOT a top-level
|
||||
// segment — it is a ghost link in the toolbar.
|
||||
// View = active vs archived dataset. Archived is low-frequency,
|
||||
// accessed through a ghost link in the toolbar.
|
||||
// Scope = ownership lens (All vs Mine). Layer-1 segment.
|
||||
// Availability = "Can it take work?" — 3-state chip group.
|
||||
// Last task = "What was the last thing it did?" — 5-state chip group.
|
||||
//
|
||||
// Availability and Last task are independent axes (Option B). Filter is
|
||||
// the intersection: "online + last failed" is a meaningful combination
|
||||
// (find broken-but-alive agents). Counts on each chip reflect "if I
|
||||
// selected this chip on this axis (with the other axis's current
|
||||
// selection), this many agents would match".
|
||||
// Availability = "Can the agent take work right now?" — 3-state chip
|
||||
// group (online / unstable / offline) sourced from
|
||||
// AgentAvailability. The only chip filter we keep —
|
||||
// the previous Workload axis was dropped because its
|
||||
// "queued / failed / cancelled" buckets became
|
||||
// meaningless once Failed left the workload model.
|
||||
type View = "active" | "archived";
|
||||
type Scope = "all" | "mine";
|
||||
type AvailabilityFilter = "all" | AgentAvailability;
|
||||
type LastTaskFilter = "all" | LastTaskState;
|
||||
|
||||
const AVAILABILITY_DESCRIPTION: Record<AgentAvailability, string> = {
|
||||
online: "Runtime online — agent ready to take work",
|
||||
unstable:
|
||||
"Runtime just dropped (< 5 min) — queued work is paused, system is auto-retrying",
|
||||
offline: "Runtime unreachable",
|
||||
};
|
||||
|
||||
const LAST_TASK_DESCRIPTION: Record<LastTaskState, string> = {
|
||||
running: "At least one task running or queued right now",
|
||||
completed: "Most recent task completed successfully",
|
||||
failed: "Most recent task failed — needs attention",
|
||||
cancelled: "Most recent task was cancelled",
|
||||
idle: "No task history yet",
|
||||
};
|
||||
|
||||
type SortKey = "recent" | "name" | "runs" | "created";
|
||||
const SORT_KEYS: SortKey[] = ["recent", "name", "runs", "created"];
|
||||
@@ -131,7 +103,6 @@ export function AgentsPage() {
|
||||
const [scope, setScope] = useState<Scope>("mine");
|
||||
const [availabilityFilter, setAvailabilityFilter] =
|
||||
useState<AvailabilityFilter>("all");
|
||||
const [lastTaskFilter, setLastTaskFilter] = useState<LastTaskFilter>("all");
|
||||
const [sort, setSort] = useState<SortKey>("recent");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
@@ -143,9 +114,6 @@ export function AgentsPage() {
|
||||
null,
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
|
||||
const runtimesById = useMemo(() => {
|
||||
const m = new Map<string, AgentRuntime>();
|
||||
for (const r of runtimes) m.set(r.id, r);
|
||||
@@ -176,102 +144,82 @@ export function AgentsPage() {
|
||||
[agents, view],
|
||||
);
|
||||
|
||||
// Layer 1b — ownership scope. Counts shown on the segment are
|
||||
// computed against the inView set so the numbers always reflect
|
||||
// Layer 1b — visibility. Personal (visibility=private) agents owned by
|
||||
// someone else are hidden from regular members; workspace owners/admins
|
||||
// still see everything. Mirrors the assign-to-issue gate so the list
|
||||
// only ever shows agents the user could actually act on. Backend keeps
|
||||
// returning all agents, so admin tools (and the API itself) are
|
||||
// unaffected — this is a UI-only filter.
|
||||
const visibleInView = useMemo(() => {
|
||||
return inView.filter((a) =>
|
||||
canAssignAgentToIssue(a, {
|
||||
userId: currentUser?.id ?? null,
|
||||
role: myRole,
|
||||
}).allowed,
|
||||
);
|
||||
}, [inView, currentUser?.id, myRole]);
|
||||
|
||||
// Layer 1c — ownership scope. Counts shown on the segment are
|
||||
// computed against the visibleInView set so the numbers always reflect
|
||||
// "what would I see if I clicked this".
|
||||
const scopeCounts = useMemo(() => {
|
||||
let mine = 0;
|
||||
if (currentUser) {
|
||||
for (const a of inView) {
|
||||
for (const a of visibleInView) {
|
||||
if (a.owner_id === currentUser.id) mine += 1;
|
||||
}
|
||||
}
|
||||
return { all: inView.length, mine };
|
||||
}, [inView, currentUser]);
|
||||
return { all: visibleInView.length, mine };
|
||||
}, [visibleInView, currentUser]);
|
||||
|
||||
const inScope = useMemo(() => {
|
||||
if (scope === "all" || !currentUser) return inView;
|
||||
return inView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [inView, scope, currentUser]);
|
||||
// Archived view ignores Mine / All — its toolbar has no scope
|
||||
// segment, so silently filtering by `scope` would hide other
|
||||
// people's archived agents without any UI to explain why.
|
||||
if (view === "archived") return visibleInView;
|
||||
if (scope === "all" || !currentUser) return visibleInView;
|
||||
return visibleInView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [visibleInView, scope, currentUser, view]);
|
||||
|
||||
// Layer 2 — chip counts on each axis. Counts cross-filter against the
|
||||
// OTHER axis so the displayed number is "if I clicked this chip with
|
||||
// the other axis as-is, this many agents would match". Stable mental
|
||||
// model: numbers don't dance unless the user actually changes scope.
|
||||
// Final cut — availability chip + search.
|
||||
const filteredAgents = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return inScope.filter((a) => {
|
||||
// Availability chip filter only applies to the Active view —
|
||||
// archived agents have no presence to match against.
|
||||
if (view === "active" && availabilityFilter !== "all") {
|
||||
const detail = presenceMap.get(a.id);
|
||||
if (detail?.availability !== availabilityFilter) return false;
|
||||
}
|
||||
if (q) {
|
||||
if (
|
||||
!a.name.toLowerCase().includes(q) &&
|
||||
!(a.description ?? "").toLowerCase().includes(q)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [inScope, view, availabilityFilter, presenceMap, search]);
|
||||
|
||||
// Per-availability counts for the chip badges. Computed against
|
||||
// `inScope` (ignoring the availability filter itself) so the numbers
|
||||
// reflect "if I clicked this chip, this many agents would match"
|
||||
// rather than collapsing to 0 for the unselected chips.
|
||||
const availabilityCounts = useMemo(() => {
|
||||
const counts: Record<AgentAvailability, number> = {
|
||||
online: 0,
|
||||
unstable: 0,
|
||||
offline: 0,
|
||||
};
|
||||
let total = 0;
|
||||
for (const a of inScope) {
|
||||
const detail = presenceMap.get(a.id);
|
||||
if (!detail) continue;
|
||||
if (lastTaskFilter !== "all" && detail.lastTask !== lastTaskFilter) {
|
||||
continue;
|
||||
}
|
||||
counts[detail.availability] += 1;
|
||||
total += 1;
|
||||
}
|
||||
return { counts, total };
|
||||
}, [inScope, presenceMap, lastTaskFilter]);
|
||||
|
||||
const lastTaskCounts = useMemo(() => {
|
||||
const counts: Record<LastTaskState, number> = {
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
idle: 0,
|
||||
};
|
||||
let total = 0;
|
||||
for (const a of inScope) {
|
||||
const detail = presenceMap.get(a.id);
|
||||
if (!detail) continue;
|
||||
if (
|
||||
availabilityFilter !== "all" &&
|
||||
detail.availability !== availabilityFilter
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
counts[detail.lastTask] += 1;
|
||||
total += 1;
|
||||
}
|
||||
return { counts, total };
|
||||
}, [inScope, presenceMap, availabilityFilter]);
|
||||
|
||||
// Final cut — apply both axes + search.
|
||||
const filteredAgents = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return inScope.filter((a) => {
|
||||
// Filter chips only apply to the Active view; Archived hides the
|
||||
// chip rows entirely (presence is undefined for archived agents).
|
||||
if (view === "active") {
|
||||
const detail = presenceMap.get(a.id);
|
||||
if (
|
||||
availabilityFilter !== "all" &&
|
||||
detail?.availability !== availabilityFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
lastTaskFilter !== "all" &&
|
||||
detail?.lastTask !== lastTaskFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (q) {
|
||||
if (
|
||||
!a.name.toLowerCase().includes(q) &&
|
||||
!(a.description ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [inScope, availabilityFilter, lastTaskFilter, view, search, presenceMap]);
|
||||
return counts;
|
||||
}, [inScope, presenceMap]);
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
const xs = [...filteredAgents];
|
||||
@@ -347,16 +295,67 @@ export function AgentsPage() {
|
||||
// Surfaced softly; the agent itself is fine.
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
navigation.push(paths.agentDetail(agent.id));
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
};
|
||||
|
||||
const handleDuplicate = (agent: Agent) => {
|
||||
const handleDuplicate = useCallback((agent: Agent) => {
|
||||
setDuplicateTemplate(agent);
|
||||
setShowCreate(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Assemble per-row data once per render — agent + runtime + presence +
|
||||
// activity + role flags. The columns reach into `row.original` and never
|
||||
// pull their own queries, which keeps each cell a pure function.
|
||||
const agentRows = useMemo<AgentRow[]>(() => {
|
||||
return sortedAgents.map((agent) => {
|
||||
const isOwner =
|
||||
!!currentUser?.id && agent.owner_id === currentUser.id;
|
||||
const canManage = isWorkspaceAdmin || isOwner;
|
||||
const ownerIdToShow =
|
||||
scope === "all" &&
|
||||
agent.owner_id &&
|
||||
agent.owner_id !== currentUser?.id
|
||||
? agent.owner_id
|
||||
: null;
|
||||
return {
|
||||
agent,
|
||||
runtime: runtimesById.get(agent.runtime_id) ?? null,
|
||||
presence: presenceMap.get(agent.id) ?? null,
|
||||
activity: activityMap.get(agent.id) ?? null,
|
||||
runCount: runCountsById.get(agent.id) ?? 0,
|
||||
ownerIdToShow,
|
||||
isOwnedByMe: isOwner,
|
||||
canManage,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
sortedAgents,
|
||||
currentUser,
|
||||
isWorkspaceAdmin,
|
||||
scope,
|
||||
runtimesById,
|
||||
presenceMap,
|
||||
activityMap,
|
||||
runCountsById,
|
||||
]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createAgentColumns({ onDuplicate: handleDuplicate }),
|
||||
[handleDuplicate],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: agentRows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableColumnResizing: true,
|
||||
// Pin the kebab column right so it stays accessible during horizontal
|
||||
// scroll — matches the pattern in Linear / Notion / GitHub.
|
||||
initialState: { columnPinning: { right: ["actions"] } },
|
||||
});
|
||||
|
||||
// ---- Loading ----
|
||||
if (isLoading) {
|
||||
@@ -439,21 +438,17 @@ export function AgentsPage() {
|
||||
setSort={setSort}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
<PresenceFilterRows
|
||||
availabilityFilter={availabilityFilter}
|
||||
setAvailabilityFilter={setAvailabilityFilter}
|
||||
availabilityCounts={availabilityCounts.counts}
|
||||
availabilityTotal={availabilityCounts.total}
|
||||
lastTaskFilter={lastTaskFilter}
|
||||
setLastTaskFilter={setLastTaskFilter}
|
||||
lastTaskCounts={lastTaskCounts.counts}
|
||||
lastTaskTotal={lastTaskCounts.total}
|
||||
visibleCount={sortedAgents.length}
|
||||
totalCount={inScope.length}
|
||||
archivedCount={archivedCount}
|
||||
onShowArchived={() => setView("archived")}
|
||||
/>
|
||||
<AvailabilityFilterRow
|
||||
value={availabilityFilter}
|
||||
onChange={setAvailabilityFilter}
|
||||
counts={availabilityCounts}
|
||||
totalCount={inScope.length}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ArchivedToolbarRow
|
||||
@@ -465,76 +460,14 @@ export function AgentsPage() {
|
||||
)}
|
||||
|
||||
{sortedAgents.length === 0 ? (
|
||||
<NoMatches
|
||||
view={view}
|
||||
search={search}
|
||||
hasFilter={
|
||||
availabilityFilter !== "all" || lastTaskFilter !== "all"
|
||||
}
|
||||
scope={scope}
|
||||
/>
|
||||
<NoMatches view={view} search={search} scope={scope} />
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
{/*
|
||||
Layout strategy — CSS Grid + `max-content` on Status, ratio
|
||||
fr's elsewhere. The Status column shrinks to fit when no
|
||||
agent is in a high-load Working state, and only widens
|
||||
when the data demands it; the freed space flows into the
|
||||
Agent (1.6fr) primary column. See AGENT_LIST_GRID for the
|
||||
full breakpoint ladder. Sticky header reuses the same grid
|
||||
template so column edges align with rows pixel-for-pixel.
|
||||
*/}
|
||||
<div
|
||||
role="row"
|
||||
className={`${AGENT_LIST_GRID} sticky top-0 z-10 border-b bg-muted/30 px-4 py-2 text-xs font-medium uppercase tracking-wider text-muted-foreground backdrop-blur`}
|
||||
>
|
||||
{/* Avatar leading slot — empty header cell so the "Agent"
|
||||
label below aligns with the row's name text, not the
|
||||
avatar's left edge. */}
|
||||
<span aria-hidden />
|
||||
<span>Agent</span>
|
||||
<span>Status</span>
|
||||
<span className="hidden md:block">Last run</span>
|
||||
<span className="hidden md:block">Runtime</span>
|
||||
<span className="hidden lg:block">Activity (7d)</span>
|
||||
<span className="hidden text-right md:block">Runs</span>
|
||||
{/* Operations column header — kept silent; the kebab
|
||||
cell speaks for itself. */}
|
||||
<span aria-label="Actions" />
|
||||
</div>
|
||||
{sortedAgents.map((agent) => {
|
||||
const isOwner =
|
||||
!!currentUser?.id && agent.owner_id === currentUser.id;
|
||||
const canManage = isWorkspaceAdmin || isOwner;
|
||||
// Inline owner avatar only in All scope on a teammate's
|
||||
// agent — Mine scope means owner is always you, so a
|
||||
// self-avatar everywhere would be visual noise.
|
||||
const ownerIdToShow =
|
||||
scope === "all" &&
|
||||
agent.owner_id &&
|
||||
agent.owner_id !== currentUser?.id
|
||||
? agent.owner_id
|
||||
: null;
|
||||
return (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
runtime={runtimesById.get(agent.runtime_id) ?? null}
|
||||
presence={presenceMap.get(agent.id) ?? null}
|
||||
activity={activityMap.get(agent.id) ?? null}
|
||||
runCount={runCountsById.get(agent.id) ?? 0}
|
||||
ownerIdToShow={ownerIdToShow}
|
||||
canManage={canManage}
|
||||
onDuplicate={handleDuplicate}
|
||||
href={paths.agentDetail(agent.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
onRowClick={(row) =>
|
||||
navigation.push(paths.agentDetail(row.original.agent.id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -616,6 +549,10 @@ function ActiveToolbarRow({
|
||||
setSort,
|
||||
search,
|
||||
setSearch,
|
||||
visibleCount,
|
||||
totalCount,
|
||||
archivedCount,
|
||||
onShowArchived,
|
||||
}: {
|
||||
scope: Scope;
|
||||
setScope: (v: Scope) => void;
|
||||
@@ -624,13 +561,18 @@ function ActiveToolbarRow({
|
||||
setSort: (v: SortKey) => void;
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
archivedCount: number;
|
||||
onShowArchived: () => void;
|
||||
}) {
|
||||
// Layout follows Skills: [Search] [Mine|All] [Sort ▼]
|
||||
// Search and the scope segment cluster on the left (Skills puts its
|
||||
// filter buttons immediately after the search the same way). Sort
|
||||
// gets pushed to the far right via ml-auto.
|
||||
// Layout: [Search] [Mine|All] ......... [Show archived] [N of M] [Sort ▼]
|
||||
// Filter chips were removed (status / workload chips on a small team
|
||||
// gain less than they cost), so the toolbar collapses to a single row.
|
||||
// Visible/total count and the archived link inherit their old position
|
||||
// from the deleted PresenceFilterRows.
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -641,7 +583,19 @@ function ActiveToolbarRow({
|
||||
/>
|
||||
</div>
|
||||
<ScopeSegment scope={scope} setScope={setScope} counts={scopeCounts} />
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowArchived}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Show archived ({archivedCount}) →
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
|
||||
{visibleCount} of {totalCount}
|
||||
</span>
|
||||
<SortDropdown sort={sort} setSort={setSort} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -747,161 +701,76 @@ function SortDropdown({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active view — Layer 2: two independent filter axes (availability + last
|
||||
// task) + visible/total count. The right edge hosts the low-frequency
|
||||
// "Show archived" link, kept out of Layer 1 so the primary toolbar stays
|
||||
// uncluttered.
|
||||
//
|
||||
// Two rows because cramming both axes into a single row makes the chip
|
||||
// labels feel ambiguous ("Online" and "Failed" side by side reads as a
|
||||
// single stack of facets, but they're on different axes). Two rows with
|
||||
// a leading label make the axis split obvious.
|
||||
// Availability chip row — All / Online / Unstable / Offline. Only shown
|
||||
// in the Active view; archived agents have no presence.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PresenceFilterRows({
|
||||
availabilityFilter,
|
||||
setAvailabilityFilter,
|
||||
availabilityCounts,
|
||||
availabilityTotal,
|
||||
lastTaskFilter,
|
||||
setLastTaskFilter,
|
||||
lastTaskCounts,
|
||||
lastTaskTotal,
|
||||
visibleCount,
|
||||
function AvailabilityFilterRow({
|
||||
value,
|
||||
onChange,
|
||||
counts,
|
||||
totalCount,
|
||||
archivedCount,
|
||||
onShowArchived,
|
||||
}: {
|
||||
availabilityFilter: AvailabilityFilter;
|
||||
setAvailabilityFilter: (v: AvailabilityFilter) => void;
|
||||
availabilityCounts: Record<AgentAvailability, number>;
|
||||
availabilityTotal: number;
|
||||
lastTaskFilter: LastTaskFilter;
|
||||
setLastTaskFilter: (v: LastTaskFilter) => void;
|
||||
lastTaskCounts: Record<LastTaskState, number>;
|
||||
lastTaskTotal: number;
|
||||
visibleCount: number;
|
||||
value: AvailabilityFilter;
|
||||
onChange: (v: AvailabilityFilter) => void;
|
||||
counts: Record<AgentAvailability, number>;
|
||||
totalCount: number;
|
||||
archivedCount: number;
|
||||
onShowArchived: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-1.5 border-b px-4 py-2.5">
|
||||
{/* Row 1: Availability — 3 chips. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
Status
|
||||
</span>
|
||||
<PresenceChip
|
||||
active={availabilityFilter === "all"}
|
||||
onClick={() => setAvailabilityFilter("all")}
|
||||
label="All"
|
||||
count={availabilityTotal}
|
||||
description="No availability filter"
|
||||
/>
|
||||
{availabilityOrder.map((a) => {
|
||||
const cfg = availabilityConfig[a];
|
||||
return (
|
||||
<PresenceChip
|
||||
key={a}
|
||||
active={availabilityFilter === a}
|
||||
onClick={() => setAvailabilityFilter(a)}
|
||||
label={cfg.label}
|
||||
count={availabilityCounts[a]}
|
||||
dotClass={cfg.dotClass}
|
||||
description={AVAILABILITY_DESCRIPTION[a]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowArchived}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Show archived ({archivedCount}) →
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
|
||||
{visibleCount} of {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: Last task — 5 chips. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
Last run
|
||||
</span>
|
||||
<PresenceChip
|
||||
active={lastTaskFilter === "all"}
|
||||
onClick={() => setLastTaskFilter("all")}
|
||||
label="All"
|
||||
count={lastTaskTotal}
|
||||
description="No last-run filter"
|
||||
/>
|
||||
{lastTaskOrder.map((t) => {
|
||||
const cfg = taskStateConfig[t];
|
||||
return (
|
||||
<PresenceChip
|
||||
key={t}
|
||||
active={lastTaskFilter === t}
|
||||
onClick={() => setLastTaskFilter(t)}
|
||||
label={cfg.label}
|
||||
count={lastTaskCounts[t]}
|
||||
description={LAST_TASK_DESCRIPTION[t]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-4">
|
||||
<AvailabilityChip
|
||||
active={value === "all"}
|
||||
onClick={() => onChange("all")}
|
||||
label="All"
|
||||
count={totalCount}
|
||||
/>
|
||||
{availabilityOrder.map((a) => {
|
||||
const cfg = availabilityConfig[a];
|
||||
return (
|
||||
<AvailabilityChip
|
||||
key={a}
|
||||
active={value === a}
|
||||
onClick={() => onChange(a)}
|
||||
label={cfg.label}
|
||||
count={counts[a]}
|
||||
dotClass={cfg.dotClass}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Same Button + Tooltip pattern Skills uses for its scope filters. Selected
|
||||
// state mirrors Skills' `bg-accent text-accent-foreground hover:bg-accent/80`,
|
||||
// so any future global tweak to that token cascades here for free.
|
||||
function PresenceChip({
|
||||
function AvailabilityChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
dotClass,
|
||||
description,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
dotClass?: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={
|
||||
active
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{dotClass && (
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground/70">
|
||||
{count}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{description}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={
|
||||
active
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{dotClass && <span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />}
|
||||
<span>{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground/70">
|
||||
{count}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -969,16 +838,17 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
function NoMatches({
|
||||
view,
|
||||
search,
|
||||
hasFilter: filterActive,
|
||||
scope,
|
||||
}: {
|
||||
view: View;
|
||||
search: string;
|
||||
hasFilter: boolean;
|
||||
scope: Scope;
|
||||
}) {
|
||||
const hasSearch = search.length > 0;
|
||||
const hasFilter = filterActive || scope === "mine";
|
||||
// "mine" is the only remaining narrowing dimension after chip filters
|
||||
// were dropped — keep the wording aware of it so an empty Mine view
|
||||
// doesn't suggest the workspace itself is empty.
|
||||
const hasFilter = scope === "mine";
|
||||
|
||||
let body: string;
|
||||
if (view === "archived") {
|
||||
|
||||
19
packages/views/agents/components/char-counter.tsx
Normal file
19
packages/views/agents/components/char-counter.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// Soft warn at 90 % of the cap, hard error past it. Shared between the
|
||||
// description editor (modal) and the create-agent dialog so both surfaces
|
||||
// read the same way. Renders a single inline line so it can sit under any
|
||||
// textarea / input without disturbing surrounding spacing.
|
||||
export function CharCounter({ length, max }: { length: number; max: number }) {
|
||||
const over = length > max;
|
||||
const near = !over && length >= Math.floor(max * 0.9);
|
||||
const tone = over
|
||||
? "text-destructive"
|
||||
: near
|
||||
? "text-warning"
|
||||
: "text-muted-foreground";
|
||||
return (
|
||||
<div className={`text-right text-xs tabular-nums ${tone}`}>
|
||||
{length} / {max}
|
||||
{over && ` · ${length - max} over limit`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,12 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AGENT_DESCRIPTION_MAX_LENGTH,
|
||||
VISIBILITY_DESCRIPTION,
|
||||
VISIBILITY_LABEL,
|
||||
} from "@multica/core/agents";
|
||||
import { CharCounter } from "./char-counter";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
@@ -175,8 +181,15 @@ export function CreateAgentDialog({
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this agent do?"
|
||||
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<CharCounter
|
||||
length={[...description].length}
|
||||
max={AGENT_DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -193,8 +206,10 @@ export function CreateAgentDialog({
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="text-xs text-muted-foreground">All members can assign</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{VISIBILITY_DESCRIPTION.workspace}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -208,8 +223,10 @@ export function CreateAgentDialog({
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="text-xs text-muted-foreground">Only you can assign</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{VISIBILITY_DESCRIPTION.private}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,25 @@ const MAX = 50;
|
||||
|
||||
export function ConcurrencyPicker({
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: number) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset draft from authoritative value whenever the popover (re-)opens or
|
||||
// the prop changes from elsewhere — protects against stale draft state if
|
||||
// the user closes mid-edit and reopens later.
|
||||
|
||||
@@ -26,11 +26,14 @@ export function ModelPicker({
|
||||
runtimeId,
|
||||
runtimeOnline,
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
runtimeId: string | null;
|
||||
runtimeOnline: boolean;
|
||||
value: string;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -41,13 +44,12 @@ export function ModelPicker({
|
||||
);
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
// Memoise the model list so every downstream useMemo gets a stable
|
||||
// reference — `?? []` would mint a fresh array on every render and
|
||||
// invalidate filters / defaultModel needlessly.
|
||||
// reference; `?? []` would mint a fresh array on every render and
|
||||
// invalidate filters needlessly.
|
||||
const models = useMemo(
|
||||
() => modelsQuery.data?.models ?? [],
|
||||
[modelsQuery.data],
|
||||
);
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = search.trim().toLowerCase();
|
||||
@@ -78,11 +80,20 @@ export function ModelPicker({
|
||||
);
|
||||
}
|
||||
|
||||
const triggerLabel =
|
||||
value ||
|
||||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
|
||||
const triggerLabel = value || "Default";
|
||||
const triggerTitle = `Model · ${triggerLabel}`;
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span
|
||||
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
title={triggerTitle}
|
||||
>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
|
||||
@@ -24,12 +24,15 @@ export function RuntimePicker({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (runtimeId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -37,6 +40,25 @@ export function RuntimePicker({
|
||||
|
||||
const selected = runtimes.find((r) => r.id === value) ?? null;
|
||||
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
|
||||
|
||||
if (!canEdit) {
|
||||
const isOnline = selected?.status === "online";
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{selected?.name ?? "No runtime"}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
isOnline ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// The chip shows only the runtime name. `runtime.name` already comes back
|
||||
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
|
||||
// deliberately do NOT append `device_info` to the tooltip — that string
|
||||
|
||||
@@ -17,7 +17,14 @@ import { SkillAddDialog } from "../skill-add-dialog";
|
||||
* Hidden when there's nothing left to attach so we don't dangle a chip
|
||||
* that opens an empty dialog.
|
||||
*/
|
||||
export function SkillAttach({ agent }: { agent: Agent }) {
|
||||
export function SkillAttach({
|
||||
agent,
|
||||
canEdit = true,
|
||||
}: {
|
||||
agent: Agent;
|
||||
/** When false, hide the attach trigger entirely. */
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -27,7 +34,7 @@ export function SkillAttach({ agent }: { agent: Agent }) {
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
).length;
|
||||
|
||||
if (availableCount === 0) return null;
|
||||
if (!canEdit || availableCount === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,27 +2,38 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import {
|
||||
VISIBILITY_DESCRIPTION,
|
||||
VISIBILITY_LABEL,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import type { AgentVisibility } from "@multica/core/types";
|
||||
import {
|
||||
PickerItem,
|
||||
PropertyPicker,
|
||||
} from "../../../issues/components/pickers";
|
||||
import { VisibilityBadge } from "../visibility-badge";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
|
||||
export function VisibilityPicker({
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: AgentVisibility;
|
||||
/** When false, render a read-only `<VisibilityBadge>` and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: AgentVisibility) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!canEdit) {
|
||||
return <VisibilityBadge value={value} />;
|
||||
}
|
||||
|
||||
const Icon = value === "private" ? Lock : Globe;
|
||||
const label = value === "private" ? "Private" : "Workspace";
|
||||
const tooltip =
|
||||
value === "private"
|
||||
? "Visibility · Private — only you can assign"
|
||||
: "Visibility · Workspace — all members can assign";
|
||||
const label = VISIBILITY_LABEL[value];
|
||||
const tooltip = `Visibility · ${VISIBILITY_TOOLTIP[value]}`;
|
||||
|
||||
const select = async (next: AgentVisibility) => {
|
||||
setOpen(false);
|
||||
@@ -52,9 +63,9 @@ export function VisibilityPicker({
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
All members can assign
|
||||
{VISIBILITY_DESCRIPTION.workspace}
|
||||
</div>
|
||||
</div>
|
||||
</PickerItem>
|
||||
@@ -64,9 +75,9 @@ export function VisibilityPicker({
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only you can assign
|
||||
{VISIBILITY_DESCRIPTION.private}
|
||||
</div>
|
||||
</div>
|
||||
</PickerItem>
|
||||
|
||||
@@ -42,7 +42,6 @@ export function ModelDropdown({
|
||||
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
const grouped = useMemo(() => groupByProvider(models), [models]);
|
||||
|
||||
// When the selected runtime reports it doesn't support per-agent
|
||||
@@ -86,9 +85,7 @@ export function ModelDropdown({
|
||||
(disabled
|
||||
? "Select a runtime first"
|
||||
: runtimeOnline
|
||||
? defaultModel
|
||||
? `Default — ${defaultModel.label}`
|
||||
: "Default (provider)"
|
||||
? "Default (provider)"
|
||||
: "Runtime offline — enter manually");
|
||||
|
||||
if (!supported && !modelsQuery.isLoading) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CircleHelp,
|
||||
Hash,
|
||||
MessageSquare,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
summarizeActivityWindow,
|
||||
useWorkspaceActivityMap,
|
||||
} from "@multica/core/agents";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
@@ -35,11 +38,16 @@ import { timeAgo } from "@multica/core/utils";
|
||||
import { AppLink } from "../../../navigation";
|
||||
import { TranscriptButton } from "../../../common/task-transcript";
|
||||
import { taskStatusConfig } from "../../config";
|
||||
import { failureReasonLabel } from "../../presence";
|
||||
import { failureReasonLabel } from "./task-failure";
|
||||
import { Sparkline } from "../sparkline";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const RECENT_LIMIT = 5;
|
||||
// Recent work pagination: small initial cohort to keep the section
|
||||
// scannable, then "Show more" reveals 20 at a time. Tasks are already
|
||||
// fully cached client-side (one listAgentTasks for the whole agent), so
|
||||
// "more" is a pure state flip — zero extra fetches.
|
||||
const RECENT_INITIAL = 5;
|
||||
const RECENT_PAGE = 20;
|
||||
|
||||
interface ActivityTabProps {
|
||||
agent: Agent;
|
||||
@@ -66,10 +74,19 @@ export function ActivityTab({ agent }: ActivityTabProps) {
|
||||
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
|
||||
const activity = activityMap.get(agent.id);
|
||||
|
||||
const [recentDisplayLimit, setRecentDisplayLimit] = useState(RECENT_INITIAL);
|
||||
|
||||
// Chat tasks are intentionally hidden across every Agent-scoped surface
|
||||
// (list / detail / activity). They have their own UI in the chat
|
||||
// experience; mixing them in here muddies "what is this agent doing
|
||||
// for the team" with "what is this agent doing in private chat".
|
||||
const isWorkflowTask = (t: AgentTask) => !t.chat_session_id;
|
||||
|
||||
const activeTasks = useMemo(() => {
|
||||
return snapshot.filter(
|
||||
(t) =>
|
||||
t.agent_id === agent.id &&
|
||||
isWorkflowTask(t) &&
|
||||
(t.status === "running" ||
|
||||
t.status === "queued" ||
|
||||
t.status === "dispatched"),
|
||||
@@ -78,11 +95,12 @@ export function ActivityTab({ agent }: ActivityTabProps) {
|
||||
|
||||
// Most recent terminal tasks. Includes cancelled — users searching
|
||||
// "what just happened" want to see cancellations alongside completions
|
||||
// and failures.
|
||||
const recentTasks = useMemo(() => {
|
||||
// and failures. Chat sessions filtered out for the same reason as above.
|
||||
const recentTasksAll = useMemo(() => {
|
||||
return [...agentTasks]
|
||||
.filter(
|
||||
(t) =>
|
||||
isWorkflowTask(t) &&
|
||||
!!t.completed_at &&
|
||||
(t.status === "completed" ||
|
||||
t.status === "failed" ||
|
||||
@@ -92,10 +110,15 @@ export function ActivityTab({ agent }: ActivityTabProps) {
|
||||
(a, b) =>
|
||||
new Date(b.completed_at!).getTime() -
|
||||
new Date(a.completed_at!).getTime(),
|
||||
)
|
||||
.slice(0, RECENT_LIMIT);
|
||||
);
|
||||
}, [agentTasks]);
|
||||
|
||||
const recentTasks = useMemo(
|
||||
() => recentTasksAll.slice(0, recentDisplayLimit),
|
||||
[recentTasksAll, recentDisplayLimit],
|
||||
);
|
||||
const hasMoreRecent = recentTasksAll.length > recentTasks.length;
|
||||
|
||||
const avgDurationMs = useMemo(
|
||||
() => deriveAvgDurationLast30d(agentTasks, Date.now()),
|
||||
[agentTasks],
|
||||
@@ -133,6 +156,11 @@ export function ActivityTab({ agent }: ActivityTabProps) {
|
||||
<Last30dSection activity={activity} avgDurationMs={avgDurationMs} />
|
||||
<RecentWorkSection
|
||||
tasks={recentTasks}
|
||||
totalCount={recentTasksAll.length}
|
||||
hasMore={hasMoreRecent}
|
||||
onShowMore={() =>
|
||||
setRecentDisplayLimit((n) => n + RECENT_PAGE)
|
||||
}
|
||||
issueMap={issueMap}
|
||||
agent={agent}
|
||||
/>
|
||||
@@ -244,31 +272,52 @@ function Last30dSection({
|
||||
|
||||
function RecentWorkSection({
|
||||
tasks,
|
||||
totalCount,
|
||||
hasMore,
|
||||
onShowMore,
|
||||
issueMap,
|
||||
agent,
|
||||
}: {
|
||||
tasks: AgentTask[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
onShowMore: () => void;
|
||||
issueMap: Map<string, Issue>;
|
||||
agent: Agent;
|
||||
}) {
|
||||
// Subtitle phrasing: "5 of 47" once we know the total is bigger than
|
||||
// what we're rendering, otherwise "5 latest". Total comes from
|
||||
// recentTasksAll (already filtered for chat / terminals) so it
|
||||
// accurately reflects what would appear if the user kept clicking
|
||||
// "Show more" — not the raw on-the-wire row count.
|
||||
const subtitle =
|
||||
tasks.length === 0
|
||||
? "Nothing finished yet"
|
||||
: totalCount > tasks.length
|
||||
? `${tasks.length} of ${totalCount}`
|
||||
: `${tasks.length} latest`;
|
||||
return (
|
||||
<Section
|
||||
title="Recent work"
|
||||
subtitle={
|
||||
tasks.length === 0
|
||||
? "Nothing finished yet"
|
||||
: `${tasks.length} latest`
|
||||
}
|
||||
>
|
||||
<Section title="Recent work" subtitle={subtitle}>
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyText>This agent hasn't completed anything yet.</EmptyText>
|
||||
) : (
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
issueMap={issueMap}
|
||||
timeMode="completed"
|
||||
agent={agent}
|
||||
/>
|
||||
<>
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
issueMap={issueMap}
|
||||
timeMode="completed"
|
||||
agent={agent}
|
||||
/>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowMore}
|
||||
className="mt-2 self-start rounded text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Show more →
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
@@ -312,6 +361,7 @@ function TaskRow({
|
||||
agent: Agent;
|
||||
}) {
|
||||
const paths = useWorkspacePaths();
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const cfg = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
|
||||
const Icon = cfg.icon;
|
||||
const hasIssue = task.issue_id !== "";
|
||||
@@ -320,13 +370,47 @@ function TaskRow({
|
||||
// Queued tasks have no messages yet — hiding the transcript button avoids
|
||||
// a guaranteed "No execution data recorded." dialog open.
|
||||
const showTranscript = task.status !== "queued";
|
||||
// Cancel only makes sense for the three active states. Terminal rows
|
||||
// (completed / failed / cancelled) hide the button entirely.
|
||||
const showCancel =
|
||||
timeMode === "active" &&
|
||||
(task.status === "queued" ||
|
||||
task.status === "dispatched" ||
|
||||
task.status === "running");
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await api.cancelTaskById(task.id);
|
||||
// No manual invalidate needed — the task:cancelled WS event flows
|
||||
// through useRealtimeSync's `task:` prefix path which already
|
||||
// invalidates snapshot + per-agent + per-issue task lists.
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Terminal states never use active wording. The server links the new
|
||||
// issue back to a quick-create task on completion (so most successful
|
||||
// rows transition to kind=direct on next refetch), but rows whose link
|
||||
// write failed — or whose agent never created the issue at all — would
|
||||
// otherwise sit on "Creating issue" forever.
|
||||
const isTerminalStatus =
|
||||
task.status === "completed" ||
|
||||
task.status === "failed" ||
|
||||
task.status === "cancelled";
|
||||
const sourceFallback = !hasIssue
|
||||
? task.chat_session_id
|
||||
? "Chat session"
|
||||
: task.autopilot_run_id
|
||||
? "Autopilot run"
|
||||
: "Untracked"
|
||||
? task.kind === "quick_create"
|
||||
? isTerminalStatus
|
||||
? "Quick create"
|
||||
: "Creating issue"
|
||||
: task.chat_session_id
|
||||
? "Chat session"
|
||||
: task.autopilot_run_id
|
||||
? "Autopilot run"
|
||||
: "Untracked"
|
||||
: null;
|
||||
|
||||
// Origin marker — issue / chat / autopilot / untracked. The issue
|
||||
@@ -375,7 +459,7 @@ function TaskRow({
|
||||
}
|
||||
|
||||
const rowClass = `group flex items-center gap-3 rounded-md border px-3 py-2.5 ${
|
||||
isRunning ? "border-success/40 bg-success/5" : ""
|
||||
isRunning ? "border-brand/40 bg-brand/5" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -396,12 +480,40 @@ function TaskRow({
|
||||
{issue.identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{issue?.title ??
|
||||
(hasIssue
|
||||
? `Issue ${task.issue_id.slice(0, 8)}…`
|
||||
: (sourceFallback ?? "Untracked"))}
|
||||
</span>
|
||||
{task.trigger_summary ? (
|
||||
// Hover surfaces "why this task ran" — the snapshot lets the
|
||||
// agent-side row stay anchored on issue.title (the
|
||||
// identification axis here) while still letting the user
|
||||
// dwell to see the trigger context. Same pattern as
|
||||
// GitHub Actions surfacing the commit message on hover.
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="truncate text-sm">
|
||||
{issue?.title ??
|
||||
(hasIssue
|
||||
? `Issue ${task.issue_id.slice(0, 8)}…`
|
||||
: (sourceFallback ?? "Untracked"))}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent className="max-w-md">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
|
||||
Triggered by
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap text-xs">
|
||||
{task.trigger_summary}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="truncate text-sm">
|
||||
{issue?.title ??
|
||||
(hasIssue
|
||||
? `Issue ${task.issue_id.slice(0, 8)}…`
|
||||
: (sourceFallback ?? "Untracked"))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>{timeText}</span>
|
||||
@@ -445,6 +557,26 @@ function TaskRow({
|
||||
title="View transcript"
|
||||
/>
|
||||
)}
|
||||
{showCancel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
aria-label="Cancel task"
|
||||
/>
|
||||
}
|
||||
className="flex items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{cancelling ? "Cancelling…" : "Cancel task"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
16
packages/views/agents/components/tabs/task-failure.ts
Normal file
16
packages/views/agents/components/tabs/task-failure.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { TaskFailureReason } from "@multica/core/types";
|
||||
|
||||
// Human-readable copy for the back-end task failure reason enum. Surfaced
|
||||
// in the agent detail Recent Work tab when a task ended in failure — the
|
||||
// only place the front-end exposes failure_reason directly to the user.
|
||||
//
|
||||
// Lives next to the consuming tab (rather than in agents/presence) because
|
||||
// failed tasks no longer have a top-level workload state; failure context
|
||||
// is purely a detail-page concern now.
|
||||
export const failureReasonLabel: Record<TaskFailureReason, string> = {
|
||||
agent_error: "Agent execution error",
|
||||
timeout: "Task timed out",
|
||||
runtime_offline: "Daemon offline",
|
||||
runtime_recovery: "Daemon restarted",
|
||||
manual: "Cancelled by user",
|
||||
};
|
||||
49
packages/views/agents/components/visibility-badge.tsx
Normal file
49
packages/views/agents/components/visibility-badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import {
|
||||
VISIBILITY_LABEL,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import type { AgentVisibility } from "@multica/core/types";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Read-only visibility badge — used wherever a user should *see* an agent's
|
||||
* visibility (Personal / Workspace) without being able to change it. Replaces
|
||||
* the interactive `<VisibilityPicker>` for non-managers on the detail page,
|
||||
* and is also the canonical badge for hover cards and list rows.
|
||||
*
|
||||
* `compact` drops the text label and shows just the icon — for tight spaces
|
||||
* like the agent table where the column header already labels the field.
|
||||
*/
|
||||
export function VisibilityBadge({
|
||||
value,
|
||||
compact = false,
|
||||
className = "",
|
||||
}: {
|
||||
value: AgentVisibility;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = value === "private" ? Lock : Globe;
|
||||
const label = VISIBILITY_LABEL[value];
|
||||
const tooltip = VISIBILITY_TOOLTIP[value];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs text-muted-foreground ${className}`}
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
{!compact && <span className="truncate">{label}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
export const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle2; color: string }> = {
|
||||
queued: { label: "Queued", icon: Clock, color: "text-muted-foreground" },
|
||||
dispatched: { label: "Dispatched", icon: Play, color: "text-info" },
|
||||
running: { label: "Running", icon: Loader2, color: "text-success" },
|
||||
running: { label: "Running", icon: Loader2, color: "text-brand" },
|
||||
completed: { label: "Completed", icon: CheckCircle2, color: "text-success" },
|
||||
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
|
||||
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
Clock,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
PlugZap,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { AgentAvailability, LastTaskState } from "@multica/core/agents";
|
||||
import type { TaskFailureReason } from "@multica/core/types";
|
||||
import type { AgentAvailability, Workload } from "@multica/core/agents";
|
||||
|
||||
// Visual mapping for the two presence dimensions, kept in matching shape
|
||||
// so consumers can pick which to render. The two are independent — the
|
||||
// dot reads only from availabilityConfig, the last-task chip reads only
|
||||
// from taskStateConfig.
|
||||
// dot reads only from availabilityConfig, the workload chip reads only
|
||||
// from workloadConfig.
|
||||
//
|
||||
// Color tokens map to project semantic tokens (no hardcoded Tailwind colors):
|
||||
//
|
||||
@@ -24,16 +21,17 @@ import type { TaskFailureReason } from "@multica/core/types";
|
||||
// unstable → warning (amber) — pairs with the runtime card's amber
|
||||
// offline → muted-foreground (gray)
|
||||
//
|
||||
// LAST TASK STATE (drives the optional last-task chip on focused surfaces):
|
||||
// running → brand (blue) has activity
|
||||
// completed → success (green) all good
|
||||
// failed → destructive (red)
|
||||
// cancelled → muted (gray)
|
||||
// idle → muted (gray) no history
|
||||
// WORKLOAD (drives the optional workload chip on focused surfaces):
|
||||
// working → brand (blue) has activity
|
||||
// queued → warning (amber) anomaly: nothing running but tasks
|
||||
// waiting (typically stuck on offline
|
||||
// runtime; brief flash on online is
|
||||
// a harmless race)
|
||||
// idle → muted (gray) nothing on the plate
|
||||
//
|
||||
// Critically: `failed` colour appears ONLY in the last-task chip, never
|
||||
// on the dot. A runtime-healthy agent whose last task failed shows a
|
||||
// green dot + a red "Failed" chip — the dot stops being sticky-red.
|
||||
// `failed` / `completed` / `cancelled` deliberately have no top-level visual
|
||||
// — those are historical context, surfaced via Recent Work + Inbox, not
|
||||
// list-level summary state.
|
||||
|
||||
export interface AvailabilityVisual {
|
||||
label: string;
|
||||
@@ -74,7 +72,7 @@ export const availabilityOrder: AgentAvailability[] = [
|
||||
"offline",
|
||||
];
|
||||
|
||||
export interface TaskStateVisual {
|
||||
export interface WorkloadVisual {
|
||||
label: string;
|
||||
// Foreground colour for icon + label text.
|
||||
textClass: string;
|
||||
@@ -82,26 +80,19 @@ export interface TaskStateVisual {
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const taskStateConfig: Record<LastTaskState, TaskStateVisual> = {
|
||||
running: {
|
||||
label: "Running",
|
||||
export const workloadConfig: Record<Workload, WorkloadVisual> = {
|
||||
working: {
|
||||
label: "Working",
|
||||
textClass: "text-brand",
|
||||
icon: Loader2,
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
textClass: "text-success",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
textClass: "text-destructive",
|
||||
icon: XCircle,
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
textClass: "text-muted-foreground",
|
||||
icon: PauseCircle,
|
||||
queued: {
|
||||
// Amber chip: nothing running but tasks waiting. On an offline runtime
|
||||
// this is the "stuck" signal we explicitly surface (replacing the old
|
||||
// misleading "Running 0/N +Mq" copy).
|
||||
label: "Queued",
|
||||
textClass: "text-warning",
|
||||
icon: Clock,
|
||||
},
|
||||
idle: {
|
||||
label: "Idle",
|
||||
@@ -110,22 +101,5 @@ export const taskStateConfig: Record<LastTaskState, TaskStateVisual> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Order used by last-run filter chips. Actionable signals first
|
||||
// (running / failed) before passive ones (idle / cancelled).
|
||||
export const lastTaskOrder: LastTaskState[] = [
|
||||
"running",
|
||||
"failed",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"idle",
|
||||
];
|
||||
|
||||
// Human-readable copy for the back-end task failure reason enum. Surfaced
|
||||
// in the hover card and detail header when lastTask === "failed".
|
||||
export const failureReasonLabel: Record<TaskFailureReason, string> = {
|
||||
agent_error: "Agent execution error",
|
||||
timeout: "Task timed out",
|
||||
runtime_offline: "Daemon offline",
|
||||
runtime_recovery: "Daemon restarted",
|
||||
manual: "Cancelled by user",
|
||||
};
|
||||
// Order used in any future workload chip group; actionable signals first.
|
||||
export const workloadOrder: Workload[] = ["working", "queued", "idle"];
|
||||
|
||||
@@ -44,7 +44,9 @@ import {
|
||||
} from "./trigger-config";
|
||||
import type { TriggerConfig } from "./trigger-config";
|
||||
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
|
||||
import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -63,11 +65,34 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
function RunRow({ run }: { run: AutopilotRun }) {
|
||||
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
// For runs with a task_id (run_only mode), build a minimal AgentTask so
|
||||
// TranscriptButton can lazy-load the execution transcript.
|
||||
const syntheticTask: AgentTask | null = run.task_id
|
||||
? {
|
||||
id: run.task_id,
|
||||
agent_id: agentId,
|
||||
runtime_id: "",
|
||||
issue_id: "",
|
||||
status:
|
||||
run.status === "running" ? "running" :
|
||||
run.status === "completed" ? "completed" :
|
||||
run.status === "failed" ? "failed" :
|
||||
"queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: run.triggered_at || null,
|
||||
completed_at: run.completed_at || null,
|
||||
result: null,
|
||||
error: run.failure_reason || null,
|
||||
created_at: run.created_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
|
||||
@@ -83,6 +108,14 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(run.triggered_at || run.created_at)}
|
||||
</span>
|
||||
{syntheticTask && !run.issue_id && (
|
||||
<TranscriptButton
|
||||
task={syntheticTask}
|
||||
agentName={agentName}
|
||||
isLive={run.status === "running"}
|
||||
title="View execution log"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -438,7 +471,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -130,38 +130,40 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
|
||||
const StatusIcon = statusCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
|
||||
<div className="group/row flex flex-col gap-2 border-b px-4 py-3 text-sm transition-colors hover:bg-accent/40 sm:h-11 sm:flex-row sm:items-center sm:gap-2 sm:border-b-0 sm:px-5 sm:py-0">
|
||||
<AppLink
|
||||
href={wsPaths.autopilotDetail(autopilot.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
className="flex min-w-0 items-center gap-2 sm:flex-1"
|
||||
>
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
|
||||
</AppLink>
|
||||
|
||||
{/* Agent */}
|
||||
<span className="flex w-32 items-center gap-1.5 shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
|
||||
{/* Agent */}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<span className="truncate">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Mode */}
|
||||
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
{/* Mode */}
|
||||
<span className="text-muted-foreground sm:w-24 sm:shrink-0 sm:text-center">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
{/* Status */}
|
||||
<span className={cn("flex items-center gap-1 sm:w-20 sm:shrink-0 sm:justify-center", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
{/* Last run */}
|
||||
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
{/* Last run */}
|
||||
<span className="text-muted-foreground tabular-nums sm:w-20 sm:shrink-0 sm:text-right">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export function AutopilotsPage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
|
||||
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 sm:flex">
|
||||
<span className="shrink-0 w-4" />
|
||||
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
@@ -206,9 +208,9 @@ export function AutopilotsPage() {
|
||||
<Skeleton className="h-3 w-10 shrink-0" />
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
</div>
|
||||
<div className="p-5 pt-1 space-y-1">
|
||||
<div className="space-y-2 p-4 sm:space-y-1 sm:p-5 sm:pt-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full" />
|
||||
<Skeleton key={i} className="h-[72px] w-full sm:h-11" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -246,7 +248,7 @@ export function AutopilotsPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Column headers */}
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
|
||||
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground sm:flex">
|
||||
<span className="shrink-0 w-4" />
|
||||
<span className="min-w-0 flex-1">Name</span>
|
||||
<span className="w-32 shrink-0">Agent</span>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
@@ -14,6 +15,10 @@ interface ChatInputProps {
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
/** True when the user has no agent available — disables the editor and
|
||||
* surfaces a distinct placeholder. Kept separate from `disabled` so
|
||||
* archived-session copy stays untouched. */
|
||||
noAgent?: boolean;
|
||||
/** Name of the currently selected agent, used in the placeholder. */
|
||||
agentName?: string;
|
||||
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
|
||||
@@ -30,6 +35,7 @@ export function ChatInput({
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled,
|
||||
noAgent,
|
||||
agentName,
|
||||
leftAdornment,
|
||||
rightAdornment,
|
||||
@@ -54,11 +60,12 @@ export function ChatInput({
|
||||
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || isRunning || disabled) {
|
||||
if (!content || isRunning || disabled || noAgent) {
|
||||
logger.debug("input.send skipped", {
|
||||
emptyContent: !content,
|
||||
isRunning,
|
||||
disabled,
|
||||
noAgent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -69,19 +76,50 @@ export function ChatInput({
|
||||
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
|
||||
onSend(content);
|
||||
editorRef.current?.clearContent();
|
||||
// Drop focus so the caret doesn't keep blinking under the StatusPill /
|
||||
// streaming reply that's about to take over the user's attention. The
|
||||
// input is also `disabled` once isRunning flips, and a focused-but-
|
||||
// disabled editor reads as a stale cursor. We deliberately don't auto-
|
||||
// refocus on completion — that would interrupt the user if they're
|
||||
// selecting text from the assistant reply; one click to refocus is
|
||||
// a fair price for not stealing focus mid-action.
|
||||
editorRef.current?.blur();
|
||||
clearInputDraft(keyAtSend);
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
const placeholder = disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
const placeholder = noAgent
|
||||
? "Create an agent to start chatting"
|
||||
: disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
|
||||
return (
|
||||
<div className="px-5 pb-3 pt-0">
|
||||
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div
|
||||
className={cn(
|
||||
"px-5 pb-3 pt-0",
|
||||
// Outer wrapper carries the disabled cursor. Inner card sets
|
||||
// pointer-events-none, which suppresses hover (and therefore
|
||||
// any cursor of its own) — splitting the two layers lets hover
|
||||
// bubble back here so the browser actually reads cursor.
|
||||
noAgent && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand",
|
||||
// Visual + interaction lock when there's no agent. We don't
|
||||
// toggle ContentEditor's editable mode (Tiptap can't switch
|
||||
// cleanly post-mount, and the prop has been removed); instead
|
||||
// we drop pointer events at the wrapper level so clicks miss
|
||||
// the editor entirely, and dim the surface so it reads as
|
||||
// "disabled" rather than "broken".
|
||||
noAgent && "pointer-events-none opacity-60",
|
||||
)}
|
||||
aria-disabled={noAgent || undefined}
|
||||
>
|
||||
{topSlot}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
@@ -113,7 +151,7 @@ export function ChatInput({
|
||||
{rightAdornment}
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || !!disabled}
|
||||
disabled={isEmpty || !!disabled || !!noAgent}
|
||||
running={isRunning}
|
||||
onStop={onStop}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user