mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
48 Commits
v0.2.19
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411fd6a5a1 | ||
|
|
de0d30509e | ||
|
|
472e78022e | ||
|
|
5bf0e7022d | ||
|
|
665ac39730 | ||
|
|
55b7e2e93a | ||
|
|
80c5bb9e9e | ||
|
|
6a665c68a3 | ||
|
|
174b8c62a6 | ||
|
|
768d3f8b0c | ||
|
|
7dfa72465c | ||
|
|
0b969483a6 | ||
|
|
e024ab1232 | ||
|
|
f4eb83bd41 | ||
|
|
dde42ba84a | ||
|
|
9467a8c616 | ||
|
|
cfa38df97b | ||
|
|
4ad0a0b847 | ||
|
|
1fd583ef65 | ||
|
|
286ecf04b1 | ||
|
|
bd82607645 | ||
|
|
365e84b920 | ||
|
|
86e7de3e41 | ||
|
|
936ccce8fa | ||
|
|
49ccd22027 | ||
|
|
e66bd593ea | ||
|
|
7528022355 | ||
|
|
391a4ecd09 | ||
|
|
54d895a210 | ||
|
|
40a984c997 | ||
|
|
9ccaf18479 | ||
|
|
866b901943 | ||
|
|
9baa72cc68 | ||
|
|
576304519b | ||
|
|
f0a3f5ddeb | ||
|
|
22136a55fc | ||
|
|
375534573c | ||
|
|
2a59236575 | ||
|
|
415060e6be | ||
|
|
f745a3bbbe | ||
|
|
a475c17283 | ||
|
|
e4103f6ad7 | ||
|
|
2d9c153695 | ||
|
|
805071b5b1 | ||
|
|
f0c845b777 | ||
|
|
9587a577e2 | ||
|
|
21e3cfaa01 | ||
|
|
01855f6b09 |
@@ -181,8 +181,10 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -200,6 +202,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
33
apps/desktop/src/main/app-version.ts
Normal file
33
apps/desktop/src/main/app-version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
@@ -914,6 +914,20 @@ export function setupDaemonManager(
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -203,7 +204,7 @@ if (!gotTheLock) {
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
|
||||
1
apps/desktop/src/preload/index.d.ts
vendored
1
apps/desktop/src/preload/index.d.ts
vendored
@@ -68,6 +68,7 @@ interface DaemonAPI {
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
openLogFile: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
|
||||
@@ -145,6 +145,8 @@ const daemonAPI = {
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:open-log-file"),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
|
||||
@@ -7,13 +7,14 @@ import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -99,18 +100,29 @@ function AppContent() {
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
// Bridge local daemon IPC status into the runtimes cache so this user's
|
||||
// own daemon flips to offline/online sub-second instead of waiting on the
|
||||
// server's 75s sweeper. Resolves wsId from the active tab so workspace
|
||||
// switches automatically rebind the subscription.
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const activeWsId = activeWorkspaceSlug
|
||||
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Workspace presence wins over onboarding state: a user invited into an
|
||||
// existing workspace must enter that workspace, not be trapped in the
|
||||
// onboarding overlay just because their personal `onboarded_at` is null.
|
||||
// Onboarding is only the right destination when the account has zero
|
||||
// workspaces AND has never onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (wsCount > 0) return;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
} else {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
|
||||
@@ -1,150 +1,261 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Copy as CopyIcon,
|
||||
Search,
|
||||
Server,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
/** Number of runtimes this local daemon has registered (for the context badge). */
|
||||
runtimeCount: number;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
let logIdCounter = 0;
|
||||
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
|
||||
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
|
||||
INFO: "border-foreground/15 text-foreground/80",
|
||||
WARN: "border-warning/40 text-warning",
|
||||
ERROR: "border-destructive/40 text-destructive",
|
||||
};
|
||||
|
||||
// What gets rendered in the viewport — a single line or a folded group of
|
||||
// consecutive lines that share the same `message`. The group form is what
|
||||
// turns a wall of `DBG poll: no tasks` into a single placeholder.
|
||||
type DisplayItem =
|
||||
| { kind: "line"; line: ParsedLogLine }
|
||||
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
|
||||
|
||||
export function DaemonPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
runtimeCount,
|
||||
}: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
// Each level chip is an independent toggle. DEBUG is off by default so
|
||||
// poll-loop noise doesn't drown out real events when the panel opens —
|
||||
// users opt in if they want to see it.
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
|
||||
);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
|
||||
const idCounterRef = useRef(0);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// --- Log stream subscription ---
|
||||
// Active only while the modal is open. On open we replay the file's tail
|
||||
// (~200 lines) so users have context for "what just happened"; on close
|
||||
// we tear down the watcher so the main process isn't doing work for a
|
||||
// hidden UI.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
idCounterRef.current = 0;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
const id = ++idCounterRef.current;
|
||||
const parsed = parseLogLine(line, id);
|
||||
const next =
|
||||
prev.length >= MAX_LOG_LINES
|
||||
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
|
||||
: [...prev, parsed];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
// --- Derived: counts per level (for filter chip badges) ---
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
};
|
||||
for (const l of logs) {
|
||||
if (l.level) counts[l.level] += 1;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
const handleLogScroll = useCallback(() => {
|
||||
// --- Derived: filtered list (level toggle + search) ---
|
||||
// Lines that didn't parse (level = null) always pass — they're typically
|
||||
// panic stack traces / partial writes; never silently drop them.
|
||||
const filtered = useMemo(() => {
|
||||
let result = logs;
|
||||
result = result.filter((l) => {
|
||||
if (!l.level) return true;
|
||||
return enabledLevels.has(l.level);
|
||||
});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((l) => l.raw.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [logs, enabledLevels, search]);
|
||||
|
||||
// --- Derived: collapse runs of consecutive lines that share the same
|
||||
// message into a single group placeholder. The most common case is the
|
||||
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
|
||||
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
|
||||
// doesn't strand groups.
|
||||
const displayed = useMemo<DisplayItem[]>(() => {
|
||||
const out: DisplayItem[] = [];
|
||||
for (const line of filtered) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last) {
|
||||
out.push({ kind: "line", line });
|
||||
continue;
|
||||
}
|
||||
const lastMessage =
|
||||
last.kind === "line" ? last.line.message : last.first.message;
|
||||
if (lastMessage && lastMessage === line.message) {
|
||||
if (last.kind === "line") {
|
||||
out[out.length - 1] = {
|
||||
kind: "group",
|
||||
first: last.line,
|
||||
rest: [line],
|
||||
};
|
||||
} else {
|
||||
last.rest.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: "line", line });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
|
||||
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [displayed, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
|
||||
// here. Re-enabling lives in the "Jump to latest" footer button so a
|
||||
// burst of lines doesn't yank a reading user back to the bottom.
|
||||
if (!atBottom && autoScroll) setAutoScroll(false);
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setAutoScroll(true);
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
const toggleLevel = useCallback((lv: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lv)) next.delete(lv);
|
||||
else next.add(lv);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
const toggleFields = useCallback((id: number) => {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
const toggleGroup = useCallback((id: number) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Server className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Local daemon logs
|
||||
</DialogTitle>
|
||||
<ContextBadge status={status} runtimeCount={runtimeCount} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -153,157 +264,412 @@ export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</SheetHeader>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{/* Level toggle chips. Each chip is independent — click to
|
||||
show/hide that level. DEBUG starts hidden because the
|
||||
poll-loop heartbeat dominates otherwise. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((lv) => (
|
||||
<FilterChip
|
||||
key={lv}
|
||||
active={enabledLevels.has(lv)}
|
||||
onClick={() => toggleLevel(lv)}
|
||||
label={lv}
|
||||
count={levelCounts[lv]}
|
||||
variant={lv}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
<CopyIcon className="size-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs viewport */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{displayed.length === 0 ? (
|
||||
<EmptyState
|
||||
hasLogs={logs.length > 0}
|
||||
hasFilter={hasActiveFilter}
|
||||
isRunning={status.state === "running"}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex flex-col">
|
||||
{displayed.map((item) =>
|
||||
item.kind === "line" ? (
|
||||
<LogLineRow
|
||||
key={item.line.id}
|
||||
line={item.line}
|
||||
expanded={expandedFields.has(item.line.id)}
|
||||
onToggle={() => toggleFields(item.line.id)}
|
||||
search={search}
|
||||
/>
|
||||
) : (
|
||||
<GroupRows
|
||||
key={item.first.id}
|
||||
first={item.first}
|
||||
rest={item.rest}
|
||||
expanded={expandedGroups.has(item.first.id)}
|
||||
onToggle={() => toggleGroup(item.first.id)}
|
||||
expandedFields={expandedFields}
|
||||
onToggleFields={toggleFields}
|
||||
search={search}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Status bar — count only. The "is the user following" state is
|
||||
communicated implicitly by the presence of the Jump-to-latest
|
||||
button below; an explicit "Paused" word read as "log stream is
|
||||
paused" (it isn't — data keeps flowing into the buffer). */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
Showing {filtered.length} of {logs.length}
|
||||
{logs.length === MAX_LOG_LINES && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
(buffer full)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function ContextBadge({
|
||||
status,
|
||||
runtimeCount,
|
||||
}: {
|
||||
status: DaemonStatus;
|
||||
runtimeCount: number;
|
||||
}) {
|
||||
const isRunning = status.state === "running";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && runtimeCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
variant?: LogLevel;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
|
||||
active
|
||||
? variant
|
||||
? LEVEL_BADGE_CLASS[variant]
|
||||
: "bg-accent text-accent-foreground"
|
||||
: "border-dashed text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
active ? "text-current/80" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
|
||||
LEVEL_BADGE_CLASS[level],
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLineRow({
|
||||
line,
|
||||
expanded,
|
||||
onToggle,
|
||||
search,
|
||||
}: {
|
||||
line: ParsedLogLine;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
search: string;
|
||||
}) {
|
||||
const fieldEntries = Object.entries(line.fields);
|
||||
const hasFields = fieldEntries.length > 0;
|
||||
|
||||
// Unparseable line — render the raw text so nothing is hidden. Common
|
||||
// for panic stack traces and partial writes during log rotation.
|
||||
if (!line.timestamp || !line.level) {
|
||||
return (
|
||||
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
|
||||
{highlight(line.raw, search)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
|
||||
hasFields && "cursor-pointer",
|
||||
)}
|
||||
onClick={hasFields ? onToggle : undefined}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<LevelBadge level={line.level} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="break-words">{highlight(line.message, search)}</span>
|
||||
{hasFields && !expanded && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60">
|
||||
{fieldEntries
|
||||
.map(([k, v]) => `${k}=${truncateValue(v)}`)
|
||||
.join(" ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && hasFields && (
|
||||
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{fieldEntries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-muted-foreground/70">{k}</span>
|
||||
<span className="break-all text-foreground/85">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
first,
|
||||
rest,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedFields,
|
||||
onToggleFields,
|
||||
search,
|
||||
}: {
|
||||
first: ParsedLogLine;
|
||||
rest: ParsedLogLine[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedFields: Set<number>;
|
||||
onToggleFields: (id: number) => void;
|
||||
search: string;
|
||||
}) {
|
||||
// Folded: show the first occurrence so the user still sees a sample
|
||||
// (timestamp, level, message), then a click-to-expand placeholder for
|
||||
// the suppressed run. The placeholder uses a dashed border + italics
|
||||
// so the eye reads it as "not a real line".
|
||||
if (!expanded) {
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>
|
||||
{rest.length} more “{truncateValue(first.message, 48)}
|
||||
” — click to expand
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unfolded: render every line, then a small "collapse" affordance at
|
||||
// the end so the user can put the toothpaste back in the tube.
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
{rest.map((l) => (
|
||||
<LogLineRow
|
||||
key={l.id}
|
||||
line={l}
|
||||
expanded={expandedFields.has(l.id)}
|
||||
onToggle={() => onToggleFields(l.id)}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>collapse {rest.length + 1} repeated</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasLogs,
|
||||
hasFilter,
|
||||
isRunning,
|
||||
}: {
|
||||
hasLogs: boolean;
|
||||
hasFilter: boolean;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
if (hasFilter) {
|
||||
title = "No matching log lines";
|
||||
subtitle = "Try a different search or level toggle.";
|
||||
} else if (!isRunning) {
|
||||
title = "Daemon isn't running";
|
||||
subtitle = "Start the daemon to see logs here.";
|
||||
} else if (!hasLogs) {
|
||||
title = "Waiting for logs…";
|
||||
subtitle = "New entries will appear in real time.";
|
||||
} else {
|
||||
title = "";
|
||||
subtitle = "";
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
|
||||
<p className="text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function truncateValue(value: string, max = 32): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const q = query.toLowerCase();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(q);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,94 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
runtimes
|
||||
.filter((r) => r.daemon_id === status.daemonId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}, [runtimes, status.daemonId]);
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
(t) =>
|
||||
localRuntimeIds.has(t.runtime_id) &&
|
||||
(t.status === "running" || t.status === "dispatched"),
|
||||
),
|
||||
[snapshot, localRuntimeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
@@ -36,7 +108,10 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
@@ -44,112 +119,214 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
} else {
|
||||
setConfirmStop(true);
|
||||
}
|
||||
}, [affectedTasks.length, performStop]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRetryInstall = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
status={status}
|
||||
runtimeCount={runtimeCount}
|
||||
/>
|
||||
|
||||
<StopConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
affectedCount={affectedTasks.length}
|
||||
onConfirm={() => {
|
||||
setConfirmStop(false);
|
||||
void performStop();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function StopConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
affectedCount,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
affectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const plural = affectedCount === 1 ? "" : "s";
|
||||
const verb = affectedCount === 1 ? "is" : "are";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Stop daemon with {affectedCount} active task{plural}?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{affectedCount} task{plural} {verb} currently running on this
|
||||
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
|
||||
— affected tasks get marked <strong>failed</strong> once the
|
||||
timeout hits. The daemon won't auto-restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Stop daemon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -10,7 +16,7 @@ function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
@@ -23,14 +29,44 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
// One row inside the diagnostics block. Values that are likely to be
|
||||
// long IDs / URLs render as monospaced + truncated with a tooltip.
|
||||
function DiagnosticsRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
@@ -98,6 +134,68 @@ export function DaemonSettingsTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics — moved out of the logs panel so the panel can focus
|
||||
on logs. These fields matter for support tickets and bug reports,
|
||||
not for everyday use. */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold">Diagnostics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Identification and connection details. Useful when filing a bug
|
||||
report or investigating why a runtime isn't showing up.
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
|
||||
<DiagnosticsRow
|
||||
label="State"
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Uptime"
|
||||
value={status.uptime ? formatUptime(status.uptime) : "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="PID"
|
||||
value={status.pid ?? "—"}
|
||||
mono={!!status.pid}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Daemon ID"
|
||||
value={status.daemonId ?? "—"}
|
||||
mono={!!status.daemonId}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Profile"
|
||||
value={status.profile || "default"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Server URL"
|
||||
value={status.serverUrl ?? "—"}
|
||||
mono={!!status.serverUrl}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Device name"
|
||||
value={status.deviceName ?? "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Workspaces"
|
||||
value={
|
||||
typeof status.workspaceCount === "number"
|
||||
? status.workspaceCount
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
@@ -157,9 +158,11 @@ export function DesktopShell() {
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling */}
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLogLine } from "./parse-daemon-log";
|
||||
|
||||
// All sample lines below are taken verbatim from real daemon output (Go
|
||||
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
|
||||
// stay aligned with what tint actually writes — not what we assume.
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses tint's 3-letter INF level", () => {
|
||||
const line =
|
||||
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.timestamp).toBe("17:52:35.587");
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("task completed");
|
||||
expect(r.fields).toEqual({
|
||||
component: "daemon",
|
||||
task: "c45266e5",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-letter DBG / WRN / ERR levels", () => {
|
||||
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
|
||||
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
|
||||
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("still accepts 4-letter level names (defensive against config changes)", () => {
|
||||
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("regular");
|
||||
});
|
||||
|
||||
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
|
||||
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
|
||||
// We treat the base level as canonical and drop the delta from the UI.
|
||||
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("unusual delta");
|
||||
});
|
||||
|
||||
it("preserves message text containing colons and special chars", () => {
|
||||
// Real sample: "tool #1: Skill component=daemon task=..."
|
||||
const r = parseLogLine(
|
||||
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("tool #1: Skill");
|
||||
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
|
||||
});
|
||||
|
||||
it("unquotes a double-quoted value containing escaped quotes", () => {
|
||||
// Real sample with escaped quotes inside the agent's emitted text.
|
||||
const line =
|
||||
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent");
|
||||
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
|
||||
expect(r.fields.task).toBe("8791b717");
|
||||
});
|
||||
|
||||
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
|
||||
// Real sample: error="Post \"http://...\": dial tcp ..."
|
||||
const line =
|
||||
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.level).toBe("WARN");
|
||||
expect(r.message).toBe("claim task failed");
|
||||
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
|
||||
expect(r.fields.error).toBe(
|
||||
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
|
||||
const line =
|
||||
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent command");
|
||||
expect(r.fields.exec).toBe("claude");
|
||||
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
|
||||
});
|
||||
|
||||
it("handles message words ending with characters before the field block", () => {
|
||||
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
|
||||
const r = parseLogLine(
|
||||
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("execenv: prepared env");
|
||||
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
|
||||
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
|
||||
expect(r.fields).toEqual({});
|
||||
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for unrecognised level tokens", () => {
|
||||
// If tint ever emits something we don't know, never crash; show raw.
|
||||
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
|
||||
});
|
||||
|
||||
it("attaches an id to every parsed line for stable React keys", () => {
|
||||
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
|
||||
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
|
||||
expect(a.id).toBe(7);
|
||||
expect(b.id).toBe(8);
|
||||
});
|
||||
|
||||
it("returns empty fields object when there are no key=value pairs", () => {
|
||||
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
|
||||
expect(r.message).toBe("a bare message with no fields");
|
||||
expect(r.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
96
apps/desktop/src/renderer/src/components/parse-daemon-log.ts
Normal file
96
apps/desktop/src/renderer/src/components/parse-daemon-log.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Pure parser for daemon log lines. The daemon writes via Go's slog with
|
||||
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
|
||||
// has a stable shape:
|
||||
//
|
||||
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
|
||||
//
|
||||
// We split it into structured pieces so the UI can render timestamp,
|
||||
// level, message and structured fields in separate columns and let users
|
||||
// filter / search across them. Anything that doesn't match (panic stack
|
||||
// traces, third-party prints, partial writes during log rotation) falls
|
||||
// back to a raw view — we never drop input.
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ParsedLogLine {
|
||||
/** Monotonic id assigned at receive time; stable across re-renders. */
|
||||
id: number;
|
||||
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
|
||||
timestamp: string | null;
|
||||
level: LogLevel | null;
|
||||
/** Human-readable message body, with structured fields stripped off. */
|
||||
message: string;
|
||||
/** key/value pairs trailing the message. Empty if there were none. */
|
||||
fields: Record<string, string>;
|
||||
/** The original line, kept for fallback rendering and copy-to-clipboard. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
|
||||
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
|
||||
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
|
||||
// against future config changes) and normalize them to a canonical
|
||||
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
|
||||
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
|
||||
// and complicates the level filter chips.
|
||||
const HEADER_RE =
|
||||
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
|
||||
|
||||
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
|
||||
DEBUG: "DEBUG",
|
||||
DBG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
INF: "INFO",
|
||||
WARN: "WARN",
|
||||
WRN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
ERR: "ERROR",
|
||||
};
|
||||
// Anchored to the END of the remaining string so we peel one field at a
|
||||
// time from the right. `value` is either a double-quoted string (which may
|
||||
// contain escaped chars) or any non-whitespace run.
|
||||
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractTrailingFields(rest: string): {
|
||||
message: string;
|
||||
fields: Record<string, string>;
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
let work = rest;
|
||||
while (true) {
|
||||
const match = work.match(TRAILING_FIELD_RE);
|
||||
if (!match || match.index === undefined) break;
|
||||
fields[match[1]!] = unquote(match[2]!);
|
||||
work = work.slice(0, match.index);
|
||||
}
|
||||
return { message: work.trim(), fields };
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string, id: number): ParsedLogLine {
|
||||
const match = raw.match(HEADER_RE);
|
||||
if (!match) {
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const [, timestamp, level, rest] = match;
|
||||
const normalized = LEVEL_NORMALIZE[level!];
|
||||
if (!normalized) {
|
||||
// Unknown level token — keep raw shape so we don't mis-categorize.
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const { message, fields } = extractTrailingFields(rest!);
|
||||
return {
|
||||
id,
|
||||
timestamp: timestamp!,
|
||||
level: normalized,
|
||||
message,
|
||||
fields,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
@@ -40,7 +39,6 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
@@ -22,7 +23,7 @@ export function UpdatesSettingsTab() {
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
: { status: "up-to-date" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -35,6 +36,15 @@ export function UpdatesSettingsTab() {
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
@@ -45,7 +55,7 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
You're on the latest version.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,7 @@ export function WorkspaceRouteLayout() {
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
|
||||
18
apps/desktop/src/renderer/src/pages/agent-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/agent-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agent = agents.find((a) => a.id === id) ?? null;
|
||||
|
||||
useDocumentTitle(agent?.name ?? "Agent");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedAgentDetailPage agentId={id} />;
|
||||
}
|
||||
18
apps/desktop/src/renderer/src/pages/runtime-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/runtime-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function RuntimeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const runtime = runtimes?.find((r) => r.id === id);
|
||||
|
||||
useDocumentTitle(runtime?.name ?? "Runtime");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedRuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
76
apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
Normal file
76
apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { runtimeKeys } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
|
||||
* type — we redeclare the fields we consume here to avoid coupling the bridge
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
|
||||
* field is overridden; other fields (name, provider, last_seen_at, etc)
|
||||
* remain server-authoritative. We deliberately ignore intermediate states
|
||||
* (starting / stopping / installing_cli / cli_not_found) so the cache
|
||||
* doesn't flap during boot — if the daemon is in such a state, the runtime
|
||||
* is effectively offline anyway, and the server-side sweeper will mark it
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return {
|
||||
...rt,
|
||||
status: "online",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local daemon status changes via Electron IPC and writes them
|
||||
* into the runtimes Query cache for the active workspace.
|
||||
*
|
||||
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
|
||||
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
|
||||
* we know about local daemon state instantly via IPC, so we use it to
|
||||
* pre-populate the cache and give users a sub-second feedback loop. Web and
|
||||
* "looking at someone else's daemon" still go through the server path.
|
||||
*
|
||||
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
|
||||
* in the same workspace (one per provider). We map across all matches so
|
||||
* every related runtime row sees the same status flip.
|
||||
*/
|
||||
export function useDaemonIPCBridge(wsId: string | undefined): void {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
|
||||
if (!daemonAPI?.onStatusChange) return;
|
||||
|
||||
const unsubscribe = daemonAPI.onStatusChange((status) => {
|
||||
if (!status.daemonId) return;
|
||||
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((rt) =>
|
||||
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [wsId, qc]);
|
||||
}
|
||||
@@ -115,10 +115,10 @@ export function DesktopNavigationProvider({
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() can read URL search params.
|
||||
// Must stay in sync with TabNavigationProvider below; a partial shape
|
||||
// here (just pathname) silently broke focus-mode anchor resolution on
|
||||
// `/inbox?issue=…`.
|
||||
// shell-level consumers of useNavigation() — ChatWindow in particular —
|
||||
// can read URL search params. Must stay in sync with TabNavigationProvider
|
||||
// below; a partial shape here (just pathname) silently broke focus-mode
|
||||
// anchor resolution on `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
|
||||
@@ -10,6 +10,8 @@ import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
@@ -18,7 +20,6 @@ import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { ChatPage } from "@multica/views/chat";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
@@ -118,6 +119,11 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{
|
||||
path: "runtimes/:id",
|
||||
element: <RuntimeDetailPage />,
|
||||
handle: { title: "Runtime" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
@@ -125,8 +131,12 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
@@ -101,7 +101,6 @@ interface TabStore {
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
chat: "MessageSquare",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
|
||||
@@ -51,3 +51,35 @@ export function formatUptime(uptime?: string): string {
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
* "what does this mean for me?" — i.e. whether tasks can run on this device.
|
||||
*
|
||||
* `runtimeCount` is the number of runtimes the local daemon has registered
|
||||
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
|
||||
* when state === "running".
|
||||
*/
|
||||
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
if (runtimeCount === 0) {
|
||||
return "Running, but no runtimes have registered yet.";
|
||||
}
|
||||
if (runtimeCount === 1) {
|
||||
return "Running here · 1 runtime available for tasks.";
|
||||
}
|
||||
return `Running here · ${runtimeCount} runtimes available for tasks.`;
|
||||
case "stopped":
|
||||
return "Not running · this device can't take new tasks.";
|
||||
case "starting":
|
||||
return "Starting up the local daemon…";
|
||||
case "stopping":
|
||||
return "Shutting down the local daemon…";
|
||||
case "installing_cli":
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,38 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
1
apps/web/.gitignore
vendored
Normal file
1
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
@@ -72,10 +72,6 @@ function LoginPageContent() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
@@ -89,10 +85,6 @@ function LoginPageContent() {
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
|
||||
@@ -32,20 +32,25 @@ export default function OnboardingPage() {
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
enabled: !!user,
|
||||
});
|
||||
const hasWorkspaces = workspaces.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
if (!workspacesFetched) return;
|
||||
// Bounce out if onboarding doesn't apply: either already onboarded, or
|
||||
// the user already has a workspace (e.g. arrived via invitation) — we
|
||||
// never trap an in-workspace user on the onboarding screen.
|
||||
if (hasOnboarded || hasWorkspaces) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, hasWorkspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
if (isLoading || !user || hasOnboarded || hasWorkspaces) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { AgentDetailPage } from "@multica/views/agents";
|
||||
|
||||
export default function AgentDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <AgentDetailPage agentId={id} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ChatPage as default } from "@multica/views/chat";
|
||||
@@ -3,6 +3,7 @@
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
@@ -13,6 +14,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
extra={
|
||||
<>
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { RuntimeDetailPage } from "@multica/views/runtimes";
|
||||
|
||||
export default function RuntimeDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <RuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
@@ -61,28 +61,54 @@ import CallbackPage from "./page";
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
// Snapshot keys before deleting — forEach + delete skips entries because
|
||||
// the iteration index advances while the underlying list shrinks.
|
||||
Array.from(mockSearchParams.keys()).forEach((k) =>
|
||||
mockSearchParams.delete(k),
|
||||
);
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
|
||||
mockListWorkspaces.mockResolvedValue([
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "ACME",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
|
||||
@@ -66,10 +66,10 @@ function CallbackContent() {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
// Workspace presence beats onboarding state: an invitee with zero
|
||||
// `onboarded_at` but a real workspace must land in that workspace,
|
||||
// not in the new-workspace wizard. A `next=` (e.g. /invite/<id>)
|
||||
// always wins so invite acceptance flows survive auth round-trips.
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
|
||||
61
apps/web/app/not-found.tsx
Normal file
61
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
|
||||
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
|
||||
// inline — these brand experiments have not been promoted to design tokens.
|
||||
// The route lives outside the (landing) group's font scope, so we attach
|
||||
// Instrument Serif locally to match the editorial direction.
|
||||
const CREAM = "#faf9f6";
|
||||
const INK = "#1b1812";
|
||||
const TERRACOTTA = "#a64a2c";
|
||||
|
||||
const editorialSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section
|
||||
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
|
||||
style={{ backgroundColor: CREAM, color: INK }}
|
||||
>
|
||||
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
|
||||
style={{ color: TERRACOTTA }}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
<span>error · not found</span>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
</div>
|
||||
|
||||
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
|
||||
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
|
||||
This page{" "}
|
||||
<em className="not-italic" style={{ color: TERRACOTTA }}>
|
||||
doesn’t exist
|
||||
</em>
|
||||
.
|
||||
</p>
|
||||
<p
|
||||
className="mt-5 max-w-md text-center text-sm leading-relaxed"
|
||||
style={{ color: INK, opacity: 0.6 }}
|
||||
>
|
||||
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
|
||||
style={{ background: INK, color: CREAM }}
|
||||
>
|
||||
Back to Multica
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +283,29 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
title: "Create Issue by Agent, Agent Presence v3 & Daemon WebSocket Heartbeat",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent — press `c`, write one line, pick an agent; issue creation runs async and the result lands in your inbox",
|
||||
"Agent Presence v3 — availability and last-task split into clearer signals, with an execution log on the issue panel showing active and recent runs",
|
||||
"Daemon ↔ server heartbeat now flows over WebSocket with HTTP fallback, cutting task wakeup latency",
|
||||
"Mention picker ranks suggestions by your local recency",
|
||||
],
|
||||
improvements: [
|
||||
"Server caches PAT / daemon token lookups in Redis, so large fleets stop hammering the database on every request",
|
||||
"Backend default agent CLI args via `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` env vars",
|
||||
"Manual and agent create-issue flows share one dialog shell, and picker agents become the default assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create-issue-by-agent no longer leaves tasks stuck queued, and no longer duplicates the issue when an attachment upload fails",
|
||||
"Agent comments respect newlines instead of rendering literal `\\n`, and multi-line replies keep their formatting",
|
||||
"Agent-authored root comments no longer inherit parent @mentions, breaking accidental agent loops",
|
||||
"Cursor agent on Windows preserves multi-line prompts",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
|
||||
@@ -283,6 +283,29 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
title: "Create Issue by Agent、Agent Presence v3 与 Daemon WebSocket 心跳",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent —— 按 `c` 输入一句话并选 Agent,Issue 异步创建,结果回执送达 Inbox",
|
||||
"Agent Presence v3 —— 可用性与最近任务拆成两条更清晰的信号;Issue 详情右侧新增 Execution Log,可看到当前 active run 与历史 run",
|
||||
"Daemon ↔ Server 心跳改走 WebSocket,HTTP 自动 fallback,任务起跑延迟更低",
|
||||
"Mention 选择器按本机最近使用排序",
|
||||
],
|
||||
improvements: [
|
||||
"Server 用 Redis 缓存 PAT / Daemon Token 校验,大型团队不再让 DB 抗下每次请求",
|
||||
"后端支持通过 `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` 配置 Agent CLI 默认参数",
|
||||
"Manual 与 Agent 创建 Issue 共享同一个 Dialog 外壳,picker Agent 会被默认设为 assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create Issue by Agent 不再卡住 queued 任务,也不再因附件上传失败而重复创建 Issue",
|
||||
"Agent 评论保留换行,不再渲染成字面量 `\\n`,多行回复的格式也被完整保留",
|
||||
"Agent 自身发出的根评论不再继承父评论的 @mention,避免互相唤起的死循环",
|
||||
"Windows 下 Cursor Agent 启动时保留多行 prompt",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
4
packages/core/agents/constants.ts
Normal file
4
packages/core/agents/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// User-facing limits enforced symmetrically on the front-end (UI counter +
|
||||
// disabled save) and the back-end (handler validation + DB CHECK constraint).
|
||||
// Kept in core so both apps and the test suite read from one source.
|
||||
export const AGENT_DESCRIPTION_MAX_LENGTH = 255;
|
||||
418
packages/core/agents/derive-presence.test.ts
Normal file
418
packages/core/agents/derive-presence.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import {
|
||||
buildPresenceMap,
|
||||
deriveAgentAvailability,
|
||||
deriveAgentPresenceDetail,
|
||||
deriveWorkload,
|
||||
deriveWorkloadDetail,
|
||||
} from "./derive-presence";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: "rt-1",
|
||||
name: "Test Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 6,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Anchor for all wall-clock comparisons in the suite. Pairs with the
|
||||
// runtime fixture's last_seen_at (10s before NOW) so an "online" runtime
|
||||
// looks fresh by default.
|
||||
const NOW = new Date("2026-04-27T12:00:00Z").getTime();
|
||||
|
||||
function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "rt-1",
|
||||
issue_id: "",
|
||||
status: "queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-27T11:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveAgentAvailability", () => {
|
||||
// Reachability dimension only — runtime + clock decide it; tasks are
|
||||
// irrelevant to this axis.
|
||||
|
||||
it("returns online when runtime is fresh-online", () => {
|
||||
expect(deriveAgentAvailability(makeRuntime(), NOW)).toBe("online");
|
||||
});
|
||||
|
||||
it("returns unstable when runtime just dropped (< 5 min)", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:59:30Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("unstable");
|
||||
});
|
||||
|
||||
it("returns offline when runtime has been gone > 5 min", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:50:00Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("collapses about_to_gc into offline (it's a runtime-card concern, not the dot)", () => {
|
||||
expect(
|
||||
deriveAgentAvailability(
|
||||
// 6.5 days ago — past the 6-day about_to_gc threshold.
|
||||
makeRuntime({ status: "offline", last_seen_at: "2026-04-21T00:00:00Z" }),
|
||||
NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("returns offline when the runtime is null (deleted / never registered)", () => {
|
||||
expect(deriveAgentAvailability(null, NOW)).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkload", () => {
|
||||
// Atomic 3-way classifier — used by both Agent (per-agent task counts)
|
||||
// and Runtime (per-runtime aggregated counts). Pure functional mapping
|
||||
// from a count pair to a workload label.
|
||||
|
||||
it("returns working when runningCount > 0", () => {
|
||||
expect(deriveWorkload({ runningCount: 1, queuedCount: 0 })).toBe("working");
|
||||
expect(deriveWorkload({ runningCount: 3, queuedCount: 5 })).toBe("working");
|
||||
});
|
||||
|
||||
it("returns queued when nothing running but queuedCount > 0", () => {
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 1 })).toBe("queued");
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 5 })).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns idle when both counts are zero", () => {
|
||||
expect(deriveWorkload({ runningCount: 0, queuedCount: 0 })).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkloadDetail", () => {
|
||||
// Aggregates a task list into running/queued counts before classifying.
|
||||
// Terminal statuses (completed / failed / cancelled) are silently
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
|
||||
it("returns idle when no tasks at all", () => {
|
||||
const r = deriveWorkloadDetail([]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns working when at least one task is running", () => {
|
||||
const r = deriveWorkloadDetail([makeTask({ status: "running" })]);
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns queued when only queued / dispatched tasks exist (no running)", () => {
|
||||
// The "stuck on offline runtime" scenario in isolation: runningCount=0,
|
||||
// queuedCount>0 surfaces as `queued` so the UI can honestly say
|
||||
// "Queued · N" instead of misleading "Running 0/3 +Nq".
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "dispatched" }),
|
||||
]);
|
||||
expect(r.workload).toBe("queued");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns working when running coexists with queued (overflow)", () => {
|
||||
// Capacity-saturated agent: still running, but with a queue building.
|
||||
// The chip says "Working" with the queue expressed as a `+Nq` badge.
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ id: "t1", status: "running" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
makeTask({ id: "t3", status: "queued" }),
|
||||
]);
|
||||
expect(r.workload).toBe("working");
|
||||
expect(r.runningCount).toBe(1);
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores terminal statuses entirely (no historical state in workload)", () => {
|
||||
// Failed / completed / cancelled tasks contribute no count and don't
|
||||
// change the verdict — Recent Work + Inbox handle history, not workload.
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({
|
||||
id: "t-failed",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-completed",
|
||||
status: "completed",
|
||||
completed_at: "2026-04-27T11:00:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "t-cancelled",
|
||||
status: "cancelled",
|
||||
completed_at: "2026-04-27T10:30:00Z",
|
||||
}),
|
||||
]);
|
||||
expect(r.workload).toBe("idle");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("classifies running over queued when both present, regardless of order", () => {
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ id: "t1", status: "queued" }),
|
||||
makeTask({ id: "t2", status: "running" }),
|
||||
]);
|
||||
expect(r.workload).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAgentPresenceDetail", () => {
|
||||
// Composition: the two dimensions are derived independently and the
|
||||
// detail object exposes both. No cross-axis override — workload never
|
||||
// colours the dot, availability never overrides workload.
|
||||
|
||||
it("composes online + working for the common busy case", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [
|
||||
makeTask({ status: "running" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("working");
|
||||
expect(detail.runningCount).toBe(1);
|
||||
expect(detail.queuedCount).toBe(1);
|
||||
expect(detail.capacity).toBe(6);
|
||||
});
|
||||
|
||||
it("composes offline + queued — the canonical 'stuck' case (was previously misleading 'running 0/N')", () => {
|
||||
// The motivation for the redesign: runtime offline + queued tasks
|
||||
// used to surface as `running` with `0/3 +2q` counts (literally false).
|
||||
// Workload now returns `queued` honestly, paired with offline
|
||||
// availability — UI reads "Offline · Queued · 2".
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [
|
||||
makeTask({ status: "queued" }),
|
||||
makeTask({ id: "t2", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("queued");
|
||||
expect(detail.runningCount).toBe(0);
|
||||
expect(detail.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("composes unstable + working — runtime hiccup with tasks still in flight", () => {
|
||||
// Recently-lost runtime, but a task is still recorded as running.
|
||||
// Both signals surface independently — amber dot AND working chip —
|
||||
// so the user sees "connection wobbling" alongside "agent is busy".
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:59:00Z",
|
||||
}),
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("unstable");
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("composes offline + idle for an unreachable agent with no tasks pending", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:50:00Z",
|
||||
}),
|
||||
tasks: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("handles a missing runtime by reporting offline + the task-driven workload", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: null,
|
||||
tasks: [makeTask({ status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("offline");
|
||||
expect(detail.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("returns idle workload when only terminal tasks are present (history doesn't bleed in)", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent(),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [
|
||||
makeTask({
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.availability).toBe("online");
|
||||
expect(detail.workload).toBe("idle");
|
||||
});
|
||||
|
||||
it("mirrors agent.max_concurrent_tasks into capacity", () => {
|
||||
const detail = deriveAgentPresenceDetail({
|
||||
agent: makeAgent({ max_concurrent_tasks: 3 }),
|
||||
runtime: makeRuntime(),
|
||||
tasks: [],
|
||||
now: NOW,
|
||||
});
|
||||
expect(detail.capacity).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPresenceMap", () => {
|
||||
it("returns one entry per agent, sourcing tasks by agent_id from a flat list", () => {
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA, agentB],
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "running" }),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "queued" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
const a = map.get("a");
|
||||
const b = map.get("b");
|
||||
expect(a?.availability).toBe("online");
|
||||
expect(a?.workload).toBe("working");
|
||||
expect(b?.availability).toBe("online");
|
||||
expect(b?.workload).toBe("queued");
|
||||
});
|
||||
|
||||
it("returns offline availability for agents whose runtime_id has no matching runtime", () => {
|
||||
const orphan = makeAgent({ id: "orphan", runtime_id: "missing" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [orphan],
|
||||
runtimes: [],
|
||||
snapshot: [makeTask({ agent_id: "orphan", status: "running" })],
|
||||
now: NOW,
|
||||
});
|
||||
const o = map.get("orphan");
|
||||
expect(o?.availability).toBe("offline");
|
||||
// Workload still resolves independently — running task counts.
|
||||
expect(o?.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("threads the same `now` so every agent on a shared runtime gets the same availability", () => {
|
||||
// Multi-agent scenario: one local daemon backs N agents, daemon dies.
|
||||
// All dependent agents should report unstable together — the shared
|
||||
// `now` parameter is what guarantees consistent bucket boundaries.
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA, agentB],
|
||||
runtimes: [
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: "2026-04-27T11:59:00Z",
|
||||
}),
|
||||
],
|
||||
snapshot: [
|
||||
makeTask({ id: "t1", agent_id: "a", status: "queued" }),
|
||||
makeTask({ id: "t2", agent_id: "b", status: "running" }),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.availability).toBe("unstable");
|
||||
expect(map.get("b")?.availability).toBe("unstable");
|
||||
// Workload remains independent: a is queued (waiting), b is working.
|
||||
expect(map.get("a")?.workload).toBe("queued");
|
||||
expect(map.get("b")?.workload).toBe("working");
|
||||
});
|
||||
|
||||
it("ignores terminal tasks in the snapshot when building per-agent workload", () => {
|
||||
// Snapshot intentionally still includes each agent's most recent
|
||||
// terminal task (back-end SQL didn't change); the front-end now
|
||||
// filters them out at the workload-derivation step.
|
||||
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
|
||||
const map = buildPresenceMap({
|
||||
agents: [agentA],
|
||||
runtimes: [makeRuntime()],
|
||||
snapshot: [
|
||||
makeTask({
|
||||
id: "t-terminal",
|
||||
agent_id: "a",
|
||||
status: "failed",
|
||||
completed_at: "2026-04-27T11:30:00Z",
|
||||
}),
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
expect(map.get("a")?.workload).toBe("idle");
|
||||
});
|
||||
});
|
||||
134
packages/core/agents/derive-presence.ts
Normal file
134
packages/core/agents/derive-presence.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Pure derivation of an agent's user-facing presence from raw server data.
|
||||
// The back-end stores facts (which tasks exist, their statuses, the runtime
|
||||
// last_seen_at); the front-end translates them into two orthogonal
|
||||
// dimensions:
|
||||
//
|
||||
// 1. AgentAvailability — derived from runtime reachability only.
|
||||
// 2. Workload — derived from the task counts only.
|
||||
//
|
||||
// They are computed independently and assembled into AgentPresenceDetail.
|
||||
// Workload is strictly "what's on the plate right now" — no historical
|
||||
// terminal state. Past failures / completions live on the detail page
|
||||
// (Recent Work, failure_reason) and Inbox.
|
||||
|
||||
import { deriveRuntimeHealth } from "../runtimes/derive-health";
|
||||
import type { Agent, AgentRuntime, AgentTask } from "../types";
|
||||
import type {
|
||||
AgentAvailability,
|
||||
AgentPresenceDetail,
|
||||
Workload,
|
||||
} from "./types";
|
||||
|
||||
// AgentAvailability mirrors RuntimeHealth's reachability buckets but folds
|
||||
// `about_to_gc` into `offline` — both mean "long unreachable" from the
|
||||
// user's standpoint; the GC-warning copy belongs to the runtime card, not
|
||||
// the agent dot.
|
||||
export function deriveAgentAvailability(
|
||||
runtime: AgentRuntime | null,
|
||||
now: number,
|
||||
): AgentAvailability {
|
||||
if (!runtime) return "offline";
|
||||
const health = deriveRuntimeHealth(runtime, now);
|
||||
if (health === "online") return "online";
|
||||
if (health === "recently_lost") return "unstable";
|
||||
return "offline"; // offline | about_to_gc collapse here
|
||||
}
|
||||
|
||||
// Atomic workload derivation: pure 3-way classification of running/queued
|
||||
// counts. Exported so Runtime-level views (which already aggregate counts
|
||||
// per-runtime in their own indices) can plug into the same vocabulary
|
||||
// without re-deriving from raw task arrays.
|
||||
export function deriveWorkload(counts: {
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}): Workload {
|
||||
if (counts.runningCount > 0) return "working";
|
||||
if (counts.queuedCount > 0) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
interface WorkloadDetail {
|
||||
workload: Workload;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
}
|
||||
|
||||
// Aggregates a task list into running/queued counts, then classifies via
|
||||
// deriveWorkload. Caller pre-filters to the relevant scope (per-agent or
|
||||
// per-runtime) — we don't filter again here.
|
||||
export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetail {
|
||||
let runningCount = 0;
|
||||
let queuedCount = 0;
|
||||
for (const t of tasks) {
|
||||
if (t.status === "running") {
|
||||
runningCount += 1;
|
||||
} else if (t.status === "queued" || t.status === "dispatched") {
|
||||
queuedCount += 1;
|
||||
}
|
||||
// Terminal statuses (completed / failed / cancelled) intentionally
|
||||
// ignored — workload is "what's on the plate right now", not history.
|
||||
}
|
||||
return {
|
||||
workload: deriveWorkload({ runningCount, queuedCount }),
|
||||
runningCount,
|
||||
queuedCount,
|
||||
};
|
||||
}
|
||||
|
||||
interface DerivePresenceInput {
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
// Tasks for THIS agent only. Callers (buildPresenceMap, hooks) pre-filter
|
||||
// by agent_id — we don't re-check here.
|
||||
tasks: readonly AgentTask[];
|
||||
// Wall-clock millis used by deriveAgentAvailability to bucket runtime
|
||||
// health. Threading it as a parameter keeps the function pure.
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPresenceDetail {
|
||||
const availability = deriveAgentAvailability(input.runtime, input.now);
|
||||
const detail = deriveWorkloadDetail(input.tasks);
|
||||
|
||||
return {
|
||||
availability,
|
||||
workload: detail.workload,
|
||||
runningCount: detail.runningCount,
|
||||
queuedCount: detail.queuedCount,
|
||||
capacity: input.agent.max_concurrent_tasks,
|
||||
};
|
||||
}
|
||||
|
||||
// Workspace-level batch builder. One pass over the workspace's agents
|
||||
// produces a Map<agentId, AgentPresenceDetail> that every list / card /
|
||||
// runtime sub-page can read without re-deriving.
|
||||
export function buildPresenceMap(args: {
|
||||
agents: readonly Agent[];
|
||||
runtimes: readonly AgentRuntime[];
|
||||
// The workspace agent task snapshot: every active task plus each agent's
|
||||
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
|
||||
// — no pre-filtering needed. Terminal rows are silently ignored by
|
||||
// deriveWorkloadDetail (workload is current-state only).
|
||||
snapshot: readonly AgentTask[];
|
||||
now: number;
|
||||
}): Map<string, AgentPresenceDetail> {
|
||||
const out = new Map<string, AgentPresenceDetail>();
|
||||
const runtimesById = new Map<string, AgentRuntime>();
|
||||
for (const r of args.runtimes) runtimesById.set(r.id, r);
|
||||
|
||||
// Group tasks by agent_id once — O(N) — so per-agent derivation is O(1)
|
||||
// task scans rather than O(N×M).
|
||||
const tasksByAgent = new Map<string, AgentTask[]>();
|
||||
for (const t of args.snapshot) {
|
||||
const list = tasksByAgent.get(t.agent_id);
|
||||
if (list) list.push(t);
|
||||
else tasksByAgent.set(t.agent_id, [t]);
|
||||
}
|
||||
|
||||
for (const agent of args.agents) {
|
||||
const runtime = runtimesById.get(agent.runtime_id) ?? null;
|
||||
const tasks = tasksByAgent.get(agent.id) ?? [];
|
||||
out.set(agent.id, deriveAgentPresenceDetail({ agent, runtime, tasks, now: args.now }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
7
packages/core/agents/index.ts
Normal file
7
packages/core/agents/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./types";
|
||||
export * from "./derive-presence";
|
||||
export * from "./queries";
|
||||
export * from "./use-agent-presence";
|
||||
export * from "./use-agent-activity";
|
||||
export * from "./use-workspace-presence-prefetch";
|
||||
export * from "./constants";
|
||||
84
packages/core/agents/queries.ts
Normal file
84
packages/core/agents/queries.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const agentTaskSnapshotKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-task-snapshot"] as const,
|
||||
list: (wsId: string) => [...agentTaskSnapshotKeys.all(wsId), "list"] as const,
|
||||
};
|
||||
|
||||
export const agentActivityKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-activity"] as const,
|
||||
last30d: (wsId: string) => [...agentActivityKeys.all(wsId), "30d"] as const,
|
||||
};
|
||||
|
||||
export const agentRunCountsKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-run-counts"] as const,
|
||||
last30d: (wsId: string) => [...agentRunCountsKeys.all(wsId), "30d"] as const,
|
||||
};
|
||||
|
||||
// Workspace-scoped agent task snapshot — every active task plus each agent's
|
||||
// most recent terminal task. This is the single shared source of truth that
|
||||
// powers per-agent presence derivation across the app. One fetch per
|
||||
// workspace; all agent dots / hover cards / list rows derive presence from
|
||||
// this cache with zero additional network traffic.
|
||||
//
|
||||
// The 30s staleTime is a safety net only; the primary freshness signal is
|
||||
// WS task events, which invalidate this query immediately. Without WS,
|
||||
// presence still updates within 30s on focus / mount.
|
||||
export function agentTaskSnapshotOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentTaskSnapshotKeys.list(wsId),
|
||||
queryFn: () => api.getAgentTaskSnapshot(),
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace-wide daily task activity for the last 30 days, anchored on
|
||||
// completed_at. One fetch backs both the Agents-list sparkline (which
|
||||
// only uses the trailing 7 buckets via `summarizeActivityWindow`) and
|
||||
// the agent detail "Last 30 days" panel. WS task lifecycle events
|
||||
// invalidate this query in useRealtimeSync; the staleTime is a
|
||||
// tab-focus safety net.
|
||||
export function agentActivity30dOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentActivityKeys.last30d(wsId),
|
||||
queryFn: () => api.getWorkspaceAgentActivity30d(),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace-wide 30-day run counts for the Agents-list RUNS column. Same
|
||||
// single-fetch / WS-invalidate pattern as activity24hOptions.
|
||||
export function agentRunCounts30dOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentRunCountsKeys.last30d(wsId),
|
||||
queryFn: () => api.getWorkspaceAgentRunCounts(),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
export const agentTasksKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId, "agent-tasks"] as const,
|
||||
detail: (wsId: string, agentId: string) =>
|
||||
[...agentTasksKeys.all(wsId), agentId] as const,
|
||||
};
|
||||
|
||||
// All tasks for a single agent (the agent detail page consumer). Powers both
|
||||
// the inspector's 7-day throughput stats and the Tasks tab list — shared so
|
||||
// they don't fetch twice. WS task events invalidate this via the existing
|
||||
// task-prefix invalidation in useRealtimeSync.
|
||||
export function agentTasksOptions(wsId: string, agentId: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentTasksKeys.detail(wsId, agentId),
|
||||
queryFn: () => api.listAgentTasks(agentId),
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
62
packages/core/agents/types.ts
Normal file
62
packages/core/agents/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Derived presence types for agents — the user-facing state we display
|
||||
// across the UI (list dots, hover cards, status lines). Computed in the
|
||||
// front-end from raw server data (agent + runtime + recent tasks); the
|
||||
// back-end never knows about these enums.
|
||||
//
|
||||
// Two orthogonal dimensions, derived independently and answering only
|
||||
// "what's true right now?" — historical / error context lives on the
|
||||
// agent detail page (Recent Work, failure_reason) and Inbox, not in the
|
||||
// list-level summary state:
|
||||
//
|
||||
// 1. AgentAvailability — "Can this agent take work right now?"
|
||||
// Depends only on runtime reachability. The dot colour everywhere in
|
||||
// the app reflects this single dimension; never sticky-red because of
|
||||
// a past task outcome.
|
||||
//
|
||||
// 2. Workload — "What is on this agent's plate right now?"
|
||||
// Depends only on the workspace task snapshot. Three states, each
|
||||
// pointing at a clear user action:
|
||||
// working → tasks running, normal
|
||||
// queued → tasks queued but nothing running (= stuck if availability
|
||||
// is offline/unstable; momentary if online)
|
||||
// idle → nothing to do
|
||||
// No `failed` / `completed` / `cancelled` states — those are historical,
|
||||
// surfaced via Recent Work + Inbox.
|
||||
|
||||
// Runtime-reachability dimension. `unstable` is the transient amber state
|
||||
// during the runtime sweeper's grace window (offline < 5 min); it decays
|
||||
// into `offline` with no new server data, hence the 30s presence tick on
|
||||
// the consuming hooks.
|
||||
export type AgentAvailability =
|
||||
| "online" // 🟢 runtime online and reachable
|
||||
| "unstable" // 🟡 runtime recently_lost (< 5 min) — transient
|
||||
| "offline"; // ⚫ runtime long offline / missing / never registered
|
||||
|
||||
// Current task load on this agent. Three states — never historical,
|
||||
// never an error predictor (Inbox + Recent Work handle that):
|
||||
//
|
||||
// working → runningCount > 0. The runningCount/queuedCount on the detail
|
||||
// object preserve the breakdown for display.
|
||||
// queued → no running task but ≥1 queued/dispatched. Most often means
|
||||
// the runtime is offline and tasks are stuck waiting; a brief
|
||||
// flash on online runtimes between dispatch and run is a
|
||||
// harmless race.
|
||||
// idle → nothing on the plate.
|
||||
//
|
||||
// Pair with availability for the full picture: `online + working` is
|
||||
// normal; `offline + queued` is the "stuck" state we explicitly surface;
|
||||
// `offline + idle` is "agent unavailable, nothing waiting" — both honest.
|
||||
export type Workload =
|
||||
| "working" // ≥1 task currently running
|
||||
| "queued" // nothing running, but ≥1 queued/dispatched
|
||||
| "idle"; // nothing on the plate
|
||||
|
||||
export interface AgentPresenceDetail {
|
||||
availability: AgentAvailability;
|
||||
workload: Workload;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
// Mirrors agent.max_concurrent_tasks — pulled into the detail so the UI
|
||||
// can render `running / capacity` ratios without re-fetching the agent.
|
||||
capacity: number;
|
||||
}
|
||||
192
packages/core/agents/use-agent-activity.test.ts
Normal file
192
packages/core/agents/use-agent-activity.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, AgentActivityBucket } from "../types";
|
||||
import {
|
||||
buildActivityMap,
|
||||
deriveAgentActivity,
|
||||
summarizeActivityWindow,
|
||||
} from "./use-agent-activity";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Fixed anchor — derivation uses local-time start of "today", a real
|
||||
// clock would drift. 12:00 also keeps "today" stable across odd timezones.
|
||||
const NOW = new Date("2026-04-28T12:00:00").getTime();
|
||||
|
||||
function bucket(
|
||||
agentId: string,
|
||||
daysAgo: number,
|
||||
taskCount: number,
|
||||
failedCount = 0,
|
||||
): AgentActivityBucket {
|
||||
const t = new Date(NOW);
|
||||
t.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
agent_id: agentId,
|
||||
bucket_at: new Date(t.getTime() - daysAgo * DAY).toISOString(),
|
||||
task_count: taskCount,
|
||||
failed_count: failedCount,
|
||||
};
|
||||
}
|
||||
|
||||
const fullHistoryAgent: Agent = {
|
||||
id: "a1",
|
||||
workspace_id: "w",
|
||||
runtime_id: "r1",
|
||||
name: "Old Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
// Older than the window so daysSinceCreated saturates at DAYS.
|
||||
created_at: new Date(NOW - 100 * DAY).toISOString(),
|
||||
updated_at: new Date(NOW).toISOString(),
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
|
||||
describe("deriveAgentActivity", () => {
|
||||
it("places buckets in oldest→newest slots across 30 days", () => {
|
||||
const buckets = [
|
||||
bucket("a1", 29, 1), // slot 0
|
||||
bucket("a1", 0, 5), // slot 29
|
||||
];
|
||||
const result = deriveAgentActivity(
|
||||
buckets,
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
expect(result.buckets).toHaveLength(30);
|
||||
expect(result.buckets[0]).toEqual({ total: 1, failed: 0 });
|
||||
expect(result.buckets[29]).toEqual({ total: 5, failed: 0 });
|
||||
expect(result.daysSinceCreated).toBe(30);
|
||||
});
|
||||
|
||||
it("clamps daysSinceCreated for young agents", () => {
|
||||
const created = new Date(NOW - 3 * DAY - 60 * 1000).toISOString();
|
||||
const result = deriveAgentActivity([bucket("fresh", 1, 4)], created, NOW);
|
||||
expect(result.daysSinceCreated).toBe(3);
|
||||
});
|
||||
|
||||
it("treats sub-day-old agents as daysSinceCreated = 0", () => {
|
||||
const created = new Date(NOW - 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = deriveAgentActivity([bucket("fresh", 0, 1)], created, NOW);
|
||||
expect(result.daysSinceCreated).toBe(0);
|
||||
// Today's bucket still records — pre-life days simply look like zero
|
||||
// days, which is on purpose.
|
||||
expect(result.buckets[29]).toEqual({ total: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("ignores buckets older than the 30-day window", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[bucket("a1", 60, 99)],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
expect(
|
||||
result.buckets.reduce((s, b) => s + b.total, 0),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("zero-fills when the agent has no buckets", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
expect(result.buckets).toHaveLength(30);
|
||||
expect(result.buckets.every((b) => b.total === 0 && b.failed === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeActivityWindow", () => {
|
||||
it("rolls up totals across the trailing N buckets", () => {
|
||||
// 5 runs total over the 30-day series.
|
||||
const result = deriveAgentActivity(
|
||||
[
|
||||
bucket("a1", 25, 1), // outside 7d, inside 30d
|
||||
bucket("a1", 6, 1), // inside 7d
|
||||
bucket("a1", 0, 3, 1), // inside 7d
|
||||
],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
const last7 = summarizeActivityWindow(result, 7);
|
||||
expect(last7.totalRuns).toBe(4);
|
||||
expect(last7.totalFailed).toBe(1);
|
||||
expect(last7.buckets).toHaveLength(7);
|
||||
|
||||
const last30 = summarizeActivityWindow(result, 30);
|
||||
expect(last30.totalRuns).toBe(5);
|
||||
expect(last30.totalFailed).toBe(1);
|
||||
expect(last30.buckets).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("returns an empty summary for missing activity", () => {
|
||||
const summary = summarizeActivityWindow(undefined, 7);
|
||||
expect(summary.buckets).toEqual([]);
|
||||
expect(summary.totalRuns).toBe(0);
|
||||
expect(summary.totalFailed).toBe(0);
|
||||
expect(summary.windowDays).toBe(7);
|
||||
});
|
||||
|
||||
it("clamps an oversized window to the available bucket count", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[bucket("a1", 0, 2)],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
const summary = summarizeActivityWindow(result, 1000);
|
||||
expect(summary.buckets).toHaveLength(30);
|
||||
expect(summary.totalRuns).toBe(2);
|
||||
});
|
||||
|
||||
it("returns no buckets when window is 0", () => {
|
||||
const result = deriveAgentActivity(
|
||||
[bucket("a1", 0, 5)],
|
||||
fullHistoryAgent.created_at,
|
||||
NOW,
|
||||
);
|
||||
const summary = summarizeActivityWindow(result, 0);
|
||||
expect(summary.buckets).toEqual([]);
|
||||
expect(summary.totalRuns).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivityMap", () => {
|
||||
it("groups buckets by agent and yields a derivation per agent", () => {
|
||||
const agents: Agent[] = [
|
||||
fullHistoryAgent,
|
||||
{ ...fullHistoryAgent, id: "a2" },
|
||||
];
|
||||
const buckets: AgentActivityBucket[] = [
|
||||
bucket("a1", 0, 3),
|
||||
bucket("a2", 1, 2, 1),
|
||||
bucket("a1", 2, 4),
|
||||
];
|
||||
const map = buildActivityMap(agents, buckets, NOW);
|
||||
expect(map.size).toBe(2);
|
||||
expect(summarizeActivityWindow(map.get("a1"), 30).totalRuns).toBe(7);
|
||||
expect(summarizeActivityWindow(map.get("a2"), 30).totalRuns).toBe(2);
|
||||
expect(summarizeActivityWindow(map.get("a2"), 30).totalFailed).toBe(1);
|
||||
});
|
||||
|
||||
it("emits a zero-filled entry for an agent with no buckets", () => {
|
||||
const agents: Agent[] = [fullHistoryAgent];
|
||||
const map = buildActivityMap(agents, [], NOW);
|
||||
const a = map.get("a1");
|
||||
expect(a?.buckets).toHaveLength(30);
|
||||
expect(summarizeActivityWindow(a, 30).totalRuns).toBe(0);
|
||||
});
|
||||
});
|
||||
204
packages/core/agents/use-agent-activity.ts
Normal file
204
packages/core/agents/use-agent-activity.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, AgentActivityBucket } from "../types";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { agentActivity30dOptions } from "./queries";
|
||||
|
||||
const DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** One day's tally for the sparkline. */
|
||||
export interface ActivityBucket {
|
||||
total: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
/**
|
||||
* 30 daily buckets, oldest → newest. Days with no activity are
|
||||
* zero-filled. Each surface picks how much of the tail to render: the
|
||||
* Agents list uses 7, the agent detail uses all 30. Reading is the
|
||||
* caller's job (see `summarizeActivityWindow` for the standard
|
||||
* tail-slice + roll-up).
|
||||
*/
|
||||
buckets: ActivityBucket[];
|
||||
/**
|
||||
* Days the agent has existed, capped at DAYS. Pure cosmetic — used by
|
||||
* tooltip copy ("Created 3 days ago"). The sparkline doesn't change
|
||||
* shape for young agents on purpose; pre-life days look the same as
|
||||
* zero days.
|
||||
*/
|
||||
daysSinceCreated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-sized roll-up of an agent's activity series. Both the Agents
|
||||
* list (windowDays=7) and the detail "Last 30 days" panel (windowDays=30)
|
||||
* read through this so the totals can never drift from the bars they
|
||||
* label.
|
||||
*/
|
||||
export interface ActivityWindowSummary {
|
||||
/** Trailing-N buckets from the activity series (newest end). */
|
||||
buckets: ActivityBucket[];
|
||||
/** Sum of `bucket.total` across the window. */
|
||||
totalRuns: number;
|
||||
/** Sum of `bucket.failed` across the window. */
|
||||
totalFailed: number;
|
||||
/** Echo of the input window — the renderer uses it for copy. */
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
const EMPTY: AgentActivity = {
|
||||
buckets: Array.from({ length: DAYS }, () => ({ total: 0, failed: 0 })),
|
||||
daysSinceCreated: DAYS,
|
||||
};
|
||||
|
||||
const EMPTY_SUMMARY: ActivityWindowSummary = {
|
||||
buckets: [],
|
||||
totalRuns: 0,
|
||||
totalFailed: 0,
|
||||
windowDays: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Workspace-wide activity map keyed by `agent.id`. Single-pass batch:
|
||||
* one fetch + one derivation pass backs every row's sparkline on the
|
||||
* list AND the detail panel — adding rows costs O(1) HTTP and O(N)
|
||||
* compute (not O(N) HTTP).
|
||||
*/
|
||||
export function useWorkspaceActivityMap(wsId: string | undefined): {
|
||||
byAgent: Map<string, AgentActivity>;
|
||||
loading: boolean;
|
||||
} {
|
||||
const { data: agents, isPending: agentsPending } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: buckets, isPending: bucketsPending } = useQuery({
|
||||
...agentActivity30dOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
|
||||
const byAgent = useMemo(() => {
|
||||
if (!agents || !buckets) return new Map<string, AgentActivity>();
|
||||
return buildActivityMap(agents, buckets, Date.now());
|
||||
}, [agents, buckets]);
|
||||
|
||||
return { byAgent, loading: agentsPending || bucketsPending };
|
||||
}
|
||||
|
||||
export function buildActivityMap(
|
||||
agents: readonly Agent[],
|
||||
buckets: readonly AgentActivityBucket[],
|
||||
now: number,
|
||||
): Map<string, AgentActivity> {
|
||||
// Group buckets by agent once so per-agent derivation is O(buckets) not
|
||||
// O(agents × buckets).
|
||||
const bucketsByAgent = new Map<string, AgentActivityBucket[]>();
|
||||
for (const b of buckets) {
|
||||
const list = bucketsByAgent.get(b.agent_id);
|
||||
if (list) list.push(b);
|
||||
else bucketsByAgent.set(b.agent_id, [b]);
|
||||
}
|
||||
|
||||
const out = new Map<string, AgentActivity>();
|
||||
for (const agent of agents) {
|
||||
out.set(
|
||||
agent.id,
|
||||
deriveAgentActivity(
|
||||
bucketsByAgent.get(agent.id) ?? [],
|
||||
agent.created_at,
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation: filter the workspace-wide buckets to one agent and
|
||||
* normalise to a fixed 30-element series ending at `now`. Exported for
|
||||
* unit-testing and direct reuse on surfaces that already have the
|
||||
* workspace-wide buckets in hand.
|
||||
*/
|
||||
export function deriveAgentActivity(
|
||||
buckets: readonly AgentActivityBucket[],
|
||||
agentCreatedAt: string,
|
||||
now: number,
|
||||
): AgentActivity {
|
||||
const series: ActivityBucket[] = Array.from({ length: DAYS }, () => ({
|
||||
total: 0,
|
||||
failed: 0,
|
||||
}));
|
||||
|
||||
// Newest slot is the start of "today" in local time; we walk back DAYS
|
||||
// slots so index 0 = oldest, index DAYS-1 = today.
|
||||
const today = startOfDay(now);
|
||||
|
||||
for (const b of buckets) {
|
||||
const ts = new Date(b.bucket_at).getTime();
|
||||
if (Number.isNaN(ts)) continue;
|
||||
const daysAgo = Math.floor((today - startOfDay(ts)) / DAY_MS);
|
||||
if (daysAgo < 0 || daysAgo >= DAYS) continue;
|
||||
const slot = DAYS - 1 - daysAgo;
|
||||
series[slot]!.total += b.task_count;
|
||||
series[slot]!.failed += b.failed_count;
|
||||
}
|
||||
|
||||
const createdAt = new Date(agentCreatedAt).getTime();
|
||||
const ageMs = Number.isFinite(createdAt) ? now - createdAt : Infinity;
|
||||
const daysSinceCreated = Math.min(
|
||||
DAYS,
|
||||
Math.max(0, Math.floor(ageMs / DAY_MS)),
|
||||
);
|
||||
|
||||
return {
|
||||
buckets: series,
|
||||
daysSinceCreated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the trailing N buckets and roll up totals over them. This is the
|
||||
* single entry point both surfaces (list + detail) read through, so the
|
||||
* numbers can never disagree with the bars they label.
|
||||
*
|
||||
* `windowDays` is clamped to the available bucket count, so passing a
|
||||
* value larger than `activity.buckets.length` returns the full series
|
||||
* rather than an out-of-range slice.
|
||||
*/
|
||||
export function summarizeActivityWindow(
|
||||
activity: AgentActivity | undefined,
|
||||
windowDays: number,
|
||||
): ActivityWindowSummary {
|
||||
if (!activity) return { ...EMPTY_SUMMARY, windowDays };
|
||||
const safeWindow = Math.min(
|
||||
Math.max(0, windowDays),
|
||||
activity.buckets.length,
|
||||
);
|
||||
// `slice(-0)` returns the full array (JS quirk: -0 === 0), so guard
|
||||
// explicitly when no window is requested.
|
||||
const slice =
|
||||
safeWindow === 0 ? [] : activity.buckets.slice(-safeWindow);
|
||||
let totalRuns = 0;
|
||||
let totalFailed = 0;
|
||||
for (const b of slice) {
|
||||
totalRuns += b.total;
|
||||
totalFailed += b.failed;
|
||||
}
|
||||
return { buckets: slice, totalRuns, totalFailed, windowDays };
|
||||
}
|
||||
|
||||
function startOfDay(ts: number): number {
|
||||
// Local-time day boundary. The back-end truncates to UTC midnight, but
|
||||
// the user's mental model is "today/yesterday in the timezone they're
|
||||
// looking at"; using local matches that and keeps "today" stable across
|
||||
// a working session even when buckets cross UTC midnight.
|
||||
const d = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
export const __EMPTY_ACTIVITY = EMPTY;
|
||||
162
packages/core/agents/use-agent-presence.ts
Normal file
162
packages/core/agents/use-agent-presence.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
import { agentTaskSnapshotOptions } from "./queries";
|
||||
import {
|
||||
buildPresenceMap,
|
||||
deriveAgentPresenceDetail,
|
||||
} from "./derive-presence";
|
||||
import type { AgentPresenceDetail } from "./types";
|
||||
|
||||
// 30s tick, mirroring useRuntimeHealth. Presence depends on wall-clock time
|
||||
// for one reason: `unstable` (= RuntimeHealth.recently_lost) decays into
|
||||
// `offline` at the 5-minute mark with no new server data. Without a tick the
|
||||
// transition would only render on the next unrelated query update.
|
||||
// The earlier 2-minute "clear failed badge" tick was removed when failed
|
||||
// became sticky; this one re-introduces ticking with a different motivation.
|
||||
const PRESENCE_TICK_MS = 30_000;
|
||||
|
||||
function usePresenceTick(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), PRESENCE_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return tick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-wide presence map keyed by `agent.id`. **The single entry point
|
||||
* for any list / card / runtime sub-view that needs presence for more than
|
||||
* one agent.**
|
||||
*
|
||||
* Why this exists (vs calling `useAgentPresence` per row): the per-agent
|
||||
* hook subscribes to 3 queries. With 30+ rows that's a forest of redundant
|
||||
* memos. This batch hook pays the cost once for the whole page; rows just
|
||||
* `Map.get(id)` — O(1) reads, no extra subscriptions.
|
||||
*
|
||||
* Returned value:
|
||||
* - `byAgent`: ready-to-read Map. Empty if data is still loading.
|
||||
* - `loading`: true until all three input queries have resolved at least
|
||||
* once. Callers can render skeletons during loading.
|
||||
*
|
||||
* Single-agent consumers should keep using `useAgentPresenceDetail`; this
|
||||
* hook is for surfaces that already have a list of agents in hand.
|
||||
*/
|
||||
export function useWorkspacePresenceMap(wsId: string | undefined): {
|
||||
byAgent: Map<string, AgentPresenceDetail>;
|
||||
loading: boolean;
|
||||
} {
|
||||
const { data: agents, isPending: agentsPending, isError: agentsErr } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: runtimes, isPending: runtimesPending, isError: runtimesErr } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: snapshot, isPending: snapshotPending, isError: snapshotErr } = useQuery({
|
||||
...agentTaskSnapshotOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const tick = usePresenceTick();
|
||||
|
||||
const byAgent = useMemo(() => {
|
||||
// Treat errored queries as empty so the map still builds — a 404 on
|
||||
// the snapshot endpoint shouldn't leave every row's presence blank.
|
||||
const safeAgents = agents ?? (agentsErr ? [] : null);
|
||||
const safeRuntimes = runtimes ?? (runtimesErr ? [] : null);
|
||||
const safeSnapshot = snapshot ?? (snapshotErr ? [] : null);
|
||||
if (!safeAgents || !safeRuntimes || !safeSnapshot) {
|
||||
return new Map<string, AgentPresenceDetail>();
|
||||
}
|
||||
return buildPresenceMap({
|
||||
agents: safeAgents,
|
||||
runtimes: safeRuntimes,
|
||||
snapshot: safeSnapshot,
|
||||
now: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agents, runtimes, snapshot, agentsErr, runtimesErr, snapshotErr, tick]);
|
||||
|
||||
return {
|
||||
byAgent,
|
||||
// "loading" only while the queries are genuinely pending — once they
|
||||
// settle (success OR error), we render with whatever we have. This
|
||||
// matches the detail-version behaviour: don't spin forever on errors.
|
||||
loading:
|
||||
(agentsPending && !agentsErr) ||
|
||||
(runtimesPending && !runtimesErr) ||
|
||||
(snapshotPending && !snapshotErr),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-agent presence detail: availability + last task state + counts +
|
||||
* (when failed) failure reason and timestamp. Returns "loading" only while
|
||||
* the underlying queries haven't resolved yet — a missing runtime is a
|
||||
* real state (offline) and resolves into a non-loading detail.
|
||||
*
|
||||
* For surfaces that already have a list of agents in hand (Agents page,
|
||||
* Runtime detail), prefer `useWorkspacePresenceMap` to avoid forest of
|
||||
* redundant subscriptions.
|
||||
*/
|
||||
// Synthesised fallback shown when we can't resolve a real agent (deleted,
|
||||
// archived, or referenced by stale data) but still need to render something
|
||||
// next to the avatar. Yields a gray dot + idle last-task — better than a
|
||||
// skeleton spinning forever.
|
||||
const MISSING_AGENT_DETAIL: AgentPresenceDetail = {
|
||||
availability: "offline",
|
||||
workload: "idle",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
capacity: 0,
|
||||
};
|
||||
|
||||
export function useAgentPresenceDetail(
|
||||
wsId: string | undefined,
|
||||
agentId: string | undefined,
|
||||
): AgentPresenceDetail | "loading" {
|
||||
const { data: agents, isError: agentsErr } = useQuery({
|
||||
...agentListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: runtimes, isError: runtimesErr } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: snapshot, isError: snapshotErr } = useQuery({
|
||||
...agentTaskSnapshotOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const tick = usePresenceTick();
|
||||
|
||||
return useMemo<AgentPresenceDetail | "loading">(() => {
|
||||
if (!wsId || !agentId) return "loading";
|
||||
|
||||
// Treat query errors as "no data" rather than "still loading". A 404 /
|
||||
// 5xx on the snapshot endpoint (e.g. backend hasn't deployed the new
|
||||
// route yet) used to leave the UI spinning forever; now we degrade to
|
||||
// an empty list and the dot still renders based on runtime health.
|
||||
const safeAgents = agents ?? (agentsErr ? [] : null);
|
||||
const safeRuntimes = runtimes ?? (runtimesErr ? [] : null);
|
||||
const safeSnapshot = snapshot ?? (snapshotErr ? [] : null);
|
||||
if (!safeAgents || !safeRuntimes || !safeSnapshot) return "loading";
|
||||
|
||||
const agent = safeAgents.find((a) => a.id === agentId);
|
||||
// Agent referenced but not in the workspace's active list (most often:
|
||||
// archived assignee on an old issue). Render a gray-offline fallback
|
||||
// instead of looping in "loading".
|
||||
if (!agent) return MISSING_AGENT_DETAIL;
|
||||
// Missing runtime is a legitimate state (offline) — pass null and let
|
||||
// derive handle it.
|
||||
const runtime = safeRuntimes.find((r) => r.id === agent.runtime_id) ?? null;
|
||||
|
||||
const tasks = safeSnapshot.filter((t) => t.agent_id === agentId);
|
||||
return deriveAgentPresenceDetail({ agent, runtime, tasks, now: Date.now() });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, agentId, agents, runtimes, snapshot, agentsErr, runtimesErr, snapshotErr, tick]);
|
||||
}
|
||||
26
packages/core/agents/use-workspace-presence-prefetch.ts
Normal file
26
packages/core/agents/use-workspace-presence-prefetch.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
import { agentTaskSnapshotOptions } from "./queries";
|
||||
|
||||
// Subscribe to the three queries that power agent presence so they're warm
|
||||
// by the time any hover card / inline indicator first renders. Without this
|
||||
// warm-up, surfaces that don't otherwise touch the snapshot (inbox, issues,
|
||||
// chat) flash a skeleton on first hover while the fetch is in flight.
|
||||
//
|
||||
// useRealtimeSync (WS task / agent / daemon invalidations) and the 30s
|
||||
// presence tick keep these caches fresh after the initial fetch — this hook
|
||||
// only collapses the cold-start window.
|
||||
//
|
||||
// All three are workspace-scoped; the queryKeys include wsId so workspace
|
||||
// switch automatically refetches the new workspace's data with no extra
|
||||
// wiring here. The workspace-scoped layouts on both apps gate rendering on
|
||||
// "workspace resolved", so callers can safely pass useWorkspaceId() — by the
|
||||
// time this hook mounts, wsId is guaranteed non-empty.
|
||||
export function useWorkspacePresencePrefetch(wsId: string | undefined): void {
|
||||
useQuery({ ...agentListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...runtimeListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...agentTaskSnapshotOptions(wsId ?? ""), enabled: !!wsId });
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
AgentRuntime,
|
||||
InboxItem,
|
||||
IssueSubscriber,
|
||||
@@ -33,6 +35,8 @@ import type {
|
||||
RuntimeUsage,
|
||||
IssueUsageSummary,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimeUsageByAgent,
|
||||
RuntimeUsageByHour,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
@@ -151,12 +155,17 @@ export interface ImportStarterContentResponse {
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
// Raw decoded JSON body (when the server returned one). Carries structured
|
||||
// error fields like `code` so callers can branch on machine-readable
|
||||
// identifiers instead of pattern-matching the human-readable message.
|
||||
readonly body?: unknown;
|
||||
|
||||
constructor(message: string, status: number, statusText: string) {
|
||||
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +230,19 @@ export class ApiClient {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Reads the response body once for both human-readable error message and
|
||||
// structured fields. The Response stream can only be consumed once, so
|
||||
// both pieces have to come from a single read.
|
||||
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
||||
return { message, body: data };
|
||||
} catch {
|
||||
return { message: fallback, body: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
@@ -243,10 +265,10 @@ export class ApiClient {
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new ApiError(message, res.status, res.statusText);
|
||||
throw new ApiError(message, res.status, res.statusText, body);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
@@ -399,6 +421,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async createFeedback(data: {
|
||||
message: string;
|
||||
url?: string;
|
||||
@@ -566,6 +595,14 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
|
||||
// Permission: agent owner or workspace admin/owner. Server returns the
|
||||
// count of cancelled rows; broadcasts task:cancelled for each so other
|
||||
// surfaces can clear their live cards.
|
||||
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
|
||||
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
@@ -587,6 +624,24 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByAgent(
|
||||
runtimeId: string,
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByHour(
|
||||
runtimeId: string,
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByHour[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
@@ -651,6 +706,28 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
// Workspace-scoped agent task snapshot: every active task
|
||||
// (queued/dispatched/running) plus each agent's most recent terminal task.
|
||||
// Powers the front-end's "active wins, else latest terminal" presence
|
||||
// derivation; one fetch backs every per-agent presence read in the app.
|
||||
// Workspace is resolved server-side from the X-Workspace-Slug header.
|
||||
async getAgentTaskSnapshot(): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agent-task-snapshot`);
|
||||
}
|
||||
|
||||
// Per-agent daily activity for the last 30 days, anchored on
|
||||
// completed_at. One workspace-wide fetch backs both the Agents-list
|
||||
// sparkline (uses trailing 7 buckets) and the agent detail "Last 30
|
||||
// days" panel (uses all 30).
|
||||
async getWorkspaceAgentActivity30d(): Promise<AgentActivityBucket[]> {
|
||||
return this.fetch(`/api/agent-activity-30d`);
|
||||
}
|
||||
|
||||
// Per-agent 30-day total run count for the Agents-list RUNS column.
|
||||
async getWorkspaceAgentRunCounts(): Promise<AgentRunCount[]> {
|
||||
return this.fetch(`/api/agent-run-counts`);
|
||||
}
|
||||
|
||||
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
|
||||
return this.fetch(`/api/issues/${issueId}/active-task`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, DRAFT_NEW_SESSION } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -11,8 +11,19 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
/** Focus mode is a personal preference — global across workspaces/sessions. */
|
||||
const FOCUS_MODE_KEY = "multica:chat:focusMode";
|
||||
/**
|
||||
* Open/closed preference, persisted globally (not per-workspace) — most users
|
||||
* have one habitual chat-panel preference across workspaces. Missing key =
|
||||
* new user (or cleared storage); default to OPEN so the chat is discoverable.
|
||||
* Once the user toggles even once, their explicit choice is respected on
|
||||
* every subsequent reload.
|
||||
*/
|
||||
const OPEN_KEY = "multica:chat:isOpen";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
@@ -38,6 +49,11 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
* Kept as a public type because existing consumers (chat-message-list,
|
||||
* views/chat types) import it. Items themselves no longer live in the
|
||||
@@ -68,8 +84,10 @@ export interface ContextAnchor {
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/**
|
||||
@@ -78,20 +96,22 @@ export interface ChatState {
|
||||
* the preference survives workspace switches and reloads.
|
||||
*/
|
||||
focusMode: boolean;
|
||||
/**
|
||||
* Last location where a context anchor could be derived (issue/project/inbox).
|
||||
* Updated globally by useAnchorTracker; used as a fallback for the Chat page
|
||||
* which is its own route and therefore has no anchor of its own.
|
||||
* Not persisted — resets per session; focus mode itself persists.
|
||||
*/
|
||||
lastAnchorLocation: { pathname: string; search: string } | null;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
isExpanded: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
setFocusMode: (on: boolean) => void;
|
||||
setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ChatStoreOptions {
|
||||
@@ -106,13 +126,33 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return slug ? `${base}:${slug}` : base;
|
||||
};
|
||||
|
||||
// Resolve initial isOpen from storage. The three-state read (null /
|
||||
// "true" / "false") is what enables the "new user → open" default while
|
||||
// still honouring an explicit "I closed it" choice on every reload.
|
||||
const storedOpen = storage.getItem(OPEN_KEY);
|
||||
const initialIsOpen = storedOpen === null ? true : storedOpen === "true";
|
||||
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
isOpen: initialIsOpen,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
|
||||
lastAnchorLocation: null,
|
||||
setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }),
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
storage.setItem(OPEN_KEY, String(open));
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
storage.setItem(OPEN_KEY, String(next));
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
||||
if (id) {
|
||||
@@ -127,6 +167,10 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
@@ -152,6 +196,23 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
storage.setItem(CHAT_HEIGHT_KEY, String(h));
|
||||
// Dragging = user chose a manual size → exit expanded mode
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
set({ chatWidth: w, chatHeight: h, isExpanded: false });
|
||||
},
|
||||
setExpanded: (expanded) => {
|
||||
logger.info("setExpanded", { to: expanded });
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
}
|
||||
set({ isExpanded: expanded });
|
||||
},
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
@@ -165,15 +226,10 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
});
|
||||
// lastAnchorLocation is not persisted — reset it here so a pathname
|
||||
// captured in the previous workspace can't be reused against the new
|
||||
// workspace's wsId (would trigger a cross-workspace issue/project fetch
|
||||
// and silently leak context into chat messages).
|
||||
store.setState({
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
lastAnchorLocation: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ export const issueKeys = {
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
/** Per-issue task list (issue-detail Execution log section). */
|
||||
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
|
||||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||||
* the global WS task: prefix path so any task lifecycle event refreshes
|
||||
* every per-issue list, regardless of which issue is currently mounted. */
|
||||
tasksAll: () => ["issues", "tasks"] as const,
|
||||
};
|
||||
|
||||
export type MyIssuesFilter = Pick<
|
||||
|
||||
36
packages/core/issues/stores/create-mode-store.ts
Normal file
36
packages/core/issues/stores/create-mode-store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Last create-issue mode the user landed on. Drives the global `c` shortcut
|
||||
* and the in-modal mode switch — pressing `c` opens whichever modal the user
|
||||
* used last, and the switch button in either modal updates this so the
|
||||
* preference sticks.
|
||||
*
|
||||
* Workspace-agnostic on purpose: the user's mental preference for "how do I
|
||||
* file an issue" doesn't change per workspace, so this lives in plain
|
||||
* localStorage rather than the workspace-aware StateStorage that scopes
|
||||
* per-workspace stores like quick-create-store / draft-store.
|
||||
*/
|
||||
export type CreateMode = "agent" | "manual";
|
||||
|
||||
interface CreateModeState {
|
||||
lastMode: CreateMode;
|
||||
setLastMode: (mode: CreateMode) => void;
|
||||
}
|
||||
|
||||
export const useCreateModeStore = create<CreateModeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastMode: "agent",
|
||||
setLastMode: (mode) => set({ lastMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "multica_create_mode",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
|
||||
37
packages/core/issues/stores/quick-create-store.ts
Normal file
37
packages/core/issues/stores/quick-create-store.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Per-workspace memory of the last agent the user picked in the Quick Create
|
||||
// modal. Defaulted to that agent on next open so frequent users skip the
|
||||
// picker entirely. Persisted with the workspace-aware StateStorage so
|
||||
// switching workspaces shows the right default automatically. Per-user
|
||||
// scoping comes for free from localStorage being browser-profile-local —
|
||||
// matches how draft-store / issues-scope-store / comment-collapse-store
|
||||
// already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useQuickCreateStore.persist.rehydrate());
|
||||
@@ -5,6 +5,7 @@ import { create } from "zustand";
|
||||
type ModalType =
|
||||
| "create-workspace"
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
"./runtimes/queries": "./runtimes/queries.ts",
|
||||
"./runtimes/mutations": "./runtimes/mutations.ts",
|
||||
"./runtimes/hooks": "./runtimes/hooks.ts",
|
||||
"./agents": "./agents/index.ts",
|
||||
"./agents/queries": "./agents/queries.ts",
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("paths.workspace() shape", () => {
|
||||
"autopilots",
|
||||
"agents",
|
||||
"inbox",
|
||||
"chat",
|
||||
"myIssues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
@@ -41,7 +40,6 @@ describe("paths.workspace() shape", () => {
|
||||
["autopilots", "autopilots"],
|
||||
["agents", "agents"],
|
||||
["inbox", "inbox"],
|
||||
["chat", "chat"],
|
||||
["myIssues", "my-issues"],
|
||||
["runtimes", "runtimes"],
|
||||
["skills", "skills"],
|
||||
|
||||
@@ -25,10 +25,11 @@ function workspaceScoped(slug: string) {
|
||||
autopilots: () => `${ws}/autopilots`,
|
||||
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
|
||||
agents: () => `${ws}/agents`,
|
||||
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
|
||||
inbox: () => `${ws}/inbox`,
|
||||
chat: () => `${ws}/chat`,
|
||||
myIssues: () => `${ws}/my-issues`,
|
||||
runtimes: () => `${ws}/runtimes`,
|
||||
runtimeDetail: (id: string) => `${ws}/runtimes/${encode(id)}`,
|
||||
skills: () => `${ws}/skills`,
|
||||
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
|
||||
settings: () => `${ws}/settings`,
|
||||
|
||||
@@ -19,24 +19,24 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("not onboarded → /onboarding regardless of workspaces", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.onboarding(),
|
||||
);
|
||||
expect(
|
||||
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
|
||||
).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded + has workspace → /<first.slug>/issues", () => {
|
||||
it("has workspace → /<first.slug>/issues regardless of onboarded state", () => {
|
||||
const ws = [makeWs("acme"), makeWs("beta")];
|
||||
expect(resolvePostAuthDestination(ws, true)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination(ws, false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
});
|
||||
|
||||
it("onboarded + zero workspaces → /workspaces/new", () => {
|
||||
it("zero workspaces + !onboarded → /onboarding", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("zero workspaces + onboarded → /workspaces/new", () => {
|
||||
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,23 @@ import { paths } from "./paths";
|
||||
|
||||
/**
|
||||
* Priority:
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
* has workspace → /<first.slug>/issues
|
||||
* zero workspaces && !hasOnboarded → /onboarding
|
||||
* zero workspaces && hasOnboarded → /workspaces/new
|
||||
*
|
||||
* Workspace presence wins over onboarding state: a user invited into an
|
||||
* existing workspace must NOT be bounced into the new-workspace wizard
|
||||
* just because their personal `onboarded_at` is still null.
|
||||
*/
|
||||
export function resolvePostAuthDestination(
|
||||
workspaces: Workspace[],
|
||||
hasOnboarded: boolean,
|
||||
): string {
|
||||
if (!hasOnboarded) {
|
||||
return paths.onboarding();
|
||||
}
|
||||
const first = workspaces[0];
|
||||
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
|
||||
if (first) {
|
||||
return paths.workspace(first.slug).issues();
|
||||
}
|
||||
return hasOnboarded ? paths.newWorkspace() : paths.onboarding();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,12 @@ import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
import { autopilotKeys } from "../autopilots/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
agentTaskSnapshotKeys,
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -45,9 +51,13 @@ import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskQueuedPayload,
|
||||
TaskDispatchPayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
ChatDonePayload,
|
||||
ChatPendingTask,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
|
||||
@@ -143,6 +153,34 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
},
|
||||
// Powers the agent presence cache: any task lifecycle change
|
||||
// (dispatch / completed / failed / cancelled) refreshes the
|
||||
// workspace-wide agent-task-snapshot query so per-agent presence
|
||||
// reflects the change. task:message is NOT in this prefix path — it
|
||||
// stays in specificEvents to avoid an invalidate storm during long runs.
|
||||
task: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
||||
// 30d activity series shares the same lifecycle signal — any task
|
||||
// completion / failure shifts the histogram. (Dispatch alone
|
||||
// doesn't change a completed_at-anchored series, but invalidating
|
||||
// here keeps the WS-handler shape uniform; the resulting refetch
|
||||
// is cheap.) Both the list (trailing 7d slice) and the detail
|
||||
// panel read off this single cache.
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
||||
// 30-day run count likewise increments per task lifecycle event.
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
||||
// Per-agent task list (Activity tab "Recent work"). Prefix match
|
||||
// catches every agent's list — the per-agent detail key sits
|
||||
// under agentTasks/<wsId>/<agentId>.
|
||||
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
||||
// Per-issue task list (issue-detail Execution log). Prefix match
|
||||
// across all issues — keeps the contract "any task: event makes
|
||||
// every list-of-tasks query stale" so cache stays fresh even
|
||||
// when the relevant component isn't currently mounted.
|
||||
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
|
||||
},
|
||||
};
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -167,9 +205,18 @@ export function useRealtimeSync(
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat / task events are handled explicitly below; do not double-invalidate.
|
||||
// Chat events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"task:message", "task:completed", "task:failed",
|
||||
// task:message stays out of the prefix path because it fires per
|
||||
// streamed message during a long run — invalidating the snapshot on
|
||||
// every message would flood the network. Specific chat handlers below
|
||||
// still receive it via ws.on() (a separate subscription channel).
|
||||
"task:message",
|
||||
// task:completed / task:failed deliberately NOT here. They go through
|
||||
// both the task-prefix invalidate (refreshes the agent-task-snapshot
|
||||
// cache) AND the chat-specific ws.on() handlers below. The two
|
||||
// channels are independent — onAny dispatch and ws.on are separate
|
||||
// subscriptions.
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -417,7 +464,7 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
});
|
||||
|
||||
// --- Chat / task events (global, survives chat page unmount) ---
|
||||
// --- Chat / task events (global, survives ChatWindow unmount) ---
|
||||
//
|
||||
// Single source of truth: the Query cache. No Zustand writes here — the
|
||||
// earlier mirror caused a race where the cache and store disagreed
|
||||
@@ -482,6 +529,64 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// Chat task lifecycle writethrough: keep `chatKeys.pendingTask(sessionId)`
|
||||
// synchronized with the server state machine via setQueryData rather than
|
||||
// invalidate-refetch. Same pattern as task:message — the WS payload
|
||||
// carries everything we need, and an HTTP roundtrip just to read what we
|
||||
// already know would add latency to every stage transition.
|
||||
//
|
||||
// task:queued is emitted by EnqueueChatTask. The optimistic seed in
|
||||
// chat-window.tsx may have already populated the cache with a temporary
|
||||
// id; this handler upgrades it to the real task_id (and reaffirms status
|
||||
// when reconnect replays the event for an already-running task).
|
||||
const unsubTaskQueued = ws.on("task:queued", (p) => {
|
||||
const payload = p as TaskQueuedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => ({
|
||||
...(old ?? {}),
|
||||
task_id: payload.task_id,
|
||||
status: "queued",
|
||||
}),
|
||||
);
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
// task:dispatch fires when the daemon claims the queued task. The daemon
|
||||
// immediately follows with StartTask, so dispatched→running is sub-second.
|
||||
// We collapse that window by writing "running" directly — the pill jumps
|
||||
// from "Queued" straight to "Thinking", skipping a meaningless "Starting"
|
||||
// frame. Stage decision in TaskStatusPill maps "running" + empty
|
||||
// taskMessages → "Thinking · Ns".
|
||||
const unsubTaskDispatch = ws.on("task:dispatch", (p) => {
|
||||
const payload = p as TaskDispatchPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => {
|
||||
if (!old || old.task_id !== payload.task_id) return old;
|
||||
return { ...old, status: "running" };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// task:cancelled reaches us when:
|
||||
// 1. handleStop already cleared the cache locally (this is a no-op confirm)
|
||||
// 2. another tab / admin / system cancels — this is the only path that
|
||||
// drops the pending pill in those cases. Without it the pill spins
|
||||
// forever in the second-tab scenario.
|
||||
const unsubTaskCancelled = ws.on("task:cancelled", (p) => {
|
||||
const payload = p as TaskCancelledPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.info("task:cancelled (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
@@ -502,8 +607,14 @@ export function useRealtimeSync(
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
// FailTask writes a failure chat_message (mirroring CompleteTask's
|
||||
// success message), so this path mirrors the task:completed handler:
|
||||
// clear the pending signal AND invalidate the messages list so the
|
||||
// failure bubble shows up without requiring a page refresh. Pre-#1823
|
||||
// this branch only flipped pending — the comment "No new message"
|
||||
// was true then, but FailTask now persists a row.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
@@ -541,6 +652,9 @@ export function useRealtimeSync(
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskQueued();
|
||||
unsubTaskDispatch();
|
||||
unsubTaskCancelled();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
@@ -566,6 +680,9 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
} catch (e) {
|
||||
|
||||
61
packages/core/runtimes/cli-version.ts
Normal file
61
packages/core/runtimes/cli-version.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Frontend mirror of the server's MinQuickCreateCLIVersion gate. The
|
||||
* agent-create flow (Quick Create modal) requires the daemon's bundled
|
||||
* multica CLI to be at least this version — older daemons either
|
||||
* double-create issues on partial CLI failures or mishandle pasted
|
||||
* screenshot URLs (see PR #1851 / MUL-1496).
|
||||
*
|
||||
* Both the frontend pre-validation in the modal and the server's
|
||||
* `/api/issues/quick-create` handler enforce this; the server is the
|
||||
* authoritative trust boundary, the frontend just lets us tell the user
|
||||
* "your daemon needs an upgrade" before they hit submit.
|
||||
*/
|
||||
export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.20";
|
||||
|
||||
export type CliVersionState = "ok" | "too_old" | "missing";
|
||||
|
||||
export interface CliVersionCheck {
|
||||
state: CliVersionState;
|
||||
/** What the daemon reported, or empty if missing/unparsable. */
|
||||
current: string;
|
||||
/** The hard minimum we gate on. */
|
||||
min: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
function parseSemver(raw: string): [number, number, number] | null {
|
||||
const m = SEMVER_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
if (a[0] !== b[0]) return a[0] < b[0];
|
||||
if (a[1] !== b[1]) return a[1] < b[1];
|
||||
return a[2] < b[2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a daemon-reported CLI version string against the minimum. Returns
|
||||
* `"missing"` for empty/unparsable input (fail closed — same policy as the
|
||||
* server) and `"too_old"` for a parsable version below the threshold.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const min = parseSemver(MIN_QUICK_CREATE_CLI_VERSION)!;
|
||||
if (lessThan(parsed, min)) {
|
||||
return { state: "too_old", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
|
||||
/** Pull `cli_version` off a runtime row's loosely-typed metadata bag. */
|
||||
export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefined): string {
|
||||
const v = metadata?.cli_version;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
103
packages/core/runtimes/derive-health.test.ts
Normal file
103
packages/core/runtimes/derive-health.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRuntime } from "../types";
|
||||
import { deriveRuntimeHealth } from "./derive-health";
|
||||
|
||||
const FIXED_NOW = new Date("2026-04-27T12:00:00Z").getTime();
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveRuntimeHealth", () => {
|
||||
it("returns online when status is online (regardless of last_seen_at)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(makeRuntime({ status: "online", last_seen_at: null }), FIXED_NOW),
|
||||
).toBe("online");
|
||||
});
|
||||
|
||||
it("returns recently_lost when offline less than 5 minutes", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - 2 * 60_000).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("recently_lost");
|
||||
});
|
||||
|
||||
it("returns offline when offline between 5 minutes and 6 days", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - 60 * 60_000).toISOString(), // 1 hour
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("returns about_to_gc when offline beyond 6 days (within 1 day of GC)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - 6.5 * 24 * 3600_000).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("about_to_gc");
|
||||
});
|
||||
|
||||
it("treats null last_seen_at as long-offline (about_to_gc)", () => {
|
||||
// last_seen_at = null means lastSeen = 0 (epoch), so offlineFor is huge.
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({ status: "offline", last_seen_at: null }),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("about_to_gc");
|
||||
});
|
||||
|
||||
it("respects the 5-minute boundary (just inside → recently_lost)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - (5 * 60_000 - 1_000)).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("recently_lost");
|
||||
});
|
||||
|
||||
it("respects the 5-minute boundary (just outside → offline)", () => {
|
||||
expect(
|
||||
deriveRuntimeHealth(
|
||||
makeRuntime({
|
||||
status: "offline",
|
||||
last_seen_at: new Date(FIXED_NOW - (5 * 60_000 + 1_000)).toISOString(),
|
||||
}),
|
||||
FIXED_NOW,
|
||||
),
|
||||
).toBe("offline");
|
||||
});
|
||||
});
|
||||
27
packages/core/runtimes/derive-health.ts
Normal file
27
packages/core/runtimes/derive-health.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Pure derivation of a runtime's user-facing "health" state from the raw
|
||||
// server fields (status + last_seen_at). Splitting the offline state into
|
||||
// time-bucketed flavors lets the UI distinguish "just lost — likely
|
||||
// transient" from "long gone — needs attention" with no schema change.
|
||||
|
||||
import type { AgentRuntime } from "../types";
|
||||
import type { RuntimeHealth } from "./types";
|
||||
|
||||
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
||||
// The runtime sweeper GCs runtimes that have been offline for 7 days. We
|
||||
// flag the last 24 hours of that window so users can rescue a runtime
|
||||
// before it disappears silently.
|
||||
const ABOUT_TO_GC_THRESHOLD_MS = 6 * 24 * 3600 * 1000; // 6 days
|
||||
|
||||
export function deriveRuntimeHealth(runtime: AgentRuntime, now: number): RuntimeHealth {
|
||||
if (runtime.status === "online") return "online";
|
||||
|
||||
// No last_seen timestamp ever recorded — treat as long-offline. This is
|
||||
// an unusual case (the back-end always sets last_seen_at on register),
|
||||
// but defending against it keeps the UI from crashing on legacy rows.
|
||||
const lastSeen = runtime.last_seen_at ? new Date(runtime.last_seen_at).getTime() : 0;
|
||||
const offlineFor = now - lastSeen;
|
||||
|
||||
if (offlineFor < FIVE_MINUTES_MS) return "recently_lost";
|
||||
if (offlineFor > ABOUT_TO_GC_THRESHOLD_MS) return "about_to_gc";
|
||||
return "offline";
|
||||
}
|
||||
@@ -3,3 +3,7 @@ export * from "./mutations";
|
||||
export * from "./hooks";
|
||||
export * from "./models";
|
||||
export * from "./local-skills";
|
||||
export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
|
||||
@@ -5,9 +5,47 @@ export const runtimeKeys = {
|
||||
all: (wsId: string) => ["runtimes", wsId] as const,
|
||||
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
|
||||
listMine: (wsId: string) => [...runtimeKeys.all(wsId), "list", "mine"] as const,
|
||||
usage: (rid: string, days: number) =>
|
||||
["runtimes", "usage", rid, days] as const,
|
||||
usageByAgent: (rid: string, days: number) =>
|
||||
["runtimes", "usage", "by-agent", rid, days] as const,
|
||||
usageByHour: (rid: string, days: number) =>
|
||||
["runtimes", "usage", "by-hour", rid, days] as const,
|
||||
latestVersion: () => ["runtimes", "latestVersion"] as const,
|
||||
};
|
||||
|
||||
// Per-runtime usage. Used by the list view (each row pulls its own activity
|
||||
// sparkline + 30d cost) and by the detail page. TanStack Query naturally
|
||||
// deduplicates concurrent calls for the same runtime, so multiple components
|
||||
// observing the same runtimeId share one network request.
|
||||
export function runtimeUsageOptions(runtimeId: string, days: number) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usage(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsage(runtimeId, { days }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-agent token totals for one runtime — drives the "Cost by agent" tab
|
||||
// on the runtime detail page. Server-side aggregation keeps the response
|
||||
// small (one row per agent) regardless of task volume.
|
||||
export function runtimeUsageByAgentOptions(runtimeId: string, days: number) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usageByAgent(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Hourly (0..23) token totals for one runtime — drives the "By hour" tab.
|
||||
export function runtimeUsageByHourOptions(runtimeId: string, days: number) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeKeys.usageByHour(runtimeId, days),
|
||||
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimeListOptions(wsId: string, owner?: "me") {
|
||||
return queryOptions({
|
||||
queryKey: owner === "me" ? runtimeKeys.listMine(wsId) : runtimeKeys.list(wsId),
|
||||
|
||||
10
packages/core/runtimes/types.ts
Normal file
10
packages/core/runtimes/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Derived "health" type for runtimes — the user-facing state we display
|
||||
// in lists, cards, and tooltips. The raw server field is binary (online /
|
||||
// offline + last_seen_at); this enum splits the offline state into three
|
||||
// time-bucketed flavors so users can tell "just lost" from "long gone".
|
||||
|
||||
export type RuntimeHealth =
|
||||
| "online" // green — within heartbeat threshold
|
||||
| "recently_lost" // amber — offline < 5 minutes (likely transient)
|
||||
| "offline" // grey — offline 5 minutes ~ 7 days
|
||||
| "about_to_gc"; // dim — within 1 day of the 7-day GC threshold
|
||||
46
packages/core/runtimes/use-runtime-health.ts
Normal file
46
packages/core/runtimes/use-runtime-health.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { runtimeListOptions } from "./queries";
|
||||
import { deriveRuntimeHealth } from "./derive-health";
|
||||
import type { RuntimeHealth } from "./types";
|
||||
|
||||
// Re-render every 30s so transitions like recently_lost → offline (which
|
||||
// happens at the 5-minute mark with no new data) reflect in the UI.
|
||||
const HEALTH_TICK_MS = 30_000;
|
||||
|
||||
function useHealthTick(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), HEALTH_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return tick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived runtime health (online / recently_lost / offline / about_to_gc),
|
||||
* or "loading" while the runtime list is still resolving.
|
||||
*
|
||||
* Accepts wsId as a parameter so the hook works outside WorkspaceIdProvider.
|
||||
*/
|
||||
export function useRuntimeHealth(
|
||||
wsId: string | undefined,
|
||||
runtimeId: string | undefined,
|
||||
): RuntimeHealth | "loading" {
|
||||
const { data: runtimes } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const tick = useHealthTick();
|
||||
|
||||
return useMemo<RuntimeHealth | "loading">(() => {
|
||||
if (!wsId || !runtimeId) return "loading";
|
||||
if (!runtimes) return "loading";
|
||||
const runtime = runtimes.find((r) => r.id === runtimeId);
|
||||
if (!runtime) return "loading";
|
||||
return deriveRuntimeHealth(runtime, Date.now());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, runtimeId, runtimes, tick]);
|
||||
}
|
||||
@@ -23,6 +23,34 @@ export interface RuntimeDevice {
|
||||
|
||||
export type AgentRuntime = RuntimeDevice;
|
||||
|
||||
// Coarse classifier set by the backend when a task transitions to "failed".
|
||||
// Mirrors the migration-055 enum in agent_task_queue.failure_reason. Used by
|
||||
// the agent presence derivation and the UI failure-message lookup.
|
||||
export type TaskFailureReason =
|
||||
| "agent_error"
|
||||
| "timeout"
|
||||
| "runtime_offline"
|
||||
| "runtime_recovery"
|
||||
| "manual";
|
||||
|
||||
// One daily bucket for the Agents-list ACTIVITY sparkline. The back-end
|
||||
// only returns days that had at least one completion; the front-end fills
|
||||
// in missing days with zero when rendering the 7-bucket series. The series
|
||||
// is anchored on completed_at (a task in flight contributes nothing).
|
||||
export interface AgentActivityBucket {
|
||||
agent_id: string;
|
||||
// ISO timestamp at midnight UTC of the day.
|
||||
bucket_at: string;
|
||||
task_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
// 30-day total run count per agent, drives the Agents-list RUNS column.
|
||||
export interface AgentRunCount {
|
||||
agent_id: string;
|
||||
run_count: number;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
@@ -38,11 +66,35 @@ export interface AgentTask {
|
||||
completed_at: string | null;
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
// Empty string when the task is not in a failed state (the backend uses
|
||||
// `omitempty`, so the field may also be missing on non-failed tasks).
|
||||
failure_reason?: TaskFailureReason | "";
|
||||
created_at: string;
|
||||
/** Non-empty when the task was spawned from a chat session. */
|
||||
chat_session_id?: string;
|
||||
/** Non-empty when the task was spawned by an autopilot run. */
|
||||
autopilot_run_id?: string;
|
||||
/** Set when this task was created as an auto-retry of a parent task. */
|
||||
parent_task_id?: string;
|
||||
/** 1-based attempt counter; >1 means this is a retry. */
|
||||
attempt?: number;
|
||||
/** Set when an issue comment triggered this task (@mention or assignee comment). */
|
||||
trigger_comment_id?: string;
|
||||
/**
|
||||
* Canonical short description of what triggered this task — snapshot
|
||||
* taken at creation time. For comment-triggered tasks it's the
|
||||
* comment text (truncated to ~200 chars); for autopilot it's the
|
||||
* autopilot title; NULL for direct assignments and chat tasks.
|
||||
* Persists even if the source comment / autopilot is later edited
|
||||
* or deleted.
|
||||
*/
|
||||
trigger_summary?: string;
|
||||
/**
|
||||
* Server-computed source discriminator used by the activity row to label
|
||||
* tasks that have no linked issue (so e.g. quick-create tasks render
|
||||
* with a meaningful title instead of falling through to "Untracked").
|
||||
*/
|
||||
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
@@ -170,6 +222,33 @@ export interface RuntimeHourlyActivity {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// One (agent, model) row of the "Cost by agent" tab on the runtime detail
|
||||
// page. Model stays on the wire because cost is computed client-side from
|
||||
// a per-model pricing table — the client groups these rows by agent_id and
|
||||
// sums cost per agent across models.
|
||||
export interface RuntimeUsageByAgent {
|
||||
agent_id: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
// One (hour, model) row for the "By hour" tab; hour ∈ 0..23. Hours with
|
||||
// zero activity are omitted by the server; the client fills the gap to
|
||||
// render a continuous axis. Model preserved for client-side cost math.
|
||||
export interface RuntimeUsageByHour {
|
||||
hour: number;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
export type RuntimeUpdateStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
|
||||
@@ -28,18 +28,48 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
task_id: string | null;
|
||||
created_at: string;
|
||||
/**
|
||||
* When set, this is an assistant message synthesized by the server's
|
||||
* FailTask fallback (mirrors the issue path's failure system comment).
|
||||
* `content` carries the raw daemon-reported errMsg; the front-end maps
|
||||
* `failure_reason` (an enum like "agent_error" / "connection_error" /
|
||||
* "timeout") to a user-facing label and renders a destructive bubble.
|
||||
* Null on success messages and on user messages.
|
||||
*/
|
||||
failure_reason?: string | null;
|
||||
/**
|
||||
* Wall-clock duration from `task.created_at` (user hit send) to terminal
|
||||
* state (completed/failed). Set by the server on assistant messages
|
||||
* synthesized by CompleteTask/FailTask. UI renders it as "Replied in
|
||||
* 38s" / "Failed after 12s" beneath the bubble. Null on user messages
|
||||
* and on legacy assistant messages predating migration 063.
|
||||
*/
|
||||
elapsed_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
/**
|
||||
* Server-authoritative task creation time. Optimistic StatusPill seed
|
||||
* uses this as its anchor so the timer starts from the real `0s` —
|
||||
* without it the front-end falls back to its local clock and the
|
||||
* timer "snaps backwards" later when WS events update the cache.
|
||||
*/
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
* All fields are absent when the session has no in-flight task.
|
||||
*
|
||||
* `created_at` is the server-authoritative anchor for the chat StatusPill's
|
||||
* elapsed-seconds timer — the optimistic seed in chat-window.tsx fills in
|
||||
* task_id/status only, then this query catches up with the real created_at
|
||||
* so the timer survives refresh / reopen without "resetting to 0s".
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type WSEventType =
|
||||
| "agent:created"
|
||||
| "agent:archived"
|
||||
| "agent:restored"
|
||||
| "task:queued"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
| "task:completed"
|
||||
@@ -195,6 +196,22 @@ export interface TaskMessagePayload {
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface TaskQueuedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskDispatchPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
runtime_id: string;
|
||||
chat_session_id?: string;
|
||||
}
|
||||
|
||||
export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
|
||||
@@ -16,7 +16,9 @@ export type InboxItemType =
|
||||
| "task_failed"
|
||||
| "agent_blocked"
|
||||
| "agent_completed"
|
||||
| "reaction_added";
|
||||
| "reaction_added"
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
|
||||
@@ -5,6 +5,9 @@ export type {
|
||||
AgentRuntimeMode,
|
||||
AgentVisibility,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
TaskFailureReason,
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
@@ -16,6 +19,8 @@ export type {
|
||||
SetAgentSkillsRequest,
|
||||
RuntimeUsage,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimeUsageByAgent,
|
||||
RuntimeUsageByHour,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
183
packages/ui/components/ui/data-table.tsx
Normal file
183
packages/ui/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type * 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 (no spacer
|
||||
// column needed).
|
||||
// - 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>) {
|
||||
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();
|
||||
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(
|
||||
"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 })}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</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();
|
||||
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 })}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,18 @@ function DropdownMenuContent({
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
// Stop click events from bubbling out of the menu. Base UI portals the
|
||||
// popup so DOM is detached, but React's synthetic event system still
|
||||
// bubbles through the React component tree — without this, clicking a
|
||||
// menu item inside a row that's wrapped in <a> (agent / runtime list
|
||||
// rows) would ALSO fire the row's onClick → unintended navigation.
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
@@ -41,6 +47,10 @@ function DropdownMenuContent({
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
}}
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -20,12 +20,34 @@ function HoverCardContent({
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onAuxClick,
|
||||
onDoubleClick,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PreviewCardPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
// 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,
|
||||
// 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
|
||||
@@ -37,6 +59,10 @@ function HoverCardContent({
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
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: (
|
||||
|
||||
58
packages/ui/lib/data-table.ts
Normal file
58
packages/ui/lib/data-table.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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 then
|
||||
// skips the inline width for these columns and lets fixed table-layout
|
||||
// assign them the leftover space (Linear / GitHub-PR-list pattern: title
|
||||
// column grows, others stay at their declared widths).
|
||||
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 },
|
||||
): React.CSSProperties {
|
||||
const grow = column.columnDef.meta?.grow;
|
||||
const width = grow ? undefined : column.columnDef.size;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessFileCards } from './file-cards'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { preprocessMentionShortcodes } from './mentions'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './markdown.css'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
.markdown-content .katex-display {
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -70,11 +70,16 @@
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.705 0.015 286.067);
|
||||
--chart-2: oklch(0.552 0.016 285.938);
|
||||
--chart-3: oklch(0.442 0.017 285.786);
|
||||
--chart-4: oklch(0.37 0.013 285.805);
|
||||
--chart-5: oklch(0.274 0.006 286.033);
|
||||
/* Brand-derived blue gradient (h=255 = brand hue). chart-1 equals brand
|
||||
so the most important series visually anchors to the product colour;
|
||||
chart-2..5 step lighter + less saturated so a stacked bar reads
|
||||
"primary → secondary → tertiary" at a glance instead of fighting
|
||||
for attention as five equally-weighted greys. */
|
||||
--chart-1: oklch(0.55 0.16 255);
|
||||
--chart-2: oklch(0.66 0.13 255);
|
||||
--chart-3: oklch(0.76 0.10 255);
|
||||
--chart-4: oklch(0.85 0.06 255);
|
||||
--chart-5: oklch(0.92 0.03 255);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
@@ -114,11 +119,15 @@
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.871 0.006 286.286);
|
||||
--chart-2: oklch(0.75 0.016 285.938);
|
||||
--chart-3: oklch(0.62 0.017 285.786);
|
||||
--chart-4: oklch(0.52 0.013 285.805);
|
||||
--chart-5: oklch(0.42 0.006 286.033);
|
||||
/* Dark mode mirrors light mode's "primary → secondary" gradient on the
|
||||
brand hue, but flips the lightness curve: the most important series
|
||||
is the brightest (so it pops on the dark background) and trailing
|
||||
series get progressively darker / less saturated. */
|
||||
--chart-1: oklch(0.72 0.16 255);
|
||||
--chart-2: oklch(0.62 0.13 255);
|
||||
--chart-3: oklch(0.52 0.10 255);
|
||||
--chart-4: oklch(0.42 0.06 255);
|
||||
--chart-5: oklch(0.32 0.03 255);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
|
||||
368
packages/views/agents/components/agent-columns.tsx
Normal file
368
packages/views/agents/components/agent-columns.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"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,
|
||||
} 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 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 column has `meta.grow: true` so DataTable skips its inline
|
||||
// `width` — that lets fixed table-layout assign it the leftover space
|
||||
// (= container width − sum of other columns), so the table fills the
|
||||
// viewport without an empty spacer column.
|
||||
//
|
||||
// The Agent column also keeps `size: 240` even though it isn't used for
|
||||
// rendering. TanStack folds this into `table.getTotalSize()`, which
|
||||
// DataTable applies as the table's `min-width`. That's how the agent
|
||||
// column gets a real 240px floor: when the viewport drops below
|
||||
// `sum + 240`, the table refuses to shrink further and the container
|
||||
// scrolls instead. (Fixed table-layout ignores cell-level min-width
|
||||
// per spec, so the floor has to live on the table itself.)
|
||||
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,
|
||||
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,
|
||||
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 } = 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>
|
||||
Private — only the owner can assign work
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
588
packages/views/agents/components/agent-detail-inspector.tsx
Normal file
588
packages/views/agents/components/agent-detail-inspector.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Camera, Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
Agent,
|
||||
AgentRuntime,
|
||||
MemberWithUser,
|
||||
} from "@multica/core/types";
|
||||
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,
|
||||
PopoverTrigger,
|
||||
} 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";
|
||||
import { SkillAttach } from "./inspector/skill-attach";
|
||||
import { VisibilityPicker } from "./inspector/visibility-picker";
|
||||
|
||||
interface InspectorProps {
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
owner: MemberWithUser | null;
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
// Below: needed for inline edit. The inspector now owns the editing surface
|
||||
// (no Settings tab anymore), so the parent has to pass through everything
|
||||
// a write needs.
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left 320px column of the agent detail page. Holds the agent's identity card
|
||||
* (avatar / name / description / status), inline-editable properties, and
|
||||
* skills.
|
||||
*
|
||||
* **All editing happens here** — there is no separate Settings tab. The
|
||||
* trade-off is that the inspector carries some weight (4 inline pickers plus
|
||||
* 3 popovers for name/description/avatar), but it eliminates the "see vs
|
||||
* edit" mode split that the previous Settings tab created. Users no longer
|
||||
* have to switch tabs and hunt for the field they were already looking at.
|
||||
*/
|
||||
export function AgentDetailInspector({
|
||||
agent,
|
||||
runtime,
|
||||
owner,
|
||||
presence,
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
onUpdate,
|
||||
}: InspectorProps) {
|
||||
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
|
||||
const isOnline = runtime?.status === "online";
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<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. */}
|
||||
<Section label="Properties">
|
||||
<PropRow label="Runtime" interactive={false}>
|
||||
<RuntimePicker
|
||||
value={agent.runtime_id}
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
onChange={(id) => update({ runtime_id: id })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Model" interactive={false}>
|
||||
<ModelPicker
|
||||
runtimeId={agent.runtime_id}
|
||||
runtimeOnline={!!isOnline}
|
||||
value={agent.model ?? ""}
|
||||
onChange={(m) => update({ model: m })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Visibility" interactive={false}>
|
||||
<VisibilityPicker
|
||||
value={agent.visibility}
|
||||
onChange={(v) => update({ visibility: v })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Concurrency" interactive={false}>
|
||||
<ConcurrencyPicker
|
||||
value={agent.max_concurrent_tasks}
|
||||
onChange={(n) => update({ max_concurrent_tasks: n })}
|
||||
/>
|
||||
</PropRow>
|
||||
</Section>
|
||||
|
||||
{/* Details — read-only (no hover, no chip styling — these aren't clickable) */}
|
||||
<Section label="Details">
|
||||
{owner && (
|
||||
<PropRow label="Owner" interactive={false}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
actorId={owner.user_id}
|
||||
size={14}
|
||||
/>
|
||||
<span className="truncate">{owner.name}</span>
|
||||
</span>
|
||||
</PropRow>
|
||||
)}
|
||||
<PropRow label="Created" interactive={false}>
|
||||
<span className="text-muted-foreground">
|
||||
{timeAgo(agent.created_at)}
|
||||
</span>
|
||||
</PropRow>
|
||||
<PropRow label="Updated" interactive={false}>
|
||||
<span className="text-muted-foreground">
|
||||
{timeAgo(agent.updated_at)}
|
||||
</span>
|
||||
</PropRow>
|
||||
</Section>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="flex flex-col border-b px-5 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Skills
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/70">
|
||||
{agent.skills.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.skills.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
<SkillAttach agent={agent} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
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">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity — avatar / name / description editors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AvatarEditor({
|
||||
agent,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
try {
|
||||
const result = await upload(file);
|
||||
if (!result) return;
|
||||
await onUpdate({ avatar_url: result.link });
|
||||
toast.success("Avatar updated");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
// rounded-lg matches the standard agent avatar treatment used in
|
||||
// list rows. Avoid rounded-full — circles are reserved for humans.
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
aria-label="Change avatar"
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={56}
|
||||
className="rounded-none"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white" />
|
||||
) : (
|
||||
<Camera className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NameAndDescription({
|
||||
agent,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<InlineEditPopover
|
||||
value={agent.name}
|
||||
onSave={(v) => onUpdate({ name: v.trim() })}
|
||||
kind="input"
|
||||
title="Rename agent"
|
||||
placeholder="Agent name"
|
||||
validate={(v) => (v.trim().length > 0 ? null : "Name is required")}
|
||||
>
|
||||
{(triggerProps) => (
|
||||
<button
|
||||
type="button"
|
||||
{...triggerProps}
|
||||
className="group -mx-1 inline-flex items-center gap-1.5 self-start rounded px-1 text-left text-base font-semibold leading-tight transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span>{agent.name}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</InlineEditPopover>
|
||||
|
||||
<DescriptionEditor
|
||||
value={agent.description ?? ""}
|
||||
onSave={(v) => onUpdate({ description: v })}
|
||||
/>
|
||||
</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({
|
||||
value,
|
||||
onSave,
|
||||
kind,
|
||||
title,
|
||||
placeholder,
|
||||
validate,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (next: string) => Promise<void>;
|
||||
kind: "input" | "textarea";
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
validate?: (v: string) => string | null;
|
||||
children: (triggerProps: {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset draft when popover opens or upstream value changes between sessions.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(value);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, value]);
|
||||
|
||||
const commit = async () => {
|
||||
const err = validate?.(draft) ?? null;
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
if (draft === value) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(draft);
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// toast handled by parent's onUpdate
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={children({ onClick: () => setOpen(true) }) as React.ReactElement}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-72 p-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium">{title}</p>
|
||||
{kind === "input" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border bg-transparent px-2 py-1.5 text-xs outline-none focus-visible:border-input"
|
||||
/>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving || draft === value}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presence badge — unchanged from the previous version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PresenceBadge({
|
||||
presence,
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
if (!presence) {
|
||||
return (
|
||||
<span className="inline-flex h-5 w-20 animate-pulse rounded-md bg-muted" />
|
||||
);
|
||||
}
|
||||
const av = availabilityConfig[presence.availability];
|
||||
// Last-task chip / failure copy intentionally omitted on the detail page
|
||||
// — the Recent work panel below shows the same data with full context.
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
|
||||
{av.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
packages/views/agents/components/agent-detail-page.tsx
Normal file
357
packages/views/agents/components/agent-detail-page.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Agent, UpdateAgentRequest } from "@multica/core/types";
|
||||
import {
|
||||
type AgentPresenceDetail,
|
||||
useWorkspacePresenceMap,
|
||||
} from "@multica/core/agents";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
agentListOptions,
|
||||
memberListOptions,
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { AgentDetailInspector } from "./agent-detail-inspector";
|
||||
import { AgentOverviewPane } from "./agent-overview-pane";
|
||||
|
||||
interface AgentDetailPageProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
const navigation = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const {
|
||||
data: agents = [],
|
||||
isLoading: agentsLoading,
|
||||
error: agentsError,
|
||||
refetch: refetchAgents,
|
||||
} = useQuery(agentListOptions(wsId));
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
|
||||
// Single workspace-level presence pass; this page just reads its slot.
|
||||
// The hook owns the 30s tick so the failed-window auto-clears here too.
|
||||
const { byAgent: presenceMap } = useWorkspacePresenceMap(wsId);
|
||||
|
||||
const agent = agents.find((a) => a.id === agentId) ?? null;
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
}
|
||||
};
|
||||
|
||||
// --- Loading ---
|
||||
if (agentsLoading && !agent) {
|
||||
return <DetailLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// --- Not found / error ---
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<BackHeader paths={paths.agents()} title="Agents" />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Agent not found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{agentsError instanceof Error
|
||||
? agentsError.message
|
||||
: "This agent may have been archived or deleted."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchAgents()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => navigation.push(paths.agents())}
|
||||
>
|
||||
Back to agents
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isArchived = !!agent.archived_at;
|
||||
const runtime = agent.runtime_id
|
||||
? runtimes.find((r) => r.id === agent.runtime_id) ?? null
|
||||
: null;
|
||||
const owner = agent.owner_id
|
||||
? members.find((m) => m.user_id === agent.owner_id) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<DetailHeader
|
||||
agent={agent}
|
||||
presence={presence}
|
||||
backHref={paths.agents()}
|
||||
onArchive={() => setConfirmArchive(true)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
|
||||
<AgentDetailInspector
|
||||
agent={agent}
|
||||
runtime={runtime}
|
||||
owner={owner}
|
||||
presence={presence}
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
|
||||
<AgentOverviewPane
|
||||
agent={agent}
|
||||
runtimes={runtimes}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{confirmArchive && (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setConfirmArchive(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Archive agent?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
"{agent.name}" will be archived. It won't be
|
||||
assignable or mentionable, but all history is preserved. You
|
||||
can restore it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmArchive(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmArchive(false);
|
||||
handleArchive(agent.id);
|
||||
navigation.push(paths.agents());
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHeader({
|
||||
agent,
|
||||
presence,
|
||||
backHref,
|
||||
onArchive,
|
||||
}: {
|
||||
agent: Agent;
|
||||
presence: AgentPresenceDetail | null;
|
||||
backHref: string;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const isArchived = !!agent.archived_at;
|
||||
const av = presence ? availabilityConfig[presence.availability] : null;
|
||||
// Last-task state is intentionally not surfaced in the header — the
|
||||
// Recent work section on this page already shows the same information
|
||||
// (and richer: titles, timestamps, error messages). Showing "Completed"
|
||||
// up here was redundant chrome.
|
||||
|
||||
return (
|
||||
<PageHeader className="justify-between gap-3 px-5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AppLink
|
||||
href={backHref}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Agents
|
||||
</AppLink>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<h1 className="truncate text-sm font-medium">{agent.name}</h1>
|
||||
{!isArchived && av && presence && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
|
||||
{av.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" />}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={onArchive}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function BackHeader({ paths, title }: { paths: string; title: string }) {
|
||||
return (
|
||||
<PageHeader className="justify-between px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLink
|
||||
href={paths}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{title}
|
||||
</AppLink>
|
||||
</div>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLoadingSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<PageHeader className="px-5">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</PageHeader>
|
||||
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-5">
|
||||
<Skeleton className="h-14 w-14 rounded-lg" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-6">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Cloud,
|
||||
Monitor,
|
||||
FileText,
|
||||
BookOpenText,
|
||||
ListTodo,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
KeyRound,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { Agent, RuntimeDevice, MemberWithUser } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { statusConfig } from "../config";
|
||||
import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { TasksTab } from "./tabs/tasks-tab";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
import { CustomArgsTab } from "./tabs/custom-args-tab";
|
||||
|
||||
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
||||
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
||||
}
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "custom_args" | "settings";
|
||||
|
||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "custom_args", label: "Custom Args", icon: Terminal },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function AgentDetail({
|
||||
agent,
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
onUpdate,
|
||||
onArchive,
|
||||
onRestore,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Archive Banner */}
|
||||
{isArchived && (
|
||||
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
|
||||
<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={() => onRestore(agent.id)}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} disableHoverCard />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
|
||||
{isArchived ? (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
) : (
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3" />
|
||||
) : (
|
||||
<Monitor className="h-3 w-3" />
|
||||
)}
|
||||
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6">
|
||||
{detailTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{activeTab === "instructions" && (
|
||||
<InstructionsTab
|
||||
agent={agent}
|
||||
onSave={(instructions) => onUpdate(agent.id, { instructions })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "skills" && (
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "env" && (
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
readOnly={agent.custom_env_redacted}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "custom_args" && (
|
||||
<CustomArgsTab
|
||||
agent={agent}
|
||||
runtimeDevice={runtimeDevice}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsTab
|
||||
agent={agent}
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Archive Confirmation */}
|
||||
{confirmArchive && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
"{agent.name}" will be archived. It won't be assignable or mentionable, but all history is preserved. You can restore it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmArchive(false);
|
||||
onArchive(agent.id);
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud, Monitor } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { statusConfig } from "../config";
|
||||
|
||||
export function AgentListItem({
|
||||
agent,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
agent: Agent;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} disableHoverCard />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">Archived</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
209
packages/views/agents/components/agent-overview-pane.tsx
Normal file
209
packages/views/agents/components/agent-overview-pane.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
BookOpenText,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { ActivityTab } from "./tabs/activity-tab";
|
||||
import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
import { CustomArgsTab } from "./tabs/custom-args-tab";
|
||||
|
||||
type DetailTab =
|
||||
| "activity"
|
||||
| "instructions"
|
||||
| "skills"
|
||||
| "env"
|
||||
| "custom_args";
|
||||
|
||||
const detailTabs: {
|
||||
id: DetailTab;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
}[] = [
|
||||
{ id: "activity", label: "Activity", icon: Activity },
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "custom_args", label: "Custom Args", icon: Terminal },
|
||||
];
|
||||
|
||||
interface AgentOverviewPaneProps {
|
||||
agent: Agent;
|
||||
runtimes: AgentRuntime[];
|
||||
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-pane on the agent detail page. Five tabs of equal weight:
|
||||
*
|
||||
* - Activity (default) — what the agent is doing now / how it's been doing /
|
||||
* what it just finished. The "watch state" surface.
|
||||
* - Instructions / Skills / Env / Custom Args — four editing surfaces.
|
||||
*
|
||||
* The previous Settings tab was deleted because every field on it is now
|
||||
* inline-editable in the inspector (left column) — runtime / model /
|
||||
* visibility / concurrency via PropRow + Picker, and avatar / name /
|
||||
* description via popover. Two entry points for the same writes was just
|
||||
* extra concept count without extra capability.
|
||||
*
|
||||
* Activity is the landing tab because most visits to this page are diagnostic
|
||||
* ("what is this agent doing / why did it fail?"), not configuration tweaks.
|
||||
*
|
||||
* **Unsaved-changes guard**: every config tab reports its dirty state up via
|
||||
* `onDirtyChange`. Switching to another tab while the active tab is dirty
|
||||
* pops a confirm dialog — without it, switching tabs would silently drop
|
||||
* unsaved edits because each tab manages its own local state and remounts on
|
||||
* tab change.
|
||||
*/
|
||||
export function AgentOverviewPane({
|
||||
agent,
|
||||
runtimes,
|
||||
onUpdate,
|
||||
}: AgentOverviewPaneProps) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("activity");
|
||||
const [activeDirty, setActiveDirty] = useState(false);
|
||||
// Holds the destination when a tab change is intercepted by the dirty
|
||||
// guard. Null means no pending change. The AlertDialog reads non-null as
|
||||
// "open".
|
||||
const [pendingTab, setPendingTab] = useState<DetailTab | null>(null);
|
||||
|
||||
const runtime = agent.runtime_id
|
||||
? runtimes.find((r) => r.id === agent.runtime_id) ?? null
|
||||
: null;
|
||||
|
||||
const requestTabChange = (next: DetailTab) => {
|
||||
if (next === activeTab) return;
|
||||
if (activeDirty) {
|
||||
setPendingTab(next);
|
||||
return;
|
||||
}
|
||||
setActiveTab(next);
|
||||
};
|
||||
|
||||
const commitTabChange = () => {
|
||||
if (pendingTab) {
|
||||
setActiveTab(pendingTab);
|
||||
// The new tab mounts fresh; its effect will report its own dirty state.
|
||||
// We pre-clear so the guard can't trip from stale state on the way in.
|
||||
setActiveDirty(false);
|
||||
setPendingTab(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
<div className="flex shrink-0 items-center gap-0 border-b px-4">
|
||||
{detailTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => requestTabChange(tab.id)}
|
||||
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{activeTab === "activity" && <ActivityTab agent={agent} />}
|
||||
{activeTab === "instructions" && (
|
||||
<TabContent>
|
||||
<InstructionsTab
|
||||
agent={agent}
|
||||
onSave={(instructions) => onUpdate(agent.id, { instructions })}
|
||||
onDirtyChange={setActiveDirty}
|
||||
/>
|
||||
</TabContent>
|
||||
)}
|
||||
{activeTab === "skills" && (
|
||||
<TabContent>
|
||||
<SkillsTab agent={agent} />
|
||||
</TabContent>
|
||||
)}
|
||||
{activeTab === "env" && (
|
||||
<TabContent>
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
readOnly={agent.custom_env_redacted}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
onDirtyChange={setActiveDirty}
|
||||
/>
|
||||
</TabContent>
|
||||
)}
|
||||
{activeTab === "custom_args" && (
|
||||
<TabContent>
|
||||
<CustomArgsTab
|
||||
agent={agent}
|
||||
runtimeDevice={runtime ?? undefined}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
onDirtyChange={setActiveDirty}
|
||||
/>
|
||||
</TabContent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingTab !== null && (
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setPendingTab(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes in this tab. Leaving now will discard
|
||||
them.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={commitTabChange}
|
||||
>
|
||||
Discard changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Centred, max-width container shared by every config tab. `h-full flex
|
||||
// flex-col` lets a tab opt into "fill the viewport" by giving its root
|
||||
// element `flex-1 min-h-0` (Instructions does this so the editor expands
|
||||
// instead of pushing the Save row off-screen). Tabs that don't opt in
|
||||
// behave as natural-height blocks; long content (e.g. Settings, long Skills
|
||||
// list) still scrolls via the parent's overflow-y-auto.
|
||||
function TabContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-6">{children}</div>
|
||||
);
|
||||
}
|
||||
112
packages/views/agents/components/agent-presence-indicator.tsx
Normal file
112
packages/views/agents/components/agent-presence-indicator.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import type { AgentPresenceDetail } from "@multica/core/agents";
|
||||
import { availabilityConfig, workloadConfig } from "../presence";
|
||||
|
||||
interface PresenceIndicatorProps {
|
||||
// null/undefined = still loading. Caller passes the detail computed at
|
||||
// the page level (or via the useAgentPresenceDetail hook for single-agent
|
||||
// views). Keeping this as a prop avoids per-row hook subscriptions in
|
||||
// long lists.
|
||||
detail: AgentPresenceDetail | null | undefined;
|
||||
// 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 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 — 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.
|
||||
*
|
||||
* Pure presentation — takes the already-derived detail object as a prop.
|
||||
* The page-level component is responsible for sourcing it (via
|
||||
* `useAgentPresenceDetail` for a single agent, or `useWorkspacePresenceMap`
|
||||
* for lists).
|
||||
*/
|
||||
export function AgentPresenceIndicator({
|
||||
detail,
|
||||
compact,
|
||||
}: PresenceIndicatorProps) {
|
||||
if (!detail) {
|
||||
return compact ? (
|
||||
<Skeleton className="h-1.5 w-1.5 rounded-full" />
|
||||
) : (
|
||||
<Skeleton className="h-3 w-24 rounded" />
|
||||
);
|
||||
}
|
||||
|
||||
const av = availabilityConfig[detail.availability];
|
||||
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.workload !== "idle" ? ` · ${wl.label}` : ""}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
{/* Availability — dot + label. Single dimension, single colour. */}
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud, Monitor, Wifi, WifiOff } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import { useAgentPresenceDetail } from "@multica/core/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
deriveRuntimeHealth,
|
||||
type RuntimeHealth,
|
||||
} from "@multica/core/runtimes";
|
||||
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { statusConfig } from "../config";
|
||||
import { formatLastSeen } from "../../runtimes/utils";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { HealthIcon } from "../../runtimes/components/shared";
|
||||
import { availabilityConfig } from "../presence";
|
||||
|
||||
interface AgentProfileCardProps {
|
||||
agentId: string;
|
||||
@@ -17,9 +23,10 @@ interface AgentProfileCardProps {
|
||||
|
||||
export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
const wsId = useWorkspaceId();
|
||||
const p = useWorkspacePaths();
|
||||
const { data: agents = [], isLoading: agentsLoading } = useQuery(agentListOptions(wsId));
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
@@ -41,10 +48,10 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = runtimes.find((r) => r.id === agent.runtime_id) ?? null;
|
||||
const owner = agent.owner_id
|
||||
? members.find((m) => m.user_id === agent.owner_id) ?? null
|
||||
: null;
|
||||
const runtime = runtimes.find((r) => r.id === agent.runtime_id) ?? null;
|
||||
const isArchived = !!agent.archived_at;
|
||||
const initials = agent.name
|
||||
.split(" ")
|
||||
@@ -54,8 +61,14 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-left">
|
||||
{/* Header */}
|
||||
// `group` enables the hover-only Detail link on the top-right —
|
||||
// it fades in only when the user is hovering the card chrome,
|
||||
// staying out of the way during a quick glance.
|
||||
<div className="group flex flex-col gap-3 text-left">
|
||||
{/* Header — avatar + name + availability on the left, "Detail →" link
|
||||
on the right (hover-only). Card stays minimal: only the 3-state
|
||||
availability dot is surfaced here; last-task state lives in the
|
||||
agents list and the agent detail page. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ActorAvatarBase
|
||||
name={agent.name}
|
||||
@@ -74,8 +87,18 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<AgentStatusLine agent={agent} />
|
||||
{!isArchived && (
|
||||
<AgentAvailabilityLine wsId={wsId} agentId={agent.id} />
|
||||
)}
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<AppLink
|
||||
href={p.agentDetail(agent.id)}
|
||||
className="mr-1 mt-0.5 shrink-0 text-xs font-normal text-brand opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
Detail →
|
||||
</AppLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@@ -85,10 +108,11 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta rows */}
|
||||
{/* Meta rows — minimal set: runtime (where it lives), skills (what
|
||||
it knows), owner (who manages it). Model is intentionally
|
||||
omitted — power-user detail lives on the detail page. */}
|
||||
<div className="flex flex-col gap-1.5 text-xs">
|
||||
<RuntimeRow agent={agent} runtime={runtime} />
|
||||
{agent.model && <MetaRow label="Model" value={agent.model} mono />}
|
||||
{agent.skills.length > 0 && (
|
||||
<SkillsRow skills={agent.skills.map((s) => s.name)} />
|
||||
)}
|
||||
@@ -98,16 +122,37 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStatusLine({ agent }: { agent: Agent }) {
|
||||
const st = statusConfig[agent.status];
|
||||
// Compact availability line under the agent name — single 3-state signal
|
||||
// (online / unstable / offline). Last-task state is intentionally NOT
|
||||
// shown here; it belongs in the agents list and the detail page where
|
||||
// there's room for icon + label + reason without crowding the popover.
|
||||
function AgentAvailabilityLine({
|
||||
wsId,
|
||||
agentId,
|
||||
}: {
|
||||
wsId: string | undefined;
|
||||
agentId: string;
|
||||
}) {
|
||||
const detail = useAgentPresenceDetail(wsId, agentId);
|
||||
if (detail === "loading") {
|
||||
return <Skeleton className="mt-0.5 h-3 w-16" />;
|
||||
}
|
||||
const av = availabilityConfig[detail.availability];
|
||||
return (
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
<div className="mt-0.5 inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact runtime row — wifi-style health icon + runtime name. The icon
|
||||
// shape (Wifi / WifiOff) plus colour reflects the live runtime health
|
||||
// derived from runtime + clock; cloud runtimes always read as online.
|
||||
// This is duplicate signal with the availability dot above by design —
|
||||
// the dot is the agent's effective availability (which mostly tracks
|
||||
// runtime health), and seeing the same wifi icon next to the runtime
|
||||
// name confirms WHICH runtime is the one currently in the dot's state.
|
||||
function RuntimeRow({
|
||||
agent,
|
||||
runtime,
|
||||
@@ -116,34 +161,19 @@ function RuntimeRow({
|
||||
runtime: AgentRuntime | null;
|
||||
}) {
|
||||
const isCloud = agent.runtime_mode === "cloud";
|
||||
const Icon = isCloud ? Cloud : Monitor;
|
||||
const isOnline = runtime?.status === "online";
|
||||
// Cloud runtimes are always reachable from the user's perspective.
|
||||
const showOnline = isCloud || isOnline;
|
||||
|
||||
let detail: string;
|
||||
if (isCloud) {
|
||||
detail = runtime?.name ?? "Cloud";
|
||||
} else if (runtime) {
|
||||
detail = isOnline
|
||||
? runtime.name
|
||||
: `${runtime.name} · last seen ${formatLastSeen(runtime.last_seen_at)}`;
|
||||
} else {
|
||||
detail = "Unknown runtime";
|
||||
}
|
||||
|
||||
const health: RuntimeHealth = isCloud
|
||||
? "online"
|
||||
: runtime
|
||||
? deriveRuntimeHealth(runtime, Date.now())
|
||||
: "offline";
|
||||
const label = runtime?.name ?? (isCloud ? "Cloud" : "Unknown runtime");
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-12 shrink-0 text-muted-foreground">Runtime</span>
|
||||
<Icon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate" title={detail}>
|
||||
{detail}
|
||||
<HealthIcon health={health} className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
{showOnline ? (
|
||||
<Wifi className="ml-auto h-3 w-3 shrink-0 text-success" />
|
||||
) : (
|
||||
<WifiOff className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
289
packages/views/agents/components/agent-row-actions.tsx
Normal file
289
packages/views/agents/components/agent-row-actions.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
RotateCcw,
|
||||
Square,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import type { AgentPresenceDetail } from "@multica/core/agents";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
|
||||
interface AgentRowActionsProps {
|
||||
agent: Agent;
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
// True when the current user can manage this agent (owner of agent or
|
||||
// workspace admin/owner). Mirrors the back-end's canManageAgent check —
|
||||
// the server is still the source of truth, this only hides UI for ops
|
||||
// the user can't perform.
|
||||
canManage: boolean;
|
||||
// Called when the user picks "Duplicate" — the page opens a Create
|
||||
// dialog pre-populated with this agent's config as a template.
|
||||
onDuplicate: (agent: Agent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row dropdown menu for the agents list. The set of actions is derived
|
||||
* from (a) the agent's lifecycle state (active vs archived) and (b) the
|
||||
* caller's permission level. If no actions apply, the trigger is omitted so
|
||||
* the row renders an empty cell (column width still preserved by the parent
|
||||
* `<TableCell className="w-10" />`).
|
||||
*
|
||||
* All triggers stop event propagation so clicks don't bubble up to the
|
||||
* row's navigate-to-detail handler.
|
||||
*/
|
||||
export function AgentRowActions({
|
||||
agent,
|
||||
presence,
|
||||
canManage,
|
||||
onDuplicate,
|
||||
}: AgentRowActionsProps) {
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
|
||||
const isArchived = !!agent.archived_at;
|
||||
const runningCount = presence?.runningCount ?? 0;
|
||||
const queuedCount = presence?.queuedCount ?? 0;
|
||||
const hasActiveWork = runningCount + queuedCount > 0;
|
||||
|
||||
// Derive which menu items to render. Doing this once here keeps the JSX
|
||||
// below a flat list of conditionals rather than a tangle of role/state
|
||||
// branches.
|
||||
const showStop = canManage && !isArchived && hasActiveWork;
|
||||
const showDuplicate = !isArchived; // any workspace member can duplicate
|
||||
const showArchive = canManage && !isArchived;
|
||||
const showRestore = canManage && isArchived;
|
||||
|
||||
const hasAnyAction = showStop || showDuplicate || showArchive || showRestore;
|
||||
|
||||
const invalidateAgents = () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await api.archiveAgent(agent.id);
|
||||
invalidateAgents();
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
await api.restoreAgent(agent.id);
|
||||
invalidateAgents();
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelTasks = async () => {
|
||||
try {
|
||||
const { cancelled } = await api.cancelAgentTasks(agent.id);
|
||||
// Server broadcasts task:cancelled per row; useRealtimeSync will
|
||||
// invalidate the agent-task-snapshot cache for us. We still kick
|
||||
// agents in case the back-end's ReconcileAgentStatus changed
|
||||
// agent.status.
|
||||
invalidateAgents();
|
||||
toast.success(
|
||||
cancelled === 0
|
||||
? "No active tasks to cancel"
|
||||
: `Cancelled ${cancelled} task${cancelled === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel tasks");
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAnyAction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Row actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-auto"
|
||||
// Prevent the row's onClick from firing if a click on a menu item
|
||||
// somehow bubbles back through the portal.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showStop && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmCancel(true)}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Cancel all tasks
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDuplicate && (
|
||||
<DropdownMenuItem onClick={() => onDuplicate(agent)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showRestore && (
|
||||
<DropdownMenuItem onClick={handleRestore}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showArchive && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{confirmCancel && (
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setConfirmCancel(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent
|
||||
// Keep clicks inside the dialog from bubbling to the row.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Cancel all tasks for “{agent.name}”?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{describeCancelImpact(runningCount, queuedCount)}
|
||||
{runningCount > 0 && (
|
||||
<>
|
||||
{" "}Running tasks may take up to 5 seconds to fully halt.
|
||||
</>
|
||||
)}{" "}
|
||||
Cancelled tasks cannot be resumed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep them</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmCancel(false);
|
||||
void handleCancelTasks();
|
||||
}}
|
||||
>
|
||||
Cancel all tasks
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{confirmArchive && (
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setConfirmArchive(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertDialogTitle>
|
||||
Archive “{agent.name}”?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The agent won't be assignable or mentionable, and any
|
||||
active tasks will be cancelled. All history is preserved
|
||||
and you can restore it later.
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmArchive(false);
|
||||
void handleArchive();
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function describeCancelImpact(running: number, queued: number): string {
|
||||
// Both zero shouldn't happen — the menu item is gated on hasActiveWork —
|
||||
// but guarding anyway so the copy never reads "stop 0 tasks and 0 tasks".
|
||||
if (running === 0 && queued === 0) {
|
||||
return "There are no active tasks to cancel.";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (running > 0) parts.push(`${running} running`);
|
||||
if (queued > 0) parts.push(`${queued} queued`);
|
||||
return `This will cancel ${parts.join(" and ")} ${
|
||||
running + queued === 1 ? "task" : "tasks"
|
||||
}.`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ModelDropdown } from "./model-dropdown";
|
||||
import type {
|
||||
Agent,
|
||||
AgentVisibility,
|
||||
RuntimeDevice,
|
||||
MemberWithUser,
|
||||
@@ -28,6 +29,8 @@ 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 } from "@multica/core/agents";
|
||||
import { CharCounter } from "./char-counter";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
@@ -36,6 +39,7 @@ export function CreateAgentDialog({
|
||||
runtimesLoading,
|
||||
members,
|
||||
currentUserId,
|
||||
template,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
@@ -43,13 +47,26 @@ export function CreateAgentDialog({
|
||||
runtimesLoading?: boolean;
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
// When provided, the dialog opens in "Duplicate" mode: the visible
|
||||
// fields (name / description / runtime / visibility / model) are
|
||||
// pre-populated from this agent, and the hidden fields
|
||||
// (instructions / custom_args / custom_env / max_concurrent_tasks)
|
||||
// are forwarded to the create call so the new agent is a true clone.
|
||||
// Skills are copied separately by the caller after createAgent
|
||||
// succeeds — they're not part of CreateAgentRequest.
|
||||
template?: Agent | null;
|
||||
onClose: () => void;
|
||||
onCreate: (data: CreateAgentRequest) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>("private");
|
||||
const [model, setModel] = useState("");
|
||||
const isDuplicate = !!template;
|
||||
const [name, setName] = useState(
|
||||
template ? `${template.name} (Copy)` : "",
|
||||
);
|
||||
const [description, setDescription] = useState(template?.description ?? "");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>(
|
||||
template?.visibility ?? "private",
|
||||
);
|
||||
const [model, setModel] = useState(template?.model ?? "");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
|
||||
@@ -72,7 +89,11 @@ export function CreateAgentDialog({
|
||||
});
|
||||
}, [runtimes, runtimeFilter, currentUserId]);
|
||||
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(filteredRuntimes[0]?.id ?? "");
|
||||
// When duplicating, default to the template's runtime so the clone
|
||||
// lands on the same machine — caller can still switch in the picker.
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(
|
||||
template?.runtime_id ?? filteredRuntimes[0]?.id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId && filteredRuntimes[0]) {
|
||||
@@ -86,13 +107,34 @@ export function CreateAgentDialog({
|
||||
if (!name.trim() || !selectedRuntime) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate({
|
||||
// When duplicating, forward the hidden config fields the template
|
||||
// carries (instructions / custom_args / custom_env / max_concurrent_tasks)
|
||||
// so the clone is functional out of the box without the user
|
||||
// having to walk back through every settings tab. Skills are
|
||||
// copied by the caller in a follow-up setAgentSkills call.
|
||||
const data: CreateAgentRequest = {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
runtime_id: selectedRuntime.id,
|
||||
visibility,
|
||||
model: model.trim() || undefined,
|
||||
});
|
||||
};
|
||||
if (template) {
|
||||
if (template.instructions) data.instructions = template.instructions;
|
||||
if (template.custom_args.length) data.custom_args = template.custom_args;
|
||||
// Skip env when the template's values are redacted from the API
|
||||
// response — copying placeholders would create a broken clone.
|
||||
if (
|
||||
!template.custom_env_redacted &&
|
||||
Object.keys(template.custom_env).length > 0
|
||||
) {
|
||||
data.custom_env = template.custom_env;
|
||||
}
|
||||
if (template.max_concurrent_tasks) {
|
||||
data.max_concurrent_tasks = template.max_concurrent_tasks;
|
||||
}
|
||||
}
|
||||
await onCreate(data);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create agent");
|
||||
@@ -104,9 +146,13 @@ export function CreateAgentDialog({
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isDuplicate ? "Duplicate Agent" : "Create Agent"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new AI agent for your workspace.
|
||||
{isDuplicate
|
||||
? `Create a new agent based on "${template!.name}". Instructions, env, and skills are copied for you.`
|
||||
: "Create a new AI agent for your workspace."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -131,8 +177,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>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { AgentsPage } from "./agents-page";
|
||||
export { AgentDetailPage } from "./agent-detail-page";
|
||||
|
||||
18
packages/views/agents/components/inspector/chip.ts
Normal file
18
packages/views/agents/components/inspector/chip.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Shared trigger styling for inspector pickers (Runtime / Model / Visibility /
|
||||
* Concurrency).
|
||||
*
|
||||
* The defining choices:
|
||||
* - `rounded-md` (6px) — soft enough to feel like a button, not a tab.
|
||||
* - `hover:bg-accent` — single hover layer carries the entire "this is a
|
||||
* button" signal. We tried adding a hover-border on top, but layered hover
|
||||
* states (border + bg) made the chip outline busier without adding info.
|
||||
* - `min-w-0` so children that `truncate` don't overflow the inspector's
|
||||
* 320px column.
|
||||
*
|
||||
* No default border on purpose: at rest the chip should sit quietly inside
|
||||
* the row; the moment the cursor enters, the bg flips and the affordance is
|
||||
* obvious.
|
||||
*/
|
||||
export const CHIP_CLASS =
|
||||
"group flex min-w-0 cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs transition-colors hover:bg-accent";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user