chore(repo): reinitialize repository
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
.DS_Store
|
||||
.cache/
|
||||
.runtime/
|
||||
.playwright-cli/
|
||||
|
||||
.claude/settings.local.json
|
||||
config/codex/
|
||||
|
||||
inbox-worktrees/
|
||||
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
**/coverage/
|
||||
**/.next/
|
||||
**/*.tsbuildinfo
|
||||
|
||||
apps/
|
||||
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,135 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文档为在此仓库中工作的 AI 编码代理(Codex、Cursor、Copilot 等)提供指导。
|
||||
|
||||
## 第一性原理
|
||||
|
||||
请使用第一性原理思考。你不能总是假设我非常清楚自己想要什么和该怎么得到。请保持审慎,从原始需求和问题出发,如果动机和目标不清晰,停下来和我讨论。
|
||||
|
||||
|
||||
## 方案规范
|
||||
|
||||
当需要你给出修改或重构方案时必须符合以下规范:
|
||||
|
||||
- 不允许给出兼容性或补丁性的方案
|
||||
- 不允许过度设计,保持最短路径实现且不能违反第一条要求
|
||||
- 不允许自行给出我提供的需求以外的方案,例如一些兜底和降级方案,这可能导致业务逻辑偏移问题
|
||||
- 必须确保方案的逻辑正确,必须经过全链路的逻辑验证
|
||||
|
||||
## 这是什么
|
||||
|
||||
这是一个多代理 AI 编排系统。
|
||||
|
||||
## 关键命令
|
||||
|
||||
### Inbox CLI(核心工具)
|
||||
|
||||
```bash
|
||||
cd inbox && go build -o /tmp/inbox-host ./cmd/inbox
|
||||
export INBOX_WORKSPACE=/Users/xd/project/ai-workflow-v2
|
||||
|
||||
/tmp/inbox-host server --workspaces-dir /Users/xd/project/ai-workflow-v2/inbox-worktrees --port 3000
|
||||
/tmp/inbox-host api GET /api/v2/projects
|
||||
/tmp/inbox-host api POST /api/v2/topics --data '{"workspace_id":"ws_1","title":"Signup Flow","space":"workflow","status":"execution"}'
|
||||
```
|
||||
|
||||
### Dashboard(React UI)
|
||||
|
||||
```bash
|
||||
cd dashboard && npm install && npm run dev # Vite 开发服务器
|
||||
cd dashboard && npm run build # 生产构建 → dist/
|
||||
```
|
||||
|
||||
开发模式下,Dashboard 会将 `/api` 代理到 `http://localhost:3000`。
|
||||
|
||||
### Apps
|
||||
|
||||
```bash
|
||||
# phonesite(Next.js 静态站点)
|
||||
cd apps/phonesite && npm install && npm run dev
|
||||
cd apps/phonesite && npm test # Vitest
|
||||
cd apps/phonesite && npx playwright test # E2E
|
||||
|
||||
# redbook(全栈:Go + Next.js)
|
||||
cd apps/redbook/backend && go run ./cmd/server # :8080
|
||||
cd apps/redbook/backend && go test ./...
|
||||
cd apps/redbook/frontend && npm install && npm run dev
|
||||
cd apps/redbook/frontend && npm test
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
### 当前流程
|
||||
|
||||
当前激活的流程以 HTTP 为中心:
|
||||
|
||||
1. `inbox server` 启动 Inbox API。
|
||||
2. `inbox api ...` 或 Dashboard 调用 `/api/v2/*`。
|
||||
3. HTTP 层通过 `internal/store/sqlite` 直接写入 SQLite。
|
||||
4. 运行时角色解析从数据库读取,并快照到 workflow run 中。
|
||||
|
||||
### 运行时目录结构
|
||||
|
||||
```
|
||||
<repo>/.runtime/inbox.db # Inbox V2 SQLite 存储
|
||||
```
|
||||
|
||||
### Inbox CLI(Go,`inbox/`)
|
||||
|
||||
单个二进制文件,提供两个命令:
|
||||
- `server`:启动 HTTP API
|
||||
- `api`:通用的、面向 AI 的 HTTP 包装器
|
||||
|
||||
关键模块区域:
|
||||
- `internal/base/`:通用基础原语
|
||||
- `internal/domain/`:稳定的业务实体与规则
|
||||
- `internal/app/`:运行时配置与 workflow-run 服务
|
||||
- `internal/httpapi/`:`/api/v2/*` 处理器
|
||||
- `internal/store/sqlite/`:SQLite schema 与仓储实现
|
||||
|
||||
### 角色系统
|
||||
|
||||
角色是数据库驱动的运行时配置,而不是文件驱动的事实来源。
|
||||
|
||||
- `leader` 负责用户对话、任务拆解、链路编排,以及宿主机上的最终执行决策
|
||||
- `worker` 在隔离的 worktree/container 中执行分配的链路任务,并向 `leader` 回传证据
|
||||
- `user` 是仅供人类使用的运行时角色,永远不会由 AI 运行时自动执行
|
||||
|
||||
### Dashboard(`dashboard/`)
|
||||
|
||||
基于 React 19 + TypeScript + Vite + Tailwind + Framer Motion。页面包括:Timeline、Topics、Roles、Executions、Merges。通过轮询 inbox web API 获取更新。
|
||||
## 工程规则
|
||||
|
||||
### 最高优先级:修复根因,而不是增加回退逻辑
|
||||
|
||||
- 默认直接修复根因。除非用户明确要求这种权衡,否则不要添加 fallback 逻辑、ignore 规则、兼容性分支、防御性特判或“以防万一”的条件分支。
|
||||
- 如果真正的问题是糟糕的历史状态、陈旧的运行时产物、脏 worktree 或畸形数据,应通过迁移、清理步骤、修复脚本或定向数据修正直接修复状态,而不是让应用逻辑静默容忍这些问题。
|
||||
- 不要把运维残留吸收到产品逻辑中。运行时文件、缓存文件、生成产物、临时目录以及本地 OS 噪音,都应在源头被隔离、迁移或显式清理。
|
||||
- 在考虑“增强韧性”的补丁之前,先问自己:“为什么会出现这个坏状态?”以及“能不能直接移除这个坏状态的来源?”优先做源头治理。
|
||||
- 如果 fallback 看起来不可避免,先停下来,把这个权衡显式告知用户,再决定是否实现。不要为了短期方便,悄悄加入长期分支逻辑。
|
||||
- 优先选择一条干净、易解释的路径,而不是堆叠逻辑去保留旧错误。
|
||||
|
||||
### 不要添加遗留兼容层
|
||||
|
||||
- 不要为旧数据、旧字段值、旧路由、旧目录布局、旧分支命名或旧运行时行为添加兼容代码。
|
||||
- 不要保留双路径逻辑,例如 `new flow || old flow`、fallback-to-legacy 分支、针对历史数据的静默运行时协调,或把 cleanup-by-ignore 规则塞进核心逻辑里。
|
||||
- 如果旧数据或运行时残留有问题,应通过显式迁移、修复脚本、定向 DB 更新或一次性清理直接修正。不要把问题藏进应用逻辑里。
|
||||
- 如果旧实现正在被替换,就删除旧路径,而不是长期同时支持两套。
|
||||
- 不要留下没有移除计划的“临时” fallback 分支。在这个仓库里,临时兼容逻辑通常就是错误方向。
|
||||
|
||||
### 清晰的模块归属
|
||||
|
||||
- 新行为必须放进职责单一、边界明确的模块中。
|
||||
- 不要把一个特性零散地分布在 handler、service、helper 和 infra 文件中,而没有清晰的模块边界。
|
||||
- HTTP handler 应保持轻量:解析请求、调用 application service、写回响应。
|
||||
- Application service 应负责编排和业务流程。
|
||||
- git、container、filesystem、process execution 等基础设施细节应封装在职责明确、接口收敛的专用模块之后。
|
||||
- 做重构时,优先先提取模块,再把逻辑迁移进去,而不是让部分逻辑继续散落在旧文件中。
|
||||
|
||||
### 处理破坏性变更的首选方式
|
||||
|
||||
- 优先干净替换,而不是渐进兼容。
|
||||
- 优先 schema/data migration,而不是运行时分支。
|
||||
- 优先显式清理,而不是用 ignore 掩盖问题。
|
||||
- 优先显式失败,而不是隐藏 fallback 行为。
|
||||
- 新模块就位后,优先删除废弃代码。
|
||||
@@ -0,0 +1,94 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## What This Is
|
||||
|
||||
Multi-agent AI orchestration system. The active V2 model is leader/worker based: the host-side `leader` talks to the user and orchestrates work, while per-chain `worker` runtimes execute tasks in isolated worktrees and containers.
|
||||
|
||||
## Key Commands
|
||||
|
||||
### Inbox CLI
|
||||
|
||||
```bash
|
||||
cd inbox && go build -o /tmp/inbox-host ./cmd/inbox
|
||||
export INBOX_WORKSPACE=/Users/xd/project/ai-workflow-v2
|
||||
|
||||
/tmp/inbox-host server --workspaces-dir /Users/xd/project/ai-workflow-v2/inbox-worktrees --port 3000
|
||||
/tmp/inbox-host api GET /api/v2/projects
|
||||
/tmp/inbox-host api POST /api/v2/topics --data '{"workspace_id":"ws_1","title":"Signup Flow","space":"workflow","status":"execution"}'
|
||||
```
|
||||
|
||||
### Dashboard
|
||||
|
||||
```bash
|
||||
cd dashboard && npm install && npm run dev
|
||||
cd dashboard && npm run build
|
||||
```
|
||||
|
||||
Dashboard dev mode proxies `/api` to `http://localhost:3000`.
|
||||
|
||||
### Container Launch
|
||||
|
||||
```bash
|
||||
./inbox/launch.sh <project-dir> [workspace-slug]
|
||||
# Example: ./inbox/launch.sh apps/phonesite phonesite-main
|
||||
```
|
||||
|
||||
The host Inbox API must already be running. `launch.sh` registers the `project` / `workspace` in SQLite, creates a git worktree, launches the Podman container, and starts `inbox agent` in API mode.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
Runtime ownership is split as:
|
||||
|
||||
- `project`: long-lived source project such as `apps/blog`
|
||||
- `workspace`: per-run git worktree / container instance such as `inbox-worktrees/blog-main`
|
||||
|
||||
Persistent runtime state is stored in SQLite, not in worktree files:
|
||||
|
||||
```text
|
||||
<repo>/.runtime/inbox.db
|
||||
<repo>/inbox-worktrees/<workspace>/
|
||||
/tmp/inbox-codex-*
|
||||
```
|
||||
|
||||
Container agents must use:
|
||||
|
||||
- `INBOX_API_URL`
|
||||
- `INBOX_WORKSPACE_NAME`
|
||||
|
||||
This keeps worktrees code-only and prevents `.runtime` or `.inbox-meta.json` from being written into them.
|
||||
|
||||
## Message Flow
|
||||
|
||||
Messages are authored as Markdown with YAML front matter, but persisted in SQLite:
|
||||
|
||||
1. `inbox send` stores the message body and metadata in SQLite and marks mailbox state
|
||||
2. `inbox agent --role <role>` polls inbox state from SQLite or, in container mode, through the host Inbox API
|
||||
3. Codex output, sessions, chain/task state, and dispatch live logs are written back to SQLite
|
||||
4. Processed messages are archived by mailbox-state update, not by moving files on disk
|
||||
|
||||
## Roles
|
||||
|
||||
Roles are DB-backed runtime configuration, not file-backed prompts.
|
||||
|
||||
- `leader` — host-side orchestrator for user dialogue, chain/task decomposition, and execution control
|
||||
- `worker` — per-chain executor inside isolated worktrees/containers
|
||||
- `user` — human-only participant; never auto-executed by the AI runtime
|
||||
|
||||
Legacy fixed roles such as `product`, `backend`, `frontend`, `reviewer`, and discovery roles are not part of the active V2 execution model.
|
||||
|
||||
## Dashboard
|
||||
|
||||
`dashboard/` is a React 19 + TypeScript + Vite app that polls the Inbox Web API for:
|
||||
|
||||
- messages
|
||||
- role status
|
||||
- executions
|
||||
- merge requests
|
||||
- workflow board state
|
||||
- leader/worker thread activity
|
||||
|
||||
## Skills
|
||||
|
||||
Reusable skill definitions live in `config/codex/skills/`.
|
||||
@@ -0,0 +1,80 @@
|
||||
[中文版](README.zh-CN.md)
|
||||
|
||||
# ai-workflow-v2
|
||||
|
||||
This repository is organized as a small set of independent modules instead of one mixed runtime tree.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Inbox host | `inbox/` | Go control plane: CLI, HTTP API, runtime storage, agent dispatch |
|
||||
| Dashboard | `dashboard/` | React operations UI for the inbox host |
|
||||
| Blog backend | `apps/blog/backend/` | Sample Express API |
|
||||
| Blog frontend | `apps/blog/frontend/` | Sample React client |
|
||||
| Blog E2E | `apps/blog/e2e/` | Playwright black-box tests for the blog app |
|
||||
|
||||
Module boundaries are documented in [docs/modules.md](docs/modules.md).
|
||||
|
||||
## Repository Rules
|
||||
|
||||
- Modules are developed and tested independently.
|
||||
- Cross-module communication should happen through HTTP or CLI boundaries, not direct source imports.
|
||||
- Runtime data, local databases, worktrees, caches, and build artifacts are generated files and are ignored by default.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Use the root `Makefile` as the stable entrypoint:
|
||||
|
||||
```bash
|
||||
make help
|
||||
make build-inbox
|
||||
make test-inbox
|
||||
make test-dashboard
|
||||
make test-blog-backend
|
||||
make test-blog-frontend
|
||||
make test-blog-e2e
|
||||
```
|
||||
|
||||
## Independent Module Usage
|
||||
|
||||
### Inbox host
|
||||
|
||||
```bash
|
||||
cd inbox
|
||||
go build ./cmd/inbox
|
||||
go test ./...
|
||||
```
|
||||
|
||||
More detail: [inbox/README.md](inbox/README.md)
|
||||
|
||||
### Dashboard
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm ci
|
||||
npm run dev
|
||||
npm test
|
||||
```
|
||||
|
||||
More detail: [dashboard/README.md](dashboard/README.md)
|
||||
|
||||
### Blog app
|
||||
|
||||
```bash
|
||||
cd apps/blog/backend && npm ci && npm test
|
||||
cd apps/blog/frontend && npm ci && npm test
|
||||
cd apps/blog/e2e && npm ci && npm test
|
||||
```
|
||||
|
||||
More detail: [apps/blog/README.md](apps/blog/README.md)
|
||||
|
||||
## Testing Matrix
|
||||
|
||||
- Fast checks: `make test-all`
|
||||
- Browser/E2E checks: `make test-all-e2e`
|
||||
- Full dependency install for JS modules: `make deps-all`
|
||||
|
||||
## Goal
|
||||
|
||||
Keep each module independently runnable, independently testable, and clear about what it owns.
|
||||
@@ -0,0 +1,80 @@
|
||||
[English](README.md)
|
||||
|
||||
# ai-workflow-v2
|
||||
|
||||
这个仓库现在按“独立模块”来组织,而不是把运行时、缓存和多套旧代码混在一起。
|
||||
|
||||
## 模块划分
|
||||
|
||||
| 模块 | 路径 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| Inbox host | `inbox/` | Go 控制面:CLI、HTTP API、运行时存储、Agent 调度 |
|
||||
| Dashboard | `dashboard/` | 面向 Inbox host 的 React 运维界面 |
|
||||
| Blog backend | `apps/blog/backend/` | 示例 Express API |
|
||||
| Blog frontend | `apps/blog/frontend/` | 示例 React 客户端 |
|
||||
| Blog E2E | `apps/blog/e2e/` | 面向 blog 应用的 Playwright 黑盒测试 |
|
||||
|
||||
模块边界说明见 [docs/modules.md](docs/modules.md)。
|
||||
|
||||
## 仓库规则
|
||||
|
||||
- 每个模块都应该可以独立开发、独立测试。
|
||||
- 跨模块只通过 HTTP 或 CLI 边界通信,不直接相互引用源码。
|
||||
- 运行时数据、本地数据库、worktree、缓存和构建产物默认都不进仓库。
|
||||
|
||||
## 快速开始
|
||||
|
||||
统一从根目录 `Makefile` 进入:
|
||||
|
||||
```bash
|
||||
make help
|
||||
make build-inbox
|
||||
make test-inbox
|
||||
make test-dashboard
|
||||
make test-blog-backend
|
||||
make test-blog-frontend
|
||||
make test-blog-e2e
|
||||
```
|
||||
|
||||
## 各模块独立使用
|
||||
|
||||
### Inbox host
|
||||
|
||||
```bash
|
||||
cd inbox
|
||||
go build ./cmd/inbox
|
||||
go test ./...
|
||||
```
|
||||
|
||||
更多说明见 [inbox/README.md](inbox/README.md)。
|
||||
|
||||
### Dashboard
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm ci
|
||||
npm run dev
|
||||
npm test
|
||||
```
|
||||
|
||||
更多说明见 [dashboard/README.md](dashboard/README.md)。
|
||||
|
||||
### Blog 应用
|
||||
|
||||
```bash
|
||||
cd apps/blog/backend && npm ci && npm test
|
||||
cd apps/blog/frontend && npm ci && npm test
|
||||
cd apps/blog/e2e && npm ci && npm test
|
||||
```
|
||||
|
||||
更多说明见 [apps/blog/README.md](apps/blog/README.md)。
|
||||
|
||||
## 测试入口
|
||||
|
||||
- 快速测试:`make test-all`
|
||||
- 浏览器 / E2E:`make test-all-e2e`
|
||||
- 一次性安装全部 JS 依赖:`make deps-all`
|
||||
|
||||
## 当前目标
|
||||
|
||||
让每个模块边界清晰、职责单一,并且默认就是“方便使用、方便测试”的状态。
|
||||
@@ -0,0 +1,34 @@
|
||||
# Dashboard Module
|
||||
|
||||
`dashboard/` is the operations UI for the inbox host.
|
||||
|
||||
## Owns
|
||||
|
||||
- Page routing and layout
|
||||
- API client for `/api/*`
|
||||
- Presentation components and hooks
|
||||
- Dashboard-specific Playwright and Vitest suites
|
||||
|
||||
## Dependency Rule
|
||||
|
||||
The dashboard may call inbox HTTP APIs, but it must not import code from `inbox/`.
|
||||
|
||||
## Local Commands
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
npm run build
|
||||
npm test
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make deps-dashboard
|
||||
make dev-dashboard
|
||||
make build-dashboard
|
||||
make test-dashboard
|
||||
make test-dashboard-e2e
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "coverage/**", "test-results/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports", disallowTypeAnnotations: false },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Observe clarifications, workflow, runs, and merge readiness across AI delivery roles."
|
||||
/>
|
||||
<meta name="application-name" content="Delivery Console" />
|
||||
<meta name="apple-mobile-web-app-title" content="Delivery Console" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#120e13" />
|
||||
<meta property="og:title" content="Delivery Console" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Observe clarifications, workflow, runs, and merge readiness across AI delivery roles."
|
||||
/>
|
||||
<title>Delivery Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6365
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "inbox-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"typescript-eslint": "^8.27.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 4173",
|
||||
url: "http://127.0.0.1:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ElementType } from "react";
|
||||
import { Routes, Route, Navigate, useLocation } from "react-router";
|
||||
import DashboardLayout from "./layouts/DashboardLayout";
|
||||
import WorkflowPrototype from "./components/WorkflowPrototype";
|
||||
import RoleStatus from "./components/RoleStatus";
|
||||
import SkillCatalogManager from "./components/SkillCatalogManager";
|
||||
import ExecutionTable from "./components/ExecutionTable";
|
||||
import {
|
||||
dashboardSections,
|
||||
getWorkspaceFromPathname,
|
||||
isWorkspaceScopedSection,
|
||||
} from "./routes";
|
||||
|
||||
const workspaceSectionComponents = {
|
||||
workflow: WorkflowPrototype,
|
||||
roles: RoleStatus,
|
||||
executions: ExecutionTable,
|
||||
} as const;
|
||||
|
||||
type WorkspaceSectionId = keyof typeof workspaceSectionComponents;
|
||||
type NonWorkflowWorkspaceSectionId = Exclude<WorkspaceSectionId, "workflow">;
|
||||
|
||||
function getWorkspaceSectionComponent(
|
||||
section: WorkspaceSectionId,
|
||||
) {
|
||||
return workspaceSectionComponents[section];
|
||||
}
|
||||
|
||||
function isNonWorkflowWorkspaceSection(
|
||||
section: (typeof dashboardSections)[number],
|
||||
): section is Extract<(typeof dashboardSections)[number], { id: NonWorkflowWorkspaceSectionId }> {
|
||||
return section.id !== "workflow" && section.id !== "skills" && isWorkspaceScopedSection(section.id);
|
||||
}
|
||||
|
||||
function WorkspaceRoute({
|
||||
component: Component,
|
||||
}: {
|
||||
component: ElementType<{ workspace: string }>;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
return <Component workspace={getWorkspaceFromPathname(location.pathname)} />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={dashboardSections[0].path} replace />} />
|
||||
<Route path="/intro" element={<Navigate to={dashboardSections[0].path} replace />} />
|
||||
<Route element={<DashboardLayout />}>
|
||||
{dashboardSections.map((section) => (
|
||||
<Route
|
||||
key={`plain-${section.id}`}
|
||||
path={section.path}
|
||||
element={
|
||||
section.id === "skills"
|
||||
? <SkillCatalogManager />
|
||||
: <WorkspaceRoute component={getWorkspaceSectionComponent(section.id)} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path={`/workspaces/:workspace${dashboardSections[0].path}`}
|
||||
element={<WorkspaceRoute component={WorkflowPrototype} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${dashboardSections[0].path}/:topic`}
|
||||
element={<WorkspaceRoute component={WorkflowPrototype} />}
|
||||
/>
|
||||
<Route
|
||||
path={`/workspaces/:workspace${dashboardSections[0].path}/:topic`}
|
||||
element={<WorkspaceRoute component={WorkflowPrototype} />}
|
||||
/>
|
||||
{dashboardSections
|
||||
.filter(isNonWorkflowWorkspaceSection)
|
||||
.map((section) => (
|
||||
<Route
|
||||
key={`workspace-${section.id}`}
|
||||
path={`/workspaces/:workspace${section.path}`}
|
||||
element={<WorkspaceRoute component={getWorkspaceSectionComponent(section.id)} />}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
answerHumanTask,
|
||||
confirmTopicPlan,
|
||||
fetchRoleSkills,
|
||||
fetchRoles,
|
||||
fetchWorkflowBoard,
|
||||
fetchWorkspaces,
|
||||
sendMessage,
|
||||
stopTopic,
|
||||
} from "./client";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const rawWorkspace = {
|
||||
id: "workspace-1",
|
||||
project_id: "project-1",
|
||||
slug: "alpha",
|
||||
name: "alpha",
|
||||
root_path: "/tmp/alpha",
|
||||
base_branch: "",
|
||||
worktree_branch: "",
|
||||
runtime_backend: "container",
|
||||
status: "active",
|
||||
provision_state: "ready",
|
||||
provision_error: "",
|
||||
last_provisioned_at: "2026-03-13T10:00:00Z",
|
||||
container_state: "running",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("dashboard api client", () => {
|
||||
it("parses successful workspace responses", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
projects: [
|
||||
{
|
||||
id: "project-1",
|
||||
slug: "demo",
|
||||
name: "Demo",
|
||||
root_path: "/tmp/demo",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
workspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
project_id: "project-1",
|
||||
slug: "alpha",
|
||||
name: "alpha",
|
||||
root_path: "/tmp/alpha",
|
||||
base_branch: "",
|
||||
worktree_branch: "",
|
||||
runtime_backend: "container",
|
||||
status: "",
|
||||
provision_state: "ready",
|
||||
provision_error: "",
|
||||
last_provisioned_at: "2026-03-13T10:00:00Z",
|
||||
container_state: "running",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchWorkspaces()).resolves.toEqual([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "alpha",
|
||||
slug: "alpha",
|
||||
project_slug: "demo",
|
||||
project_dir: "/tmp/demo",
|
||||
path: "/tmp/alpha",
|
||||
base_branch: "",
|
||||
worktree_branch: "",
|
||||
runtime_backend: "container",
|
||||
container_name: "",
|
||||
status: "",
|
||||
provision_state: "ready",
|
||||
provision_error: "",
|
||||
last_provisioned_at: "2026-03-13T10:00:00Z",
|
||||
container_state: "running",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps abort errors to a timeout message", async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(fetchWorkspaces()).rejects.toThrow(
|
||||
"Request timed out. Check the local API and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads structured server errors from json responses", async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ message: "Bad topic payload" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
sendMessage({
|
||||
workspace: "alpha",
|
||||
to: "leader",
|
||||
topic: "bad",
|
||||
body: "broken",
|
||||
}),
|
||||
).rejects.toThrow("Bad topic payload");
|
||||
});
|
||||
|
||||
it("surfaces unreadable JSON responses", async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response("{", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchWorkspaces()).rejects.toThrow("Server returned an unreadable response.");
|
||||
});
|
||||
|
||||
it("falls back to a 404 message when the server returns no readable body", async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response("", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchWorkspaces()).rejects.toThrow("Resource not found.");
|
||||
});
|
||||
|
||||
it("maps rate limits to a retryable message", async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchWorkspaces()).rejects.toThrow(
|
||||
"Too many requests. Try again in a moment.",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads global roles without adding a workspace query", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
roles: [
|
||||
{
|
||||
name: "leader",
|
||||
description: "Owns scope.",
|
||||
sort_order: 100,
|
||||
pending: 0,
|
||||
session: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchRoles("")).resolves.toEqual([
|
||||
{
|
||||
name: "leader",
|
||||
description: "Owns scope.",
|
||||
sort_order: 100,
|
||||
pending: 0,
|
||||
session: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/v2/dashboard/roles",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes nullable workflow board arrays", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
topics: [
|
||||
{
|
||||
name: "launch-dashboard",
|
||||
message_count: 2,
|
||||
latest_stage: "execution",
|
||||
latest_time: "2026-03-09T10:10:00Z",
|
||||
running_roles: null,
|
||||
waiting_roles: null,
|
||||
},
|
||||
],
|
||||
active_topic: "launch-dashboard",
|
||||
board: {
|
||||
topic: {
|
||||
name: "launch-dashboard",
|
||||
latest_stage: "execution",
|
||||
message_count: 2,
|
||||
created_at: "2026-03-09T10:00:00Z",
|
||||
updated_at: "2026-03-09T10:10:00Z",
|
||||
status: "execution",
|
||||
},
|
||||
summary: {
|
||||
running_count: 0,
|
||||
waiting_count: 0,
|
||||
active_roles: null,
|
||||
last_event_at: "2026-03-09T10:10:00Z",
|
||||
},
|
||||
agents: null,
|
||||
links: null,
|
||||
events: null,
|
||||
pending_human_tasks: null,
|
||||
},
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchWorkflowBoard("alpha")).resolves.toEqual({
|
||||
topics: [
|
||||
{
|
||||
name: "launch-dashboard",
|
||||
message_count: 2,
|
||||
latest_stage: "execution",
|
||||
latest_time: "2026-03-09T10:10:00Z",
|
||||
status: "",
|
||||
running_roles: [],
|
||||
waiting_roles: [],
|
||||
},
|
||||
],
|
||||
active_topic: "launch-dashboard",
|
||||
board: {
|
||||
topic: {
|
||||
name: "launch-dashboard",
|
||||
latest_stage: "execution",
|
||||
message_count: 2,
|
||||
created_at: "2026-03-09T10:00:00Z",
|
||||
updated_at: "2026-03-09T10:10:00Z",
|
||||
status: "execution",
|
||||
},
|
||||
plan: null,
|
||||
summary: {
|
||||
running_count: 0,
|
||||
waiting_count: 0,
|
||||
active_roles: [],
|
||||
last_event_at: "2026-03-09T10:10:00Z",
|
||||
},
|
||||
agents: [],
|
||||
lanes: [],
|
||||
tasks: [],
|
||||
links: [],
|
||||
events: [],
|
||||
pending_human_tasks: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads role skills through explicit workspace context", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ workspaces: [rawWorkspace] }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
role: {
|
||||
name: "worker",
|
||||
title: "Worker",
|
||||
description: "Handles execution.",
|
||||
is_enabled: true,
|
||||
is_builtin: true,
|
||||
sort_order: 200,
|
||||
},
|
||||
prompts: [],
|
||||
config: {},
|
||||
bindings: [
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
skill_id: "skill_1",
|
||||
is_enabled: true,
|
||||
sort_order: 100,
|
||||
config: { category: "adaptation" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
skills: [
|
||||
{
|
||||
id: "skill_1",
|
||||
skill_key: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
source_type: "other",
|
||||
content_markdown: "---\\nname: adapt\\n---",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchRoleSkills("alpha", "worker")).resolves.toEqual([
|
||||
{
|
||||
id: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
category: "adaptation",
|
||||
enabled: true,
|
||||
missing: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads role skills without workspace context from global config", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
role: {
|
||||
name: "worker",
|
||||
title: "Worker",
|
||||
description: "Handles execution.",
|
||||
is_enabled: true,
|
||||
is_builtin: true,
|
||||
sort_order: 200,
|
||||
},
|
||||
prompts: [],
|
||||
config: {},
|
||||
bindings: [],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
skills: [
|
||||
{
|
||||
id: "skill_1",
|
||||
skill_key: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
source_type: "adaptation",
|
||||
content_markdown: "---\\nname: adapt\\n---",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(fetchRoleSkills("", "worker")).resolves.toEqual([
|
||||
{
|
||||
id: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
category: "adaptation",
|
||||
enabled: false,
|
||||
missing: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the created message id from the send response", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
workspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
project_id: "project-1",
|
||||
slug: "alpha",
|
||||
name: "alpha",
|
||||
root_path: "/tmp/alpha",
|
||||
base_branch: "main",
|
||||
worktree_branch: "worktree/alpha",
|
||||
runtime_backend: "container",
|
||||
status: "active",
|
||||
provision_state: "ready",
|
||||
provision_error: "",
|
||||
last_provisioned_at: "",
|
||||
container_state: "missing",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ topics: [] }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "topic-1",
|
||||
workspace_id: "workspace-1",
|
||||
slug: "topic",
|
||||
title: "topic",
|
||||
space: "workflow",
|
||||
status: "plan",
|
||||
summary: "",
|
||||
created_at: "2026-03-10T09:15:00Z",
|
||||
updated_at: "2026-03-10T09:15:00Z",
|
||||
}, 201),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
status: "delivered",
|
||||
id: "20260310T092000Z-topic.md",
|
||||
}, 201),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
sendMessage({
|
||||
workspace: "alpha",
|
||||
to: "leader",
|
||||
topic: "topic",
|
||||
body: "Need scope.",
|
||||
stage: "plan",
|
||||
type: "chat",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: "delivered",
|
||||
file: "20260310T092000Z-topic.md",
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenLastCalledWith(
|
||||
"/api/v2/topics/topic-1/messages",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
workspace_id: "workspace-1",
|
||||
from_role_name: "user",
|
||||
to_expr: "leader",
|
||||
type: "chat",
|
||||
stage: "plan",
|
||||
reply_to_message_id: undefined,
|
||||
body_markdown: "Need scope.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("posts human task answers to the dedicated host-side endpoint", async () => {
|
||||
globalThis.fetch = vi.fn(async () => jsonResponse({ status: "ok" })) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
answerHumanTask("human-task-1", "Clarify the delivery boundary."),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/v2/human-tasks/human-task-1/answer",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body_markdown: "Clarify the delivery boundary.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops a topic through the topic stop endpoint", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
workspaces: [rawWorkspace],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
topics: [{
|
||||
id: "topic-1",
|
||||
workspace_id: "workspace-1",
|
||||
slug: "topic",
|
||||
title: "topic",
|
||||
space: "workflow",
|
||||
status: "execution",
|
||||
created_at: "2026-03-10T09:15:00Z",
|
||||
updated_at: "2026-03-10T09:15:00Z",
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "topic-1",
|
||||
workspace_id: "workspace-1",
|
||||
slug: "topic",
|
||||
title: "topic",
|
||||
space: "workflow",
|
||||
status: "cancelled",
|
||||
created_at: "2026-03-10T09:15:00Z",
|
||||
updated_at: "2026-03-10T09:20:00Z",
|
||||
closed_at: "2026-03-10T09:20:00Z",
|
||||
}, 202),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(stopTopic("alpha", "topic")).resolves.toBeUndefined();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenLastCalledWith(
|
||||
"/api/v2/topics/topic-1/stop",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("confirms a topic plan through the topic confirm endpoint", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
workspaces: [rawWorkspace],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
topics: [{
|
||||
id: "topic-1",
|
||||
workspace_id: "workspace-1",
|
||||
slug: "topic",
|
||||
title: "topic",
|
||||
space: "workflow",
|
||||
status: "awaiting_confirmation",
|
||||
created_at: "2026-03-10T09:15:00Z",
|
||||
updated_at: "2026-03-10T09:15:00Z",
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "topic-1",
|
||||
workspace_id: "workspace-1",
|
||||
slug: "topic",
|
||||
title: "topic",
|
||||
space: "workflow",
|
||||
status: "execution",
|
||||
created_at: "2026-03-10T09:15:00Z",
|
||||
updated_at: "2026-03-10T09:20:00Z",
|
||||
}, 202),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(confirmTopicPlan("alpha", "topic")).resolves.toBeUndefined();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenLastCalledWith(
|
||||
"/api/v2/topics/topic-1/confirm",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import type {
|
||||
DispatchLog,
|
||||
RoleInfo,
|
||||
WorkflowBoardResponse,
|
||||
Workspace,
|
||||
} from "../types";
|
||||
import {
|
||||
API_PREFIX,
|
||||
fetchJson,
|
||||
sendNoContent,
|
||||
writeJson,
|
||||
} from "./http";
|
||||
import {
|
||||
fetchProjectsRaw,
|
||||
fetchWorkspacesRaw,
|
||||
listTopicsByWorkspaceID,
|
||||
normalizeWorkflowBoardResponse,
|
||||
normalizeWorkspace,
|
||||
resolveTopic,
|
||||
resolveWorkspace,
|
||||
resolveWorkspaceID,
|
||||
type RawTopic,
|
||||
type TopicSpace,
|
||||
} from "./internal";
|
||||
|
||||
export {
|
||||
answerHumanTask,
|
||||
deleteSkill,
|
||||
fetchRoleDetail,
|
||||
fetchRoleSkills,
|
||||
fetchSkills,
|
||||
type RoleSkillOverrideUpdate,
|
||||
type SkillCatalogSaveInput,
|
||||
updateRole,
|
||||
updateRoleSkills,
|
||||
upsertSkill,
|
||||
} from "./roleConfig";
|
||||
|
||||
export async function fetchWorkspaces(): Promise<Workspace[]> {
|
||||
const [projects, workspaces] = await Promise.all([
|
||||
fetchProjectsRaw(),
|
||||
fetchWorkspacesRaw(),
|
||||
]);
|
||||
const projectByID = new Map(projects.map((item) => [item.id, item]));
|
||||
return workspaces.map((item) => normalizeWorkspace(item, projectByID));
|
||||
}
|
||||
|
||||
export async function fetchDispatch(ws: string): Promise<DispatchLog[]> {
|
||||
const data = await fetchJson<{ logs: DispatchLog[] }>(
|
||||
`${API_PREFIX}/dashboard/dispatch?workspace=${encodeURIComponent(ws)}`,
|
||||
);
|
||||
return data.logs ?? [];
|
||||
}
|
||||
|
||||
export async function fetchRoles(ws?: string): Promise<RoleInfo[]> {
|
||||
const q = ws?.trim()
|
||||
? `?workspace=${encodeURIComponent(ws.trim())}`
|
||||
: "";
|
||||
const data = await fetchJson<{ roles: RoleInfo[] }>(
|
||||
`${API_PREFIX}/dashboard/roles${q}`,
|
||||
);
|
||||
return data.roles ?? [];
|
||||
}
|
||||
|
||||
export async function fetchWorkflowBoard(
|
||||
workspace: string,
|
||||
topic?: string,
|
||||
): Promise<WorkflowBoardResponse> {
|
||||
const params = new URLSearchParams({ workspace });
|
||||
if (topic?.trim()) {
|
||||
params.set("topic", topic.trim());
|
||||
}
|
||||
const data = await fetchJson<WorkflowBoardResponse>(
|
||||
`${API_PREFIX}/dashboard/workflow/board?${params.toString()}`,
|
||||
);
|
||||
return normalizeWorkflowBoardResponse(data);
|
||||
}
|
||||
|
||||
export async function sendMessage(params: {
|
||||
workspace: string;
|
||||
to: string;
|
||||
topic: string;
|
||||
body: string;
|
||||
type?: string;
|
||||
stage?: string;
|
||||
reply_to?: string;
|
||||
}): Promise<{ status: string; file: string }> {
|
||||
const workspaceRecord = await resolveWorkspace(params.workspace);
|
||||
const workspaceID = workspaceRecord.id;
|
||||
const space: TopicSpace = "workflow";
|
||||
const topics = await listTopicsByWorkspaceID(workspaceID);
|
||||
let record = topics.find(
|
||||
(item) =>
|
||||
(item.slug === params.topic || item.title === params.topic) &&
|
||||
item.space === space,
|
||||
);
|
||||
if (!record) {
|
||||
const created = await createTopicByWorkspaceID(workspaceID, params.topic, space);
|
||||
if (!created.id?.trim()) {
|
||||
throw new Error(`Topic created without an id: ${params.topic}`);
|
||||
}
|
||||
record = {
|
||||
id: created.id,
|
||||
workspace_id: workspaceID,
|
||||
slug: created.name,
|
||||
title: created.name,
|
||||
space: created.space,
|
||||
status: created.status,
|
||||
summary: created.description,
|
||||
created_at: created.created_at,
|
||||
updated_at: created.updated_at,
|
||||
};
|
||||
}
|
||||
const topicID = record.id;
|
||||
const data = await writeJson<{ id?: string; message_id?: string; status?: string }>(
|
||||
`${API_PREFIX}/topics/${encodeURIComponent(topicID)}/messages`,
|
||||
"POST",
|
||||
{
|
||||
workspace_id: workspaceID,
|
||||
from_role_name: "user",
|
||||
to_expr: params.to,
|
||||
type: params.type ?? "chat",
|
||||
stage: params.stage ?? "plan",
|
||||
reply_to_message_id: params.reply_to,
|
||||
body_markdown: params.body.trim(),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
status: data.status ?? "delivered",
|
||||
file: data.id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export interface TopicRecord {
|
||||
id?: string;
|
||||
name: string;
|
||||
space: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function normalizeTopicRecord(topic: RawTopic): TopicRecord {
|
||||
return {
|
||||
id: topic.id,
|
||||
name: topic.slug,
|
||||
space: topic.space,
|
||||
status: topic.status,
|
||||
created_at: topic.created_at,
|
||||
updated_at: topic.updated_at,
|
||||
description: topic.summary,
|
||||
};
|
||||
}
|
||||
async function createTopicByWorkspaceID(
|
||||
workspaceID: string,
|
||||
topic: string,
|
||||
space: TopicSpace,
|
||||
): Promise<TopicRecord> {
|
||||
const created = await writeJson<RawTopic>(`${API_PREFIX}/topics`, "POST", {
|
||||
workspace_id: workspaceID,
|
||||
slug: topic,
|
||||
title: topic,
|
||||
space,
|
||||
status: "plan",
|
||||
summary: "",
|
||||
});
|
||||
return normalizeTopicRecord(created);
|
||||
}
|
||||
|
||||
export async function createTopic(
|
||||
workspace: string,
|
||||
topic: string,
|
||||
space: TopicSpace,
|
||||
): Promise<TopicRecord> {
|
||||
const workspaceID = await resolveWorkspaceID(workspace);
|
||||
const existingTopics = await listTopicsByWorkspaceID(workspaceID);
|
||||
const existing = existingTopics.find(
|
||||
(item) => (item.slug === topic || item.title === topic) && item.space === space,
|
||||
);
|
||||
if (existing) {
|
||||
return normalizeTopicRecord(existing);
|
||||
}
|
||||
return createTopicByWorkspaceID(workspaceID, topic, space);
|
||||
}
|
||||
|
||||
export async function fetchTopicRecords(
|
||||
workspace: string,
|
||||
space?: TopicSpace,
|
||||
): Promise<TopicRecord[]> {
|
||||
const q = new URLSearchParams({ workspace });
|
||||
if (space) q.set("space", space);
|
||||
const data = await fetchJson<{ records: TopicRecord[] }>(
|
||||
`${API_PREFIX}/dashboard/topics/records?${q.toString()}`,
|
||||
);
|
||||
return data.records ?? [];
|
||||
}
|
||||
|
||||
export async function deleteTopic(workspace: string, topic: string): Promise<void> {
|
||||
const record = await resolveTopic(workspace, topic);
|
||||
await sendNoContent(
|
||||
`${API_PREFIX}/topics/${encodeURIComponent(record.id)}`,
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopTopic(workspace: string, topic: string): Promise<void> {
|
||||
const record = await resolveTopic(workspace, topic);
|
||||
await writeJson(
|
||||
`${API_PREFIX}/topics/${encodeURIComponent(record.id)}/stop`,
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
|
||||
export async function confirmTopicPlan(workspace: string, topic: string): Promise<void> {
|
||||
const record = await resolveTopic(workspace, topic);
|
||||
await writeJson(
|
||||
`${API_PREFIX}/topics/${encodeURIComponent(record.id)}/confirm`,
|
||||
"POST",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const API_PREFIX = "/api/v2";
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
function normalizeRequestError(error: unknown): Error {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return new Error("Request timed out. Check the local API and try again.");
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
return new Error(
|
||||
"Network request failed. Check the local API or your connection.",
|
||||
);
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error("Request failed.");
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
throw normalizeRequestError(error);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson<T>(res: Response): Promise<T> {
|
||||
try {
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
throw new Error("Server returned an unreadable response.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function readErrorMessage(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
try {
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { error?: string; message?: string };
|
||||
if (data.error?.trim()) return data.error;
|
||||
if (data.message?.trim()) return data.message;
|
||||
} else {
|
||||
const text = (await res.text()).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to status fallback.
|
||||
}
|
||||
|
||||
if (res.status === 404) return "Resource not found.";
|
||||
if (res.status === 429) return "Too many requests. Try again in a moment.";
|
||||
if (res.status >= 500) return "Server error. Try again in a moment.";
|
||||
|
||||
return `${res.status} ${res.statusText}`.trim() || "Request failed.";
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
return readJson<T>(res);
|
||||
}
|
||||
|
||||
export async function writeJson<T>(
|
||||
url: string,
|
||||
method: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithTimeout(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
return readJson<T>(res);
|
||||
}
|
||||
|
||||
export async function sendNoContent(url: string, method: string): Promise<void> {
|
||||
const res = await fetchWithTimeout(url, { method });
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { WorkflowBoardResponse, Workspace } from "../types";
|
||||
import { API_PREFIX, fetchJson } from "./http";
|
||||
|
||||
export interface RawProject {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
root_path: string;
|
||||
}
|
||||
|
||||
export interface RawWorkspace {
|
||||
id: string;
|
||||
project_id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
root_path: string;
|
||||
base_branch: string;
|
||||
worktree_branch: string;
|
||||
runtime_backend: string;
|
||||
status: string;
|
||||
provision_state: string;
|
||||
provision_error: string;
|
||||
last_provisioned_at?: string | null;
|
||||
container_state?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RawTopic {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
space: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type TopicSpace = "workflow";
|
||||
|
||||
export interface RawRoleDefinition {
|
||||
name: string;
|
||||
title: string;
|
||||
executor_kind?: string;
|
||||
description: string;
|
||||
is_enabled: boolean;
|
||||
is_builtin: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface RawRolePrompt {
|
||||
role_name: string;
|
||||
workspace_id?: string;
|
||||
prompt_kind: string;
|
||||
content_markdown: string;
|
||||
}
|
||||
|
||||
export interface RawRoleConfig {
|
||||
role_name?: string;
|
||||
config_toml?: string;
|
||||
auth_json?: string;
|
||||
}
|
||||
|
||||
export interface RawRoleSkillBinding {
|
||||
workspace_id?: string;
|
||||
skill_id: string;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RawSkill {
|
||||
id: string;
|
||||
skill_key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source_type: string;
|
||||
content_markdown: string;
|
||||
status: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface RawRoleDetailResponse {
|
||||
role: RawRoleDefinition;
|
||||
prompts: RawRolePrompt[];
|
||||
config: RawRoleConfig;
|
||||
bindings: RawRoleSkillBinding[];
|
||||
}
|
||||
|
||||
export interface RawResolvedRole {
|
||||
config: RawRoleConfig;
|
||||
skills: Array<{
|
||||
binding: RawRoleSkillBinding;
|
||||
skill: RawSkill;
|
||||
}>;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
export function normalizeWorkflowBoardResponse(
|
||||
value: WorkflowBoardResponse,
|
||||
): WorkflowBoardResponse {
|
||||
const topics = Array.isArray(value.topics)
|
||||
? value.topics.map((topic) => ({
|
||||
...topic,
|
||||
status: topic.status ?? "",
|
||||
running_roles: normalizeStringArray(topic.running_roles),
|
||||
waiting_roles: normalizeStringArray(topic.waiting_roles),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const board = value.board
|
||||
? {
|
||||
...value.board,
|
||||
plan: value.board.plan
|
||||
? {
|
||||
...value.board.plan,
|
||||
summary_markdown: value.board.plan.summary_markdown ?? "",
|
||||
}
|
||||
: null,
|
||||
summary: {
|
||||
...value.board.summary,
|
||||
active_roles: normalizeStringArray(value.board.summary?.active_roles),
|
||||
},
|
||||
agents: Array.isArray(value.board.agents) ? value.board.agents : [],
|
||||
lanes: Array.isArray(value.board.lanes) ? value.board.lanes : [],
|
||||
tasks: Array.isArray(value.board.tasks)
|
||||
? value.board.tasks.map((task) => ({
|
||||
...task,
|
||||
lane_id: task.lane_id ?? "",
|
||||
deliverables: Array.isArray(task.deliverables)
|
||||
? task.deliverables.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
dependencies: Array.isArray(task.dependencies)
|
||||
? task.dependencies
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
links: Array.isArray(value.board.links) ? value.board.links : [],
|
||||
events: Array.isArray(value.board.events) ? value.board.events : [],
|
||||
pending_human_tasks: Array.isArray(value.board.pending_human_tasks)
|
||||
? value.board.pending_human_tasks
|
||||
: [],
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
...value,
|
||||
topics,
|
||||
active_topic: value.active_topic ?? null,
|
||||
board,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceRecord(
|
||||
workspace: RawWorkspace,
|
||||
project?: RawProject,
|
||||
): Workspace {
|
||||
const slug = workspace.slug || workspace.name || "";
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: slug,
|
||||
slug,
|
||||
project_slug: project?.slug ?? "",
|
||||
project_dir: project?.root_path ?? "",
|
||||
path: workspace.root_path ?? "",
|
||||
base_branch: workspace.base_branch ?? "",
|
||||
worktree_branch: workspace.worktree_branch ?? "",
|
||||
runtime_backend: workspace.runtime_backend ?? "",
|
||||
container_name: "",
|
||||
status: workspace.status ?? "",
|
||||
provision_state: workspace.provision_state ?? "pending",
|
||||
provision_error: workspace.provision_error ?? "",
|
||||
last_provisioned_at: workspace.last_provisioned_at ?? "",
|
||||
container_state: workspace.container_state ?? "missing",
|
||||
created_at: workspace.created_at ?? "",
|
||||
updated_at: workspace.updated_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkspace(
|
||||
workspace: RawWorkspace,
|
||||
projectByID: Map<string, RawProject>,
|
||||
): Workspace {
|
||||
return normalizeWorkspaceRecord(
|
||||
workspace,
|
||||
projectByID.get(workspace.project_id),
|
||||
);
|
||||
}
|
||||
|
||||
export function pickPrompt(prompts: RawRolePrompt[], workspaceID: string): string {
|
||||
const systemWorkspace = prompts.find(
|
||||
(prompt) =>
|
||||
prompt.prompt_kind === "system" && prompt.workspace_id === workspaceID,
|
||||
);
|
||||
if (systemWorkspace) return systemWorkspace.content_markdown;
|
||||
|
||||
const systemGlobal = prompts.find(
|
||||
(prompt) => prompt.prompt_kind === "system" && !prompt.workspace_id,
|
||||
);
|
||||
if (systemGlobal) return systemGlobal.content_markdown;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function selectBinding(
|
||||
bindings: RawRoleSkillBinding[],
|
||||
workspaceID: string,
|
||||
skillID: string,
|
||||
): RawRoleSkillBinding | undefined {
|
||||
return (
|
||||
bindings.find(
|
||||
(binding) =>
|
||||
binding.skill_id === skillID && binding.workspace_id === workspaceID,
|
||||
) ??
|
||||
bindings.find(
|
||||
(binding) => binding.skill_id === skillID && !binding.workspace_id,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProjectsRaw(): Promise<RawProject[]> {
|
||||
const data = await fetchJson<{ projects: RawProject[] }>(
|
||||
`${API_PREFIX}/projects`,
|
||||
);
|
||||
return data.projects ?? [];
|
||||
}
|
||||
|
||||
export async function fetchWorkspacesRaw(): Promise<RawWorkspace[]> {
|
||||
const data = await fetchJson<{ workspaces: RawWorkspace[] }>(
|
||||
`${API_PREFIX}/workspaces`,
|
||||
);
|
||||
return data.workspaces ?? [];
|
||||
}
|
||||
|
||||
export async function resolveWorkspace(workspace: string): Promise<RawWorkspace> {
|
||||
const items = await fetchWorkspacesRaw();
|
||||
const resolved =
|
||||
items.find((item) => item.slug === workspace) ??
|
||||
items.find((item) => item.name === workspace);
|
||||
if (!resolved) {
|
||||
throw new Error(`Workspace not found: ${workspace}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceID(workspace: string): Promise<string> {
|
||||
const resolved = await resolveWorkspace(workspace);
|
||||
return resolved.id;
|
||||
}
|
||||
|
||||
export async function listTopicsByWorkspaceID(workspaceID: string): Promise<RawTopic[]> {
|
||||
const data = await fetchJson<{ topics: RawTopic[] }>(
|
||||
`${API_PREFIX}/topics?workspace_id=${encodeURIComponent(workspaceID)}`,
|
||||
);
|
||||
return data.topics ?? [];
|
||||
}
|
||||
|
||||
export async function resolveTopic(
|
||||
workspace: string,
|
||||
topic: string,
|
||||
spaces?: string[],
|
||||
): Promise<RawTopic> {
|
||||
const workspaceID = await resolveWorkspaceID(workspace);
|
||||
const items = await listTopicsByWorkspaceID(workspaceID);
|
||||
const resolved = items.find(
|
||||
(item) =>
|
||||
(item.slug === topic || item.title === topic) &&
|
||||
(!spaces || spaces.includes(item.space)),
|
||||
);
|
||||
if (!resolved) {
|
||||
throw new Error(`Topic not found: ${topic}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function fetchRoleDetailResponse(
|
||||
roleName: string,
|
||||
workspaceID?: string,
|
||||
): Promise<RawRoleDetailResponse> {
|
||||
const q = workspaceID
|
||||
? `?workspace_id=${encodeURIComponent(workspaceID)}`
|
||||
: "";
|
||||
return fetchJson<RawRoleDetailResponse>(
|
||||
`${API_PREFIX}/config/roles/${encodeURIComponent(roleName)}${q}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchResolvedRole(
|
||||
roleName: string,
|
||||
workspaceID?: string,
|
||||
): Promise<RawResolvedRole> {
|
||||
const q = workspaceID
|
||||
? `?workspace_id=${encodeURIComponent(workspaceID)}`
|
||||
: "";
|
||||
return fetchJson<RawResolvedRole>(
|
||||
`${API_PREFIX}/runtime/roles/${encodeURIComponent(roleName)}${q}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSkillsRaw(): Promise<RawSkill[]> {
|
||||
const data = await fetchJson<{ skills: RawSkill[] }>(
|
||||
`${API_PREFIX}/config/skills`,
|
||||
);
|
||||
return data.skills ?? [];
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { RoleDetail, RoleSkill, SkillCatalogEntry } from "../types";
|
||||
import { extractSkillMetadata } from "../utils/skillMetadata";
|
||||
import {
|
||||
fetchRoleDetailResponse,
|
||||
fetchSkillsRaw,
|
||||
normalizeKey,
|
||||
pickPrompt,
|
||||
resolveWorkspaceID,
|
||||
selectBinding,
|
||||
} from "./internal";
|
||||
import { API_PREFIX, sendNoContent, writeJson } from "./http";
|
||||
|
||||
export async function fetchRoleDetail(
|
||||
ws: string,
|
||||
name: string,
|
||||
): Promise<RoleDetail> {
|
||||
const workspaceID = ws ? await resolveWorkspaceID(ws) : "";
|
||||
const data = await fetchRoleDetailResponse(name, workspaceID);
|
||||
return {
|
||||
name: data.role.name,
|
||||
description: data.role.description,
|
||||
prompt: pickPrompt(data.prompts ?? [], workspaceID),
|
||||
sort_order: data.role.sort_order,
|
||||
config_toml: data.config?.config_toml ?? "",
|
||||
auth_json: data.config?.auth_json ?? "{}",
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
ws: string,
|
||||
name: string,
|
||||
updates: {
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
sort_order?: number;
|
||||
config_toml?: string;
|
||||
auth_json?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const workspaceID = ws ? await resolveWorkspaceID(ws) : "";
|
||||
const current = await fetchRoleDetailResponse(name, workspaceID);
|
||||
|
||||
await writeJson(
|
||||
`${API_PREFIX}/config/roles/${encodeURIComponent(name)}`,
|
||||
"PUT",
|
||||
{
|
||||
title: current.role.title,
|
||||
executor_kind: current.role.executor_kind,
|
||||
description: updates.description ?? current.role.description,
|
||||
is_enabled: current.role.is_enabled,
|
||||
is_builtin: current.role.is_builtin,
|
||||
sort_order: updates.sort_order ?? current.role.sort_order,
|
||||
updated_by: "dashboard",
|
||||
},
|
||||
);
|
||||
|
||||
if (updates.prompt !== undefined) {
|
||||
await writeJson(
|
||||
`${API_PREFIX}/config/roles/${encodeURIComponent(name)}/prompts/system`,
|
||||
"PUT",
|
||||
{
|
||||
workspace_id: workspaceID || undefined,
|
||||
content_markdown: updates.prompt,
|
||||
updated_by: "dashboard",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (updates.config_toml !== undefined || updates.auth_json !== undefined) {
|
||||
await writeJson(
|
||||
`${API_PREFIX}/config/roles/${encodeURIComponent(name)}/config`,
|
||||
"PUT",
|
||||
{
|
||||
config_toml: updates.config_toml ?? current.config?.config_toml ?? "",
|
||||
auth_json: updates.auth_json ?? current.config?.auth_json ?? "{}",
|
||||
updated_by: "dashboard",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function answerHumanTask(
|
||||
taskID: string,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
await writeJson(
|
||||
`${API_PREFIX}/human-tasks/${encodeURIComponent(taskID)}/answer`,
|
||||
"POST",
|
||||
{ body_markdown: body.trim() },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchRoleSkills(
|
||||
workspace: string,
|
||||
roleName: string,
|
||||
): Promise<RoleSkill[]> {
|
||||
const workspaceID = workspace ? await resolveWorkspaceID(workspace) : "";
|
||||
const [detail, skills] = await Promise.all([
|
||||
fetchRoleDetailResponse(roleName, workspaceID || undefined),
|
||||
fetchSkillsRaw(),
|
||||
]);
|
||||
|
||||
const skillByID = new Map(skills.map((skill) => [skill.id, skill]));
|
||||
const results: RoleSkill[] = skills.map((skill) => {
|
||||
const binding = selectBinding(detail.bindings ?? [], workspaceID, skill.id);
|
||||
return {
|
||||
id: skill.skill_key,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category:
|
||||
(typeof binding?.config?.category === "string"
|
||||
? binding.config.category
|
||||
: skill.source_type) || "other",
|
||||
enabled: binding?.is_enabled ?? false,
|
||||
missing: false,
|
||||
};
|
||||
});
|
||||
|
||||
for (const binding of detail.bindings ?? []) {
|
||||
if (skillByID.has(binding.skill_id)) continue;
|
||||
results.push({
|
||||
id: binding.skill_id,
|
||||
name: binding.skill_id,
|
||||
description: "",
|
||||
category:
|
||||
(typeof binding.config?.category === "string"
|
||||
? binding.config.category
|
||||
: "other"),
|
||||
enabled: binding.is_enabled,
|
||||
missing: true,
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export interface RoleSkillOverrideUpdate {
|
||||
id: string;
|
||||
category: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export async function updateRoleSkills(
|
||||
workspace: string,
|
||||
roleName: string,
|
||||
selectedIds: string[],
|
||||
skillOverrides: RoleSkillOverrideUpdate[] = [],
|
||||
): Promise<void> {
|
||||
const workspaceID = workspace ? await resolveWorkspaceID(workspace) : "";
|
||||
const [detail, skills] = await Promise.all([
|
||||
fetchRoleDetailResponse(roleName, workspaceID || undefined),
|
||||
fetchSkillsRaw(),
|
||||
]);
|
||||
|
||||
const selected = new Set(selectedIds);
|
||||
const overrides = new Map(skillOverrides.map((item) => [item.id, item]));
|
||||
|
||||
await Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const binding = selectBinding(detail.bindings ?? [], workspaceID, skill.id);
|
||||
const override = overrides.get(skill.skill_key);
|
||||
const category =
|
||||
override?.category?.trim() ||
|
||||
(typeof binding?.config?.category === "string"
|
||||
? binding.config.category
|
||||
: skill.source_type || "other");
|
||||
|
||||
await writeJson(
|
||||
`${API_PREFIX}/config/roles/${encodeURIComponent(roleName)}/skills/${encodeURIComponent(skill.skill_key)}`,
|
||||
"PUT",
|
||||
{
|
||||
workspace_id: workspaceID || undefined,
|
||||
is_enabled: selected.has(skill.skill_key),
|
||||
sort_order: override?.sort_order ?? binding?.sort_order ?? 1000,
|
||||
config: { category },
|
||||
updated_by: "dashboard",
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSkills(): Promise<SkillCatalogEntry[]> {
|
||||
const skills = await fetchSkillsRaw();
|
||||
return skills.map((skill) => ({
|
||||
id: skill.skill_key,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: skill.source_type || "other",
|
||||
sort_order: 1000,
|
||||
content: skill.content_markdown,
|
||||
updated_at: skill.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SkillCatalogSaveInput {
|
||||
id?: string;
|
||||
category: string;
|
||||
sort_order: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function upsertSkill(
|
||||
skill: SkillCatalogSaveInput,
|
||||
): Promise<SkillCatalogEntry> {
|
||||
const metadata = extractSkillMetadata(skill.content);
|
||||
const skillKey =
|
||||
skill.id?.trim() ||
|
||||
normalizeKey(metadata.name) ||
|
||||
normalizeKey(skill.category) ||
|
||||
`skill-${Date.now()}`;
|
||||
|
||||
const saved = await writeJson<{
|
||||
skill_key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source_type: string;
|
||||
content_markdown: string;
|
||||
updated_at?: string;
|
||||
}>(
|
||||
`${API_PREFIX}/config/skills/${encodeURIComponent(skillKey)}`,
|
||||
"PUT",
|
||||
{
|
||||
name: metadata.name || skillKey,
|
||||
description: metadata.description || "",
|
||||
source_type: skill.category.trim() || "other",
|
||||
content_markdown: skill.content,
|
||||
status: "active",
|
||||
updated_by: "dashboard",
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: saved.skill_key,
|
||||
name: saved.name,
|
||||
description: saved.description,
|
||||
category: saved.source_type || "other",
|
||||
sort_order: skill.sort_order || 1000,
|
||||
content: saved.content_markdown,
|
||||
updated_at: saved.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
await sendNoContent(
|
||||
`${API_PREFIX}/config/skills/${encodeURIComponent(id)}`,
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DispatchLog } from "../types";
|
||||
import { usePolling } from "../hooks/usePolling";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import ExecutionTable from "./ExecutionTable";
|
||||
|
||||
vi.mock("../hooks/usePolling", () => ({
|
||||
usePolling: vi.fn(),
|
||||
}));
|
||||
|
||||
const usePollingMock = vi.mocked(usePolling);
|
||||
|
||||
function mockPollingState(overrides: Partial<ReturnType<typeof usePolling>> = {}) {
|
||||
const refresh = vi.fn();
|
||||
usePollingMock.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh,
|
||||
...overrides,
|
||||
} as ReturnType<typeof usePolling>);
|
||||
|
||||
return refresh;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeLog(overrides: Partial<DispatchLog> = {}): DispatchLog {
|
||||
return {
|
||||
role: "worker",
|
||||
inbox_file: "20260309T090000Z-old.md",
|
||||
stage: "execution",
|
||||
topic: "topic-0",
|
||||
mode: "exec",
|
||||
started_at: "2026-03-09T09:00:00Z",
|
||||
completed_at: "2026-03-09T09:01:30Z",
|
||||
exit_code: 0,
|
||||
reply: "",
|
||||
error_message: "",
|
||||
running: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ExecutionTable", () => {
|
||||
it("prompts for a workspace before loading runs", () => {
|
||||
mockPollingState();
|
||||
|
||||
renderWithProviders(<ExecutionTable workspace="" />, { locale: "en" });
|
||||
|
||||
expect(screen.getByText("Select a project")).toBeInTheDocument();
|
||||
expect(screen.getByText(/load run history/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prioritizes running and failed runs before completed runs", () => {
|
||||
const completed = makeLog({
|
||||
topic: "completed-topic",
|
||||
started_at: "2026-03-09T11:15:00Z",
|
||||
});
|
||||
const failed = makeLog({
|
||||
topic: "failed-topic",
|
||||
started_at: "2026-03-09T10:15:00Z",
|
||||
exit_code: 1,
|
||||
error_message: "failed",
|
||||
});
|
||||
const running = makeLog({
|
||||
topic: "running-topic",
|
||||
started_at: "2026-03-09T09:15:00Z",
|
||||
mode: "resume",
|
||||
completed_at: "",
|
||||
running: true,
|
||||
});
|
||||
|
||||
mockPollingState({ data: [completed, failed, running] });
|
||||
|
||||
const { container } = renderWithProviders(<ExecutionTable workspace="alpha" />, {
|
||||
locale: "en",
|
||||
});
|
||||
const rows = Array.from(
|
||||
container.querySelectorAll("tbody > tr"),
|
||||
) as HTMLTableRowElement[];
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(within(rows[0]!).getByText("running-topic")).toBeInTheDocument();
|
||||
expect(within(rows[0]!).getByText("Continue thread")).toBeInTheDocument();
|
||||
expect(within(rows[0]!).getByText("Running")).toBeInTheDocument();
|
||||
expect(within(rows[1]!).getByText("failed-topic")).toBeInTheDocument();
|
||||
expect(within(rows[2]!).getByText("completed-topic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("paginates desktop rows and loads older runs on mobile", async () => {
|
||||
const user = userEvent.setup();
|
||||
const logs = Array.from({ length: 27 }, (_, index) => makeLog({
|
||||
inbox_file: `20260309T09${String(index).padStart(2, "0")}00Z-${index}.md`,
|
||||
topic: `topic-${index}`,
|
||||
started_at: `2026-03-09T${String(index % 24).padStart(2, "0")}:00:00Z`,
|
||||
}));
|
||||
mockPollingState({ data: logs });
|
||||
|
||||
const { container } = renderWithProviders(<ExecutionTable workspace="alpha" />, {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(screen.getByText("Showing 8 of 27 runs.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("tbody > tr")).toHaveLength(25);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Load older runs" }));
|
||||
|
||||
expect(screen.getByText("Showing 16 of 27 runs.")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
|
||||
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 26-27 of 27 runs.")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("tbody > tr")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps run notes collapsed until explicitly expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockPollingState({
|
||||
data: [
|
||||
makeLog({
|
||||
topic: "noted-run",
|
||||
reply: "Long diagnostic note for this run.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderWithProviders(<ExecutionTable workspace="alpha" />, { locale: "en" });
|
||||
|
||||
expect(screen.queryByText("Long diagnostic note for this run.")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByRole("button", { name: "Show note" })[0]!);
|
||||
|
||||
expect(
|
||||
screen.getAllByText("Long diagnostic note for this run.").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows a new-runs banner when fresh data arrives while viewing older history", async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialLogs = Array.from({ length: 26 }, (_, index) => makeLog({
|
||||
inbox_file: `20260309T09${String(index).padStart(2, "0")}00Z-${index}.md`,
|
||||
topic: `topic-${index}`,
|
||||
started_at: `2026-03-09T${String(index % 24).padStart(2, "0")}:00:00Z`,
|
||||
}));
|
||||
const refresh = mockPollingState({ data: initialLogs });
|
||||
|
||||
const { rerender } = renderWithProviders(<ExecutionTable workspace="alpha" />, {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
|
||||
|
||||
usePollingMock.mockReturnValue({
|
||||
data: [
|
||||
makeLog({
|
||||
inbox_file: "20260310T120000Z-fresh.md",
|
||||
topic: "fresh-topic",
|
||||
started_at: "2026-03-10T12:00:00Z",
|
||||
running: true,
|
||||
completed_at: "",
|
||||
mode: "resume",
|
||||
}),
|
||||
...initialLogs,
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh,
|
||||
} as ReturnType<typeof usePolling>);
|
||||
|
||||
rerender(<ExecutionTable workspace="alpha" />);
|
||||
|
||||
expect(
|
||||
screen.getByText("New executions arrived while you were reviewing older history."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back to latest" }));
|
||||
|
||||
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("New executions arrived while you were reviewing older history.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries after a polling error", async () => {
|
||||
const refresh = mockPollingState({
|
||||
error: "dispatch offline",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<ExecutionTable workspace="alpha" />, { locale: "en" });
|
||||
|
||||
expect(screen.getByText("Couldn't load runs")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePolling } from "../hooks/usePolling";
|
||||
import { fetchDispatch } from "../api/client";
|
||||
import type { DispatchLog } from "../types";
|
||||
import ExecutionRunNote from "./executions/ExecutionRunNote";
|
||||
import ExecutionRunSummary from "./executions/ExecutionRunSummary";
|
||||
import AlertBanner from "./ui/AlertBanner";
|
||||
import RoleBadge from "./RoleBadge";
|
||||
import AsyncPageState from "./ui/AsyncPageState";
|
||||
import Button from "./ui/Button";
|
||||
import Card from "./ui/Card";
|
||||
import PageHero from "./ui/PageHero";
|
||||
import PageSectionCard from "./ui/PageSectionCard";
|
||||
import StatusBadge from "./ui/StatusBadge";
|
||||
import SummaryStat from "./ui/SummaryStat";
|
||||
import { useI18n } from "../i18n";
|
||||
import { dispatchListChangeKey } from "../utils/pollingKeys";
|
||||
import { exitCodeTone } from "../styles/tokens";
|
||||
|
||||
interface ExecutionTableProps {
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
const MOBILE_PAGE_SIZE = 8;
|
||||
const DESKTOP_PAGE_SIZE = 25;
|
||||
|
||||
function getRunKey(log: DispatchLog): string {
|
||||
return `${log.role}-${log.inbox_file}-${log.started_at}-${log.completed_at || "running"}`;
|
||||
}
|
||||
|
||||
function getRunPriority(log: DispatchLog): number {
|
||||
if (log.running) return 0;
|
||||
if (log.exit_code !== 0) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function getRunNote(log: DispatchLog): string {
|
||||
return log.error_message?.trim() || log.reply?.trim() || "";
|
||||
}
|
||||
|
||||
function getRunSummaryFields(
|
||||
log: DispatchLog,
|
||||
labels: {
|
||||
topic: string;
|
||||
phase: string;
|
||||
thread: string;
|
||||
duration: string;
|
||||
started: string;
|
||||
fallback: string;
|
||||
},
|
||||
formatStageLabel: (value: string) => string,
|
||||
formatRunModeLabel: (value: string) => string,
|
||||
formatDuration: (startedAt: string, completedAt: string) => string,
|
||||
formatTimestampLabel: (value: string) => string,
|
||||
formatAbsoluteDateTime: (value: string) => string,
|
||||
) {
|
||||
return [
|
||||
{
|
||||
label: labels.topic,
|
||||
value: <span className="app-text-primary">{log.topic || labels.fallback}</span>,
|
||||
},
|
||||
{
|
||||
label: labels.phase,
|
||||
value: formatStageLabel(log.stage),
|
||||
},
|
||||
{
|
||||
label: labels.thread,
|
||||
value: formatRunModeLabel(log.mode),
|
||||
},
|
||||
{
|
||||
label: labels.duration,
|
||||
value: <span className="font-mono">{formatDuration(log.started_at, log.completed_at)}</span>,
|
||||
},
|
||||
{
|
||||
label: labels.started,
|
||||
value: (
|
||||
<span
|
||||
className="app-text-soft font-mono"
|
||||
title={formatAbsoluteDateTime(log.started_at)}
|
||||
>
|
||||
{formatTimestampLabel(log.started_at)}
|
||||
</span>
|
||||
),
|
||||
fullWidth: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function ExecutionTable({ workspace }: ExecutionTableProps) {
|
||||
const {
|
||||
copy,
|
||||
formatAbsoluteDateTime,
|
||||
formatDuration,
|
||||
formatRunModeLabel,
|
||||
formatStageLabel,
|
||||
formatTimestampLabel,
|
||||
} = useI18n();
|
||||
const fetcher = useCallback(() => fetchDispatch(workspace), [workspace]);
|
||||
const { data, loading, error, refresh } = usePolling<DispatchLog[]>(
|
||||
workspace ? fetcher : null,
|
||||
5000,
|
||||
{ getChangeKey: dispatchListChangeKey },
|
||||
);
|
||||
const [mobileVisibleCount, setMobileVisibleCount] = useState(MOBILE_PAGE_SIZE);
|
||||
const [desktopPage, setDesktopPage] = useState(1);
|
||||
const [expandedNotes, setExpandedNotes] = useState<Set<string>>(new Set());
|
||||
const [hasHiddenNewRuns, setHasHiddenNewRuns] = useState(false);
|
||||
const latestSeenKeyRef = useRef<string | null>(null);
|
||||
const latestAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((a, b) => {
|
||||
const priorityDifference = getRunPriority(a) - getRunPriority(b);
|
||||
if (priorityDifference !== 0) return priorityDifference;
|
||||
return b.started_at.localeCompare(a.started_at);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / DESKTOP_PAGE_SIZE));
|
||||
const currentDesktopPage = Math.min(desktopPage, totalPages);
|
||||
const desktopStartIndex = (currentDesktopPage - 1) * DESKTOP_PAGE_SIZE;
|
||||
const desktopVisible = sorted.slice(
|
||||
desktopStartIndex,
|
||||
desktopStartIndex + DESKTOP_PAGE_SIZE,
|
||||
);
|
||||
const mobileVisible = sorted.slice(0, mobileVisibleCount);
|
||||
const mobileWindowEnd = mobileVisible.length;
|
||||
const desktopWindowStart = sorted.length === 0 ? 0 : desktopStartIndex + 1;
|
||||
const desktopWindowEnd = desktopStartIndex + desktopVisible.length;
|
||||
const latestRunKey = sorted[0] ? getRunKey(sorted[0]) : null;
|
||||
const isViewingLatest = desktopPage === 1 && mobileVisibleCount === MOBILE_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setMobileVisibleCount(MOBILE_PAGE_SIZE);
|
||||
setDesktopPage(1);
|
||||
setExpandedNotes(new Set());
|
||||
setHasHiddenNewRuns(false);
|
||||
latestSeenKeyRef.current = null;
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopPage((current) => Math.min(current, totalPages));
|
||||
setMobileVisibleCount((current) => {
|
||||
if (sorted.length === 0) return MOBILE_PAGE_SIZE;
|
||||
return Math.min(Math.max(current, MOBILE_PAGE_SIZE), sorted.length);
|
||||
});
|
||||
}, [sorted.length, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestRunKey) {
|
||||
latestSeenKeyRef.current = null;
|
||||
setHasHiddenNewRuns(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isViewingLatest) {
|
||||
latestSeenKeyRef.current = latestRunKey;
|
||||
setHasHiddenNewRuns(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestSeenKeyRef.current === null) {
|
||||
latestSeenKeyRef.current = latestRunKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestSeenKeyRef.current !== latestRunKey) {
|
||||
setHasHiddenNewRuns(true);
|
||||
}
|
||||
}, [isViewingLatest, latestRunKey]);
|
||||
|
||||
const toggleNote = useCallback((key: string) => {
|
||||
setExpandedNotes((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const jumpToLatest = useCallback(() => {
|
||||
setDesktopPage(1);
|
||||
setMobileVisibleCount(MOBILE_PAGE_SIZE);
|
||||
setHasHiddenNewRuns(false);
|
||||
if (latestRunKey) {
|
||||
latestSeenKeyRef.current = latestRunKey;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
latestAnchorRef.current?.scrollIntoView?.({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}, [latestRunKey]);
|
||||
|
||||
const runningCount = sorted.filter((log) => log.running).length;
|
||||
const failedCount = sorted.filter((log) => !log.running && log.exit_code !== 0).length;
|
||||
const resumedCount = sorted.filter((log) => log.mode === "resume").length;
|
||||
|
||||
return (
|
||||
<AsyncPageState
|
||||
workspace={workspace}
|
||||
workspaceSubject={copy.executions.workspaceSubject}
|
||||
loading={loading}
|
||||
hasData={sorted.length > 0}
|
||||
error={error}
|
||||
loadingEyebrow={copy.executions.eyebrow}
|
||||
loadingTitle={copy.executions.loadingTitle}
|
||||
errorEyebrow={copy.executions.eyebrow}
|
||||
errorTitle={copy.executions.errorTitle}
|
||||
emptyEyebrow={copy.executions.eyebrow}
|
||||
emptyTitle={copy.executions.emptyTitle}
|
||||
emptyDetail={copy.executions.emptyDetail}
|
||||
retryLabel={copy.common.retry}
|
||||
onRetry={refresh}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div ref={latestAnchorRef} />
|
||||
<PageHero
|
||||
eyebrow={copy.executions.eyebrow}
|
||||
title={copy.executions.heroTitle}
|
||||
description={copy.executions.heroDetail}
|
||||
stats={
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:min-w-[24rem]">
|
||||
<SummaryStat
|
||||
label={copy.executions.summary.running}
|
||||
value={runningCount}
|
||||
detail={copy.executions.summary.runningDetail}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.executions.summary.resumed}
|
||||
value={resumedCount}
|
||||
detail={copy.executions.summary.resumedDetail}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.executions.summary.failed}
|
||||
value={failedCount}
|
||||
detail={copy.executions.summary.failedDetail}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{hasHiddenNewRuns ? (
|
||||
<AlertBanner
|
||||
tone="attention"
|
||||
title={copy.executions.newRunsAvailable}
|
||||
detail={<p className="app-text-primary text-sm font-medium">{copy.executions.newRunsDetail}</p>}
|
||||
actionLabel={copy.common.backToLatest}
|
||||
onAction={jumpToLatest}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
{mobileVisible.map((log) => (
|
||||
(() => {
|
||||
const key = getRunKey(log);
|
||||
const note = getRunNote(log);
|
||||
const noteOpen = expandedNotes.has(key);
|
||||
|
||||
return (
|
||||
<Card
|
||||
as="article"
|
||||
key={key}
|
||||
variant="hero"
|
||||
className="relative overflow-hidden rounded-2xl p-4"
|
||||
>
|
||||
<div className="app-accent-rail absolute left-0 top-0 h-full w-1 opacity-70" />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<RoleBadge role={log.role} />
|
||||
<StatusBadge
|
||||
size="sm"
|
||||
toneClassName={log.running ? "app-chip-running" : exitCodeTone(log.exit_code)}
|
||||
className="min-w-7 font-bold"
|
||||
>
|
||||
{log.running ? "…" : log.exit_code}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<ExecutionRunSummary
|
||||
className="mt-3"
|
||||
fields={getRunSummaryFields(
|
||||
log,
|
||||
{
|
||||
topic: copy.executions.topic,
|
||||
phase: copy.executions.phase,
|
||||
thread: copy.executions.thread,
|
||||
duration: copy.executions.duration,
|
||||
started: copy.executions.started,
|
||||
fallback: copy.common.fallback,
|
||||
},
|
||||
formatStageLabel,
|
||||
formatRunModeLabel,
|
||||
formatDuration,
|
||||
formatTimestampLabel,
|
||||
formatAbsoluteDateTime,
|
||||
)}
|
||||
/>
|
||||
{note && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() => toggleNote(key)}
|
||||
variant="soft"
|
||||
size="xs"
|
||||
aria-expanded={noteOpen}
|
||||
aria-controls={`run-note-${key}`}
|
||||
>
|
||||
{noteOpen ? copy.common.hideNote : copy.common.showNote}
|
||||
</Button>
|
||||
{noteOpen && (
|
||||
<ExecutionRunNote
|
||||
id={`run-note-${key}`}
|
||||
className="mt-3 text-sm"
|
||||
note={note}
|
||||
isError={Boolean(log.error_message)}
|
||||
runNoteLabel={copy.common.runNote}
|
||||
replyLabel={copy.common.latestReply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)] px-4 py-3">
|
||||
<p className="app-text-faint text-sm">
|
||||
{copy.executions.mobileShowing(mobileWindowEnd, sorted.length)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mobileVisibleCount > MOBILE_PAGE_SIZE && (
|
||||
<Button
|
||||
onClick={() => setMobileVisibleCount(MOBILE_PAGE_SIZE)}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
>
|
||||
{copy.common.backToLatest}
|
||||
</Button>
|
||||
)}
|
||||
{mobileVisibleCount < sorted.length && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileVisibleCount((current) => Math.min(current + MOBILE_PAGE_SIZE, sorted.length));
|
||||
}}
|
||||
variant="soft"
|
||||
size="xs"
|
||||
>
|
||||
{copy.executions.loadOlderRuns}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageSectionCard
|
||||
eyebrow={copy.executions.recentRuns}
|
||||
title={copy.executions.recentRunsTitle}
|
||||
className="hidden rounded-[26px] md:block"
|
||||
headerClassName="px-5 py-4"
|
||||
bodyClassName=""
|
||||
>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="app-table-head border-b border-[color:var(--app-divider)]">
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.role}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.topic}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.phase}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.thread}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.duration}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.exitCode}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.started}</th>
|
||||
<th className="app-text-soft px-4 py-3 font-medium">{copy.executions.details}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{desktopVisible.map((log) => {
|
||||
const key = getRunKey(log);
|
||||
const note = getRunNote(log);
|
||||
const noteOpen = expandedNotes.has(key);
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<tr className="border-b border-[color:var(--app-divider-soft)] transition-colors hover:bg-[color:var(--app-surface-muted)]">
|
||||
<td className="px-4 py-3">
|
||||
<RoleBadge role={log.role} />
|
||||
</td>
|
||||
<td className="app-text-muted px-4 py-3">
|
||||
<div className="max-w-[20rem] whitespace-normal break-words leading-5" title={log.topic || copy.common.fallback}>
|
||||
{log.topic || copy.common.fallback}
|
||||
</div>
|
||||
</td>
|
||||
<td className="app-text-soft px-4 py-3">{formatStageLabel(log.stage)}</td>
|
||||
<td className="app-text-soft px-4 py-3">{formatRunModeLabel(log.mode)}</td>
|
||||
<td className="app-text-soft px-4 py-3 font-mono text-xs">
|
||||
{formatDuration(log.started_at, log.completed_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
size="xs"
|
||||
toneClassName={log.running ? "app-chip-running" : exitCodeTone(log.exit_code)}
|
||||
className="min-w-6 font-bold"
|
||||
>
|
||||
{log.running ? "…" : log.exit_code}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td
|
||||
className="app-text-faint px-4 py-3 text-xs"
|
||||
title={formatAbsoluteDateTime(log.started_at)}
|
||||
>
|
||||
{formatTimestampLabel(log.started_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{note ? (
|
||||
<Button
|
||||
onClick={() => toggleNote(key)}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-expanded={noteOpen}
|
||||
aria-controls={`run-note-row-${key}`}
|
||||
>
|
||||
{noteOpen ? copy.common.hideNote : copy.common.showNote}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="app-text-faint text-xs">{copy.executions.noDetail}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{note && noteOpen && (
|
||||
<tr
|
||||
id={`run-note-row-${key}`}
|
||||
className="border-b border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)]"
|
||||
>
|
||||
<td colSpan={8} className="px-4 py-4">
|
||||
<ExecutionRunNote
|
||||
note={note}
|
||||
isError={Boolean(log.error_message)}
|
||||
runNoteLabel={copy.common.runNote}
|
||||
replyLabel={copy.common.latestReply}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-[color:var(--app-divider-soft)] px-5 py-4">
|
||||
<p className="app-text-faint text-sm">
|
||||
{copy.executions.desktopShowing(desktopWindowStart, desktopWindowEnd, sorted.length)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setDesktopPage((current) => Math.max(1, current - 1))}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={currentDesktopPage === 1}
|
||||
>
|
||||
{copy.common.previous}
|
||||
</Button>
|
||||
<span className="app-text-soft text-sm">
|
||||
{copy.executions.pageLabel(currentDesktopPage, totalPages)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setDesktopPage((current) => Math.min(totalPages, current + 1))}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={currentDesktopPage === totalPages}
|
||||
>
|
||||
{copy.common.next}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageSectionCard>
|
||||
</div>
|
||||
</AsyncPageState>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useId } from "react";
|
||||
import type { Locale } from "../copy";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export default function LocaleSelect() {
|
||||
const { copy, locale, setLocale } = useI18n();
|
||||
const selectId = useId();
|
||||
const options: Array<{ id: Locale; label: string }> = [
|
||||
{ id: "zh-CN", label: copy.localeToggle.zh },
|
||||
{ id: "en", label: copy.localeToggle.en },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-panel flex min-h-[3.75rem] min-w-[7.5rem] flex-col justify-center rounded-2xl px-4 py-2">
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="app-text-soft text-[11px] font-medium uppercase tracking-[0.16em]"
|
||||
>
|
||||
{copy.localeToggle.label}
|
||||
</label>
|
||||
<select
|
||||
id={selectId}
|
||||
value={locale}
|
||||
onChange={(event) => setLocale(event.target.value as Locale)}
|
||||
className="app-text-primary mt-1 w-full appearance-none bg-transparent text-sm font-medium outline-none"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { roleBadgeTone } from "../styles/tokens";
|
||||
import { useI18n } from "../i18n";
|
||||
import StatusBadge from "./ui/StatusBadge";
|
||||
|
||||
interface RoleBadgeProps {
|
||||
role: string;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export default function RoleBadge({ role, size = "sm" }: RoleBadgeProps) {
|
||||
const { formatRoleLabel } = useI18n();
|
||||
const colors = roleBadgeTone[role] ?? roleBadgeTone.default;
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
size={size}
|
||||
toneClassName={colors}
|
||||
uppercase
|
||||
className={size === "sm" ? "app-caption-medium font-semibold" : "font-semibold"}
|
||||
>
|
||||
{formatRoleLabel(role)}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchRoleDetail,
|
||||
fetchRoleSkills,
|
||||
updateRole,
|
||||
updateRoleSkills,
|
||||
} from "../api/client";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import RoleEditor from "./RoleEditor";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
fetchRoleDetail: vi.fn(),
|
||||
fetchRoleSkills: vi.fn(),
|
||||
updateRole: vi.fn(),
|
||||
updateRoleSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchRoleDetailMock = vi.mocked(fetchRoleDetail);
|
||||
const fetchRoleSkillsMock = vi.mocked(fetchRoleSkills);
|
||||
const updateRoleMock = vi.mocked(updateRole);
|
||||
const updateRoleSkillsMock = vi.mocked(updateRoleSkills);
|
||||
|
||||
function deferredPromise<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function mockLoadedEditor() {
|
||||
fetchRoleDetailMock.mockResolvedValue({
|
||||
name: "worker",
|
||||
description: "Handles APIs",
|
||||
prompt: "You are the worker agent.",
|
||||
sort_order: 200,
|
||||
config_toml: "model = \"gpt-5.4\"",
|
||||
auth_json: "{\"OPENAI_API_KEY\":\"token-1\"}",
|
||||
});
|
||||
fetchRoleSkillsMock.mockResolvedValue([
|
||||
{
|
||||
id: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
category: "adaptation",
|
||||
enabled: true,
|
||||
missing: false,
|
||||
},
|
||||
{
|
||||
id: "animate",
|
||||
name: "animate",
|
||||
description: "Animate interfaces.",
|
||||
category: "visual_design",
|
||||
enabled: false,
|
||||
missing: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function renderRoleEditor(props: Partial<ComponentProps<typeof RoleEditor>> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<RoleEditor
|
||||
workspace="alpha"
|
||||
roleName="worker"
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
{...props}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
return { onClose, onSaved };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RoleEditor", () => {
|
||||
it("loads role data and switches to the skills tab", async () => {
|
||||
mockLoadedEditor();
|
||||
|
||||
renderRoleEditor();
|
||||
|
||||
expect(await screen.findByDisplayValue("Handles APIs")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("You are the worker agent.")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("200")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("model = \"gpt-5.4\"")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("{\"OPENAI_API_KEY\":\"token-1\"}")).toBeInTheDocument();
|
||||
|
||||
await userEvent.setup().click(screen.getByRole("button", { name: "Role Skills" }));
|
||||
|
||||
expect(screen.getByText("Adaptation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Visual Design")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a load error when role detail fails", async () => {
|
||||
fetchRoleDetailMock.mockRejectedValue(new Error("Role detail offline"));
|
||||
fetchRoleSkillsMock.mockResolvedValue([]);
|
||||
|
||||
renderRoleEditor();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Role detail offline");
|
||||
expect(screen.getByRole("button", { name: "Save Role" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a load error when role skills loading fails", async () => {
|
||||
fetchRoleDetailMock.mockResolvedValue({
|
||||
name: "worker",
|
||||
description: "Handles APIs",
|
||||
prompt: "You are the worker agent.",
|
||||
sort_order: 200,
|
||||
config_toml: "model = \"gpt-5.4\"",
|
||||
auth_json: "{\"OPENAI_API_KEY\":\"token-1\"}",
|
||||
});
|
||||
fetchRoleSkillsMock.mockRejectedValue(new Error("404 page not found"));
|
||||
|
||||
renderRoleEditor();
|
||||
|
||||
expect(await screen.findByDisplayValue("Handles APIs")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("404 page not found");
|
||||
});
|
||||
|
||||
it("saves the default role tab and disables the action while saving", async () => {
|
||||
mockLoadedEditor();
|
||||
const saveRequest = deferredPromise<void>();
|
||||
updateRoleMock.mockReturnValue(saveRequest.promise);
|
||||
const user = userEvent.setup();
|
||||
const { onSaved } = renderRoleEditor();
|
||||
|
||||
const descriptionInput = await screen.findByPlaceholderText(
|
||||
/short description of what this role does/i,
|
||||
);
|
||||
const sortOrderInput = screen.getByRole("spinbutton");
|
||||
const promptInput = screen.getByPlaceholderText(
|
||||
/the full system prompt for this agent role/i,
|
||||
);
|
||||
const configInput = screen.getByPlaceholderText(/approval_policy = "never"/i);
|
||||
const authInput = screen.getByPlaceholderText(/OPENAI_API_KEY/i);
|
||||
|
||||
await user.clear(descriptionInput);
|
||||
await user.type(descriptionInput, "Owns the API surface");
|
||||
await user.clear(sortOrderInput);
|
||||
await user.type(sortOrderInput, "240");
|
||||
await user.clear(promptInput);
|
||||
await user.type(promptInput, "You are the new worker agent.");
|
||||
await user.clear(configInput);
|
||||
await user.type(configInput, "model = \"gpt-5.5\"");
|
||||
await user.clear(authInput);
|
||||
fireEvent.change(authInput, { target: { value: "{\"OPENAI_API_KEY\":\"token-2\"}" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save Role" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateRoleMock).toHaveBeenCalledWith("alpha", "worker", {
|
||||
description: "Owns the API surface",
|
||||
prompt: "You are the new worker agent.",
|
||||
sort_order: 240,
|
||||
config_toml: "model = \"gpt-5.5\"",
|
||||
auth_json: "{\"OPENAI_API_KEY\":\"token-2\"}",
|
||||
});
|
||||
});
|
||||
|
||||
const savingButton = screen.getByRole("button", { name: "Saving..." });
|
||||
expect(savingButton).toBeDisabled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
|
||||
saveRequest.resolve();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses global role data when no workspace is selected", async () => {
|
||||
mockLoadedEditor();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderRoleEditor({ workspace: "" });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Save Role" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateRoleMock).toHaveBeenCalledWith("", "worker", {
|
||||
description: "Handles APIs",
|
||||
prompt: "You are the worker agent.",
|
||||
sort_order: 200,
|
||||
config_toml: "model = \"gpt-5.4\"",
|
||||
auth_json: "{\"OPENAI_API_KEY\":\"token-1\"}",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("saves role skills from the skills tab", async () => {
|
||||
mockLoadedEditor();
|
||||
updateRoleSkillsMock.mockResolvedValue();
|
||||
const user = userEvent.setup();
|
||||
const { onSaved } = renderRoleEditor();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Role Skills" }));
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /animate/i }));
|
||||
await user.clear(screen.getByLabelText("animate group"));
|
||||
await user.type(screen.getByLabelText("animate group"), "custom_bucket");
|
||||
await user.click(screen.getByRole("button", { name: "Save Skills" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateRoleSkillsMock).toHaveBeenCalledWith("alpha", "worker", ["adapt", "animate"], [
|
||||
{ id: "adapt", category: "adaptation" },
|
||||
{ id: "animate", category: "custom_bucket" },
|
||||
]);
|
||||
});
|
||||
expect(updateRoleMock).not.toHaveBeenCalled();
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("traps tab navigation inside the dialog and closes on escape or backdrop click", async () => {
|
||||
mockLoadedEditor();
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderRoleEditor();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Edit: worker" });
|
||||
const closeButton = within(dialog).getByRole("button", { name: "Close" });
|
||||
const saveButton = within(dialog).getByRole("button", { name: "Save Role" });
|
||||
|
||||
closeButton.focus();
|
||||
await user.tab({ shift: true });
|
||||
expect(saveButton).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(closeButton).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
const backdrop = dialog.previousElementSibling;
|
||||
if (!(backdrop instanceof HTMLElement)) {
|
||||
throw new Error("dialog backdrop not found");
|
||||
}
|
||||
|
||||
await user.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect, useCallback, useId } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
fetchRoleDetail,
|
||||
fetchRoleSkills,
|
||||
updateRole,
|
||||
updateRoleSkills,
|
||||
} from "../api/client";
|
||||
import Button from "./ui/Button";
|
||||
import EditorActionBar from "./ui/EditorActionBar";
|
||||
import EditorShell from "./ui/EditorShell";
|
||||
import FormField from "./ui/FormField";
|
||||
import PanelHeader from "./ui/PanelHeader";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from "./ui/Dialog";
|
||||
import InsetPanel from "./ui/InsetPanel";
|
||||
import TextInput from "./ui/TextInput";
|
||||
import TextareaField from "./ui/TextareaField";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { RoleSkill } from "../types";
|
||||
|
||||
interface RoleEditorProps {
|
||||
workspace: string;
|
||||
roleName: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type Tab = "role" | "roleSkills";
|
||||
|
||||
interface SkillSection {
|
||||
category: string;
|
||||
skills: RoleSkill[];
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "Request failed.";
|
||||
}
|
||||
|
||||
function groupRoleSkills(skills: RoleSkill[]): SkillSection[] {
|
||||
const sections: SkillSection[] = [];
|
||||
const sectionByCategory = new Map<string, SkillSection>();
|
||||
|
||||
for (const skill of skills) {
|
||||
const category = skill.category || "other";
|
||||
let section = sectionByCategory.get(category);
|
||||
if (!section) {
|
||||
section = { category, skills: [] };
|
||||
sectionByCategory.set(category, section);
|
||||
sections.push(section);
|
||||
}
|
||||
section.skills.push(skill);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function formatSkillCategoryLabel(category: string) {
|
||||
return category
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
export default function RoleEditor({
|
||||
workspace,
|
||||
roleName,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: RoleEditorProps) {
|
||||
const { copy } = useI18n();
|
||||
const titleId = useId();
|
||||
|
||||
const [tab, setTab] = useState<Tab>("role");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [sortOrder, setSortOrder] = useState(1000);
|
||||
const [description, setDescription] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [configToml, setConfigToml] = useState("");
|
||||
const [authJson, setAuthJson] = useState("{}");
|
||||
const [roleSkills, setRoleSkills] = useState<RoleSkill[]>([]);
|
||||
const [skillCategoryDrafts, setSkillCategoryDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
const syncRoleSkills = useCallback((skills: RoleSkill[]) => {
|
||||
setRoleSkills(skills);
|
||||
setSkillCategoryDrafts(
|
||||
Object.fromEntries(skills.map((skill) => [skill.id, skill.category || "other"])),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
Promise.allSettled([
|
||||
fetchRoleDetail(workspace, roleName),
|
||||
fetchRoleSkills(workspace, roleName),
|
||||
]).then(([roleResult, skillsResult]) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (roleResult.status === "rejected") {
|
||||
setError(getErrorMessage(roleResult.reason));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setName(roleResult.value.name);
|
||||
setSortOrder(roleResult.value.sort_order);
|
||||
setDescription(roleResult.value.description);
|
||||
setPrompt(roleResult.value.prompt);
|
||||
setConfigToml(roleResult.value.config_toml);
|
||||
setAuthJson(roleResult.value.auth_json);
|
||||
|
||||
if (skillsResult.status === "fulfilled") {
|
||||
syncRoleSkills(skillsResult.value);
|
||||
} else {
|
||||
setError(getErrorMessage(skillsResult.reason));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspace, roleName, syncRoleSkills]);
|
||||
|
||||
const handleSaveRole = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateRole(workspace, roleName, {
|
||||
description,
|
||||
prompt,
|
||||
sort_order: sortOrder,
|
||||
config_toml: configToml,
|
||||
auth_json: authJson,
|
||||
});
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [workspace, roleName, sortOrder, description, prompt, configToml, authJson, onSaved]);
|
||||
|
||||
const handleSaveRoleSkills = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const metadata = roleSkills
|
||||
.filter((skill) => !skill.missing)
|
||||
.map((skill) => ({
|
||||
id: skill.id,
|
||||
category: (skillCategoryDrafts[skill.id] ?? skill.category ?? "other").trim(),
|
||||
}));
|
||||
await updateRoleSkills(
|
||||
workspace,
|
||||
roleName,
|
||||
roleSkills.filter((skill) => skill.enabled).map((skill) => skill.id),
|
||||
metadata,
|
||||
);
|
||||
syncRoleSkills(
|
||||
roleSkills.map((skill) => ({
|
||||
...skill,
|
||||
category: skill.missing
|
||||
? skill.category
|
||||
: (skillCategoryDrafts[skill.id] ?? skill.category ?? "other").trim() || "other",
|
||||
})),
|
||||
);
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [workspace, roleName, roleSkills, skillCategoryDrafts, onSaved, syncRoleSkills]);
|
||||
|
||||
const toggleSkill = useCallback((id: string) => {
|
||||
setRoleSkills((skills) =>
|
||||
skills.map((skill) =>
|
||||
skill.id === id ? { ...skill, enabled: !skill.enabled } : skill,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateSkillCategory = useCallback((id: string, value: string) => {
|
||||
setSkillCategoryDrafts((drafts) => ({
|
||||
...drafts,
|
||||
[id]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const groupedRoleSkills = groupRoleSkills(roleSkills);
|
||||
const skillCategoryLabels = copy.roleEditor.skillCategories as Record<string, string>;
|
||||
const tabItems = [
|
||||
{ id: "role", label: copy.roleEditor.roleTab },
|
||||
{ id: "roleSkills", label: copy.roleEditor.roleSkillsTab },
|
||||
] as const;
|
||||
const handleSave = tab === "roleSkills" ? handleSaveRoleSkills : handleSaveRole;
|
||||
const saveLabel = saving
|
||||
? copy.roleEditor.saving
|
||||
: tab === "roleSkills"
|
||||
? copy.roleEditor.saveSkills
|
||||
: copy.roleEditor.saveRole;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPortal forceMount>
|
||||
<DialogOverlay asChild>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 bg-black/40"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
/>
|
||||
</DialogOverlay>
|
||||
|
||||
<DialogContent
|
||||
asChild
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<motion.div
|
||||
className="fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-lg flex-col overflow-hidden bg-[color:var(--app-bg)] shadow-2xl"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{copy.roleEditor.title(roleName)}
|
||||
</DialogTitle>
|
||||
<EditorShell
|
||||
header={(
|
||||
<>
|
||||
<PanelHeader
|
||||
titleId={titleId}
|
||||
title={copy.roleEditor.title(roleName)}
|
||||
trailing={(
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="xs">
|
||||
{copy.common.close}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
)}
|
||||
/>
|
||||
<Tabs items={tabItems} value={tab} onChange={setTab} />
|
||||
</>
|
||||
)}
|
||||
mainClassName="p-4"
|
||||
footer={!loading ? (
|
||||
<EditorActionBar error={error}>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="xs">
|
||||
{copy.common.cancel}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="solid"
|
||||
tone="brand"
|
||||
size="xs"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</EditorActionBar>
|
||||
) : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="app-text-soft py-8 text-center text-sm">{copy.roleEditor.loading}</div>
|
||||
) : tab === "role" ? (
|
||||
<div className="space-y-4">
|
||||
<FormField label={copy.roleEditor.name} labelClassName="text-xs font-medium normal-case tracking-normal">
|
||||
<InsetPanel padding="sm" className="text-sm app-text-primary">
|
||||
{name}
|
||||
</InsetPanel>
|
||||
</FormField>
|
||||
|
||||
<FormField label={copy.roleEditor.description} labelClassName="text-xs font-medium normal-case tracking-normal">
|
||||
<TextInput
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={copy.roleEditor.descriptionPlaceholder}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={copy.roleEditor.sortOrder}
|
||||
labelClassName="text-xs font-medium normal-case tracking-normal"
|
||||
>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number.parseInt(e.target.value || "0", 10) || 0)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<TextareaField
|
||||
label={copy.roleEditor.systemPrompt}
|
||||
labelClassName="text-xs font-medium normal-case tracking-normal"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={copy.roleEditor.promptPlaceholder}
|
||||
textareaClassName="min-h-[320px] font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
|
||||
<TextareaField
|
||||
label={copy.roleEditor.codexConfig}
|
||||
labelClassName="text-xs font-medium normal-case tracking-normal"
|
||||
value={configToml}
|
||||
onChange={(e) => setConfigToml(e.target.value)}
|
||||
placeholder={copy.roleEditor.codexConfigPlaceholder}
|
||||
textareaClassName="min-h-[240px] font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
|
||||
<TextareaField
|
||||
label={copy.roleEditor.codexAuth}
|
||||
labelClassName="text-xs font-medium normal-case tracking-normal"
|
||||
value={authJson}
|
||||
onChange={(e) => setAuthJson(e.target.value)}
|
||||
placeholder={copy.roleEditor.codexAuthPlaceholder}
|
||||
textareaClassName="min-h-[180px] font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : roleSkills.length === 0 ? (
|
||||
<InsetPanel padding="sm" className="text-sm app-text-soft">
|
||||
{copy.roleEditor.skillsEmpty}
|
||||
</InsetPanel>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<InsetPanel padding="sm" className="text-sm app-text-soft">
|
||||
{copy.roleEditor.skillsGroupingScope}
|
||||
</InsetPanel>
|
||||
{groupedRoleSkills.map((section) => (
|
||||
<section key={section.category} className="space-y-3">
|
||||
<div className="px-1">
|
||||
<p className="app-text-faint text-[11px] font-semibold uppercase tracking-[0.18em]">
|
||||
{skillCategoryLabels[section.category] ?? formatSkillCategoryLabel(section.category)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{section.skills.map((skill) => (
|
||||
<label
|
||||
key={skill.id}
|
||||
className="flex items-start gap-3 rounded-lg border border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-[color:var(--app-divider)]"
|
||||
checked={skill.enabled}
|
||||
onChange={() => toggleSkill(skill.id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium app-text-primary">{skill.name}</span>
|
||||
{skill.missing ? (
|
||||
<span className="rounded-full bg-[color:var(--app-danger-background)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[color:var(--app-danger-text)]">
|
||||
{copy.roleEditor.skillMissing}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<p className="mt-1 text-xs app-text-soft">{skill.description}</p>
|
||||
) : null}
|
||||
{!skill.missing ? (
|
||||
<div className="mt-3 flex max-w-[18rem] flex-col gap-1">
|
||||
<span className="app-text-faint text-[11px] font-medium uppercase tracking-wide">
|
||||
{copy.roleEditor.skillGroupLabel}
|
||||
</span>
|
||||
<TextInput
|
||||
aria-label={`${skill.name} group`}
|
||||
className="h-8 px-2 py-1 text-xs"
|
||||
value={skillCategoryDrafts[skill.id] ?? skill.category ?? ""}
|
||||
onChange={(e) => updateSkillCategory(skill.id, e.target.value)}
|
||||
placeholder={copy.roleEditor.skillGroupPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</EditorShell>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePolling } from "../hooks/usePolling";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import type { RoleInfo } from "../types";
|
||||
import RoleStatus from "./RoleStatus";
|
||||
|
||||
vi.mock("../hooks/usePolling", () => ({
|
||||
usePolling: vi.fn(),
|
||||
}));
|
||||
|
||||
const usePollingMock = vi.mocked(usePolling);
|
||||
|
||||
function mockPollingState(overrides: Partial<ReturnType<typeof usePolling>> = {}) {
|
||||
const refresh = vi.fn();
|
||||
usePollingMock.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh,
|
||||
...overrides,
|
||||
} as ReturnType<typeof usePolling>);
|
||||
|
||||
return refresh;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RoleStatus", () => {
|
||||
it("orders roles by sort_order and renders status labels", () => {
|
||||
const roles: RoleInfo[] = [
|
||||
{
|
||||
name: "leader",
|
||||
description: "Leader role",
|
||||
sort_order: 100,
|
||||
pending: 0,
|
||||
session: {
|
||||
role: "leader",
|
||||
created_at: "2026-03-10T00:00:00Z",
|
||||
last_used_at: "2026-03-10T00:30:00Z",
|
||||
last_message: "Scope locked",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worker",
|
||||
description: "Worker role",
|
||||
sort_order: 200,
|
||||
pending: 0,
|
||||
session: null,
|
||||
},
|
||||
{
|
||||
name: "worker-burst",
|
||||
description: "Busy worker role",
|
||||
sort_order: 300,
|
||||
pending: 1,
|
||||
session: {
|
||||
role: "worker",
|
||||
created_at: "2026-03-10T01:00:00Z",
|
||||
last_used_at: "2026-03-10T01:05:00Z",
|
||||
last_message: "Need review",
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPollingState({ data: roles });
|
||||
|
||||
renderWithProviders(<RoleStatus workspace="alpha" />, { locale: "en" });
|
||||
|
||||
const workflowHeading = screen.getByText("Agents");
|
||||
expect(workflowHeading).toBeInTheDocument();
|
||||
|
||||
const busyWorkerLabel = screen.getByText("Busy worker role");
|
||||
const leaderLabel = screen.getByText("Leader", { exact: true });
|
||||
const workerLabel = screen.getByText("Worker", { exact: true });
|
||||
const busyWorkerRow = busyWorkerLabel.closest("div");
|
||||
const leaderRow = leaderLabel.closest("article");
|
||||
const workerRow = workerLabel.closest("div");
|
||||
|
||||
expect(busyWorkerRow).toBeTruthy();
|
||||
expect(leaderRow).toBeTruthy();
|
||||
expect(workerRow).toBeTruthy();
|
||||
expect(
|
||||
leaderLabel.compareDocumentPosition(workerLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
workerLabel.compareDocumentPosition(busyWorkerLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(busyWorkerRow).toHaveTextContent("Needs attention");
|
||||
expect(busyWorkerRow).toHaveTextContent("1 waiting");
|
||||
|
||||
expect(leaderRow).toHaveTextContent("Active");
|
||||
|
||||
expect(workerRow).toHaveTextContent("Not started");
|
||||
|
||||
expect(screen.getByText("Active agents")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Needs attention").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no roles", () => {
|
||||
mockPollingState({ data: [] });
|
||||
|
||||
renderWithProviders(<RoleStatus workspace="alpha" />, { locale: "en" });
|
||||
|
||||
expect(screen.getByText("No agents yet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Agent status appears here after this workspace starts running leader and worker threads.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries after a polling error", async () => {
|
||||
const refresh = mockPollingState({ error: "roles unavailable" });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<RoleStatus workspace="alpha" />, { locale: "en" });
|
||||
|
||||
expect(screen.getByText("Couldn't load agent status")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the workspace required state when no workspace is selected", () => {
|
||||
mockPollingState({
|
||||
data: [{
|
||||
name: "leader",
|
||||
description: "Leader role",
|
||||
sort_order: 100,
|
||||
pending: 0,
|
||||
session: null,
|
||||
}],
|
||||
});
|
||||
|
||||
renderWithProviders(<RoleStatus workspace="" />, { locale: "en" });
|
||||
|
||||
expect(screen.getByText("Select a project")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Choose a project from the picker in the top-right corner to load agent status."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { usePolling } from "../hooks/usePolling";
|
||||
import { fetchRoles } from "../api/client";
|
||||
import type { RoleInfo } from "../types";
|
||||
import AsyncPageState from "./ui/AsyncPageState";
|
||||
import Button from "./ui/Button";
|
||||
import Chip from "./ui/Chip";
|
||||
import RoleBadge from "./RoleBadge";
|
||||
import PageHero from "./ui/PageHero";
|
||||
import PageSectionCard from "./ui/PageSectionCard";
|
||||
import SummaryStat from "./ui/SummaryStat";
|
||||
import StatusDot from "./ui/StatusDot";
|
||||
import RoleEditor from "./RoleEditor";
|
||||
import { useI18n } from "../i18n";
|
||||
import { roleListChangeKey } from "../utils/pollingKeys";
|
||||
|
||||
interface RoleStatusProps {
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
function getRoleState(role: RoleInfo, copy: ReturnType<typeof useI18n>["copy"]) {
|
||||
if (role.pending > 0) {
|
||||
return {
|
||||
label: copy.roleStatus.states.attention,
|
||||
dotClassName: "bg-[color:var(--app-accent-warm)]",
|
||||
textClassName: "text-[color:var(--app-accent-warm)]",
|
||||
};
|
||||
}
|
||||
|
||||
if (role.session) {
|
||||
return {
|
||||
label: copy.roleStatus.states.active,
|
||||
dotClassName: "app-dot-success",
|
||||
textClassName: "app-text-success",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: copy.roleStatus.states.idle,
|
||||
dotClassName: "app-dot-idle",
|
||||
textClassName: "app-text-idle",
|
||||
};
|
||||
}
|
||||
|
||||
function getRolePriority(role: RoleInfo) {
|
||||
return (role.pending > 0 ? 2 : 0) + (role.session ? 1 : 0);
|
||||
}
|
||||
|
||||
export default function RoleStatus({ workspace }: RoleStatusProps) {
|
||||
const { copy, formatAbsoluteDateTime, formatRelativeTime } = useI18n();
|
||||
const fetcher = useCallback(() => fetchRoles(workspace), [workspace]);
|
||||
const { data, loading, error, refresh } = usePolling<RoleInfo[]>(
|
||||
fetcher,
|
||||
5000,
|
||||
{ getChangeKey: roleListChangeKey },
|
||||
);
|
||||
|
||||
// Editor state: string = editing role name, undefined = closed
|
||||
const [editorTarget, setEditorTarget] = useState<string | undefined>();
|
||||
|
||||
const sortedRoles = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((left, right) => {
|
||||
if (left.sort_order !== right.sort_order) {
|
||||
return left.sort_order - right.sort_order;
|
||||
}
|
||||
const priorityDiff = getRolePriority(right) - getRolePriority(left);
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const activeSessions = data?.filter((role) => role.session).length ?? 0;
|
||||
const attentionCount = data?.filter((role) => role.pending > 0).length ?? 0;
|
||||
const idleCount = data ? data.length - activeSessions : 0;
|
||||
const waitingMessages = data?.reduce((sum, role) => sum + role.pending, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<AsyncPageState
|
||||
workspace={workspace}
|
||||
workspaceSubject={copy.roleStatus.workspaceSubject}
|
||||
loading={loading}
|
||||
loadingMode="initial"
|
||||
hasData={Boolean(data && data.length > 0)}
|
||||
error={error}
|
||||
loadingEyebrow={copy.roleStatus.eyebrow}
|
||||
loadingTitle={copy.roleStatus.loadingTitle}
|
||||
errorEyebrow={copy.roleStatus.eyebrow}
|
||||
errorTitle={copy.roleStatus.errorTitle}
|
||||
emptyEyebrow={copy.roleStatus.eyebrow}
|
||||
emptyTitle={copy.roleStatus.emptyTitle}
|
||||
emptyDetail={copy.roleStatus.emptyDetail}
|
||||
retryLabel={copy.common.retry}
|
||||
onRetry={refresh}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageHero
|
||||
eyebrow={copy.roleStatus.eyebrow}
|
||||
title={copy.roleStatus.heroTitle}
|
||||
stats={
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:min-w-[24rem]">
|
||||
<SummaryStat
|
||||
label={copy.roleStatus.summary.activeAgents}
|
||||
value={activeSessions}
|
||||
detail={copy.roleStatus.summary.activeAgentsDetail}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.roleStatus.summary.needsAttention}
|
||||
value={attentionCount}
|
||||
detail={copy.roleStatus.summary.needsAttentionDetail}
|
||||
tone="attention"
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.roleStatus.summary.waitingItems}
|
||||
value={waitingMessages}
|
||||
detail={copy.roleStatus.summary.waitingItemsDetail}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.roleStatus.summary.notStarted}
|
||||
value={idleCount}
|
||||
detail={copy.roleStatus.summary.notStartedDetail}
|
||||
tone="muted"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageSectionCard
|
||||
title={copy.roleStatus.listTitle}
|
||||
detail={copy.roleStatus.listDetail}
|
||||
action={<Chip size="sm">{sortedRoles.length}</Chip>}
|
||||
className="rounded-[26px]"
|
||||
headerClassName="px-4 py-4 sm:px-5"
|
||||
bodyClassName="grid gap-3 p-4 sm:p-5 md:grid-cols-2"
|
||||
>
|
||||
{sortedRoles.map((role) => {
|
||||
const state = getRoleState(role, copy);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={role.name}
|
||||
className="app-panel-muted flex h-full flex-col rounded-2xl border border-[color:var(--app-divider-soft)] p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RoleBadge role={role.name} size="md" />
|
||||
<StatusDot className={`h-2 w-2 ${state.dotClassName}`} />
|
||||
<span className={`app-caption-medium ${state.textClassName}`}>
|
||||
{state.label}
|
||||
</span>
|
||||
{role.pending > 0 && (
|
||||
<Chip tone="attention" className="app-caption-medium">
|
||||
{copy.roleStatus.states.waiting(role.pending)}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="app-text-soft mt-3 text-sm"
|
||||
title={role.description}
|
||||
dir="auto"
|
||||
>
|
||||
{copy.roleStatus.conciseDescription[role.name as keyof typeof copy.roleStatus.conciseDescription] ?? role.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setEditorTarget(role.name)}
|
||||
>
|
||||
{copy.common.edit}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{role.session?.last_message ? (
|
||||
<p
|
||||
className="app-text-faint mt-3 line-clamp-3 break-words text-xs leading-6"
|
||||
title={role.session.last_message}
|
||||
dir="auto"
|
||||
>
|
||||
{role.session.last_message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
|
||||
<div className="min-w-0">
|
||||
<div className="app-text-faint app-caption">{copy.roleStatus.lastActive}</div>
|
||||
{role.session ? (
|
||||
<div
|
||||
className="app-text-primary mt-1 text-sm"
|
||||
title={formatAbsoluteDateTime(role.session.last_used_at)}
|
||||
>
|
||||
{formatRelativeTime(role.session.last_used_at)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-text-idle mt-1 text-sm">{copy.common.notStarted}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-text-faint text-right text-[11px] font-medium uppercase tracking-[0.16em]">
|
||||
#{role.sort_order}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</PageSectionCard>
|
||||
|
||||
{/* Role Editor slide-out panel */}
|
||||
{editorTarget !== undefined && (
|
||||
<RoleEditor
|
||||
workspace={workspace}
|
||||
roleName={editorTarget}
|
||||
onClose={() => setEditorTarget(undefined)}
|
||||
onSaved={() => {
|
||||
setEditorTarget(undefined);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AsyncPageState>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteSkill, fetchSkills, upsertSkill } from "../api/client";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import SkillCatalogManager from "./SkillCatalogManager";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
fetchSkills: vi.fn(),
|
||||
upsertSkill: vi.fn(),
|
||||
deleteSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchSkillsMock = vi.mocked(fetchSkills);
|
||||
const upsertSkillMock = vi.mocked(upsertSkill);
|
||||
const deleteSkillMock = vi.mocked(deleteSkill);
|
||||
|
||||
function mockCatalogLoaded() {
|
||||
fetchSkillsMock.mockResolvedValue([
|
||||
{
|
||||
id: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
category: "adaptation",
|
||||
sort_order: 10,
|
||||
content: "---\nname: adapt\ndescription: Adapt designs.\n---\n",
|
||||
},
|
||||
{
|
||||
id: "animate",
|
||||
name: "animate",
|
||||
description: "Animate interfaces.",
|
||||
category: "visual_design",
|
||||
sort_order: 20,
|
||||
content: "---\nname: animate\ndescription: Animate interfaces.\n---\n",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSkillCatalog() {
|
||||
renderWithProviders(<SkillCatalogManager />, { locale: "en" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("SkillCatalogManager", () => {
|
||||
it("loads and saves the global skill catalog", async () => {
|
||||
mockCatalogLoaded();
|
||||
upsertSkillMock.mockImplementation(async (skill) => ({
|
||||
id: skill.id ?? "adapt",
|
||||
name: "adapt-plus",
|
||||
description: "Adapt designs across breakpoints.",
|
||||
category: skill.category,
|
||||
sort_order: skill.sort_order,
|
||||
content: skill.content,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderSkillCatalog();
|
||||
|
||||
expect((await screen.findAllByText("adapt")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Skills in catalog")).toBeInTheDocument();
|
||||
|
||||
const contentInput = screen.getByLabelText("Skill Content");
|
||||
await user.clear(contentInput);
|
||||
await user.type(contentInput, "---\nname: adapt-plus\ndescription: Adapt designs across breakpoints.\n---\n\nDo it.");
|
||||
await user.click(screen.getByRole("button", { name: "Save Skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertSkillMock).toHaveBeenCalledWith({
|
||||
id: "adapt",
|
||||
category: "adaptation",
|
||||
sort_order: 10,
|
||||
content: "---\nname: adapt-plus\ndescription: Adapt designs across breakpoints.\n---\n\nDo it.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("can start creating a new skill from the page", async () => {
|
||||
mockCatalogLoaded();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderSkillCatalog();
|
||||
|
||||
await screen.findByLabelText("Skill Content");
|
||||
await user.click(screen.getByRole("button", { name: "New Skill" }));
|
||||
|
||||
expect(screen.queryByLabelText("Path")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Skill Content")).toHaveValue(
|
||||
"---\nname: my-skill\ndescription: Brief summary shown in role assignment.\n---\n\nWrite the skill instructions here.",
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes an existing skill from the page", async () => {
|
||||
fetchSkillsMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "adapt",
|
||||
name: "adapt",
|
||||
description: "Adapt designs.",
|
||||
category: "adaptation",
|
||||
sort_order: 10,
|
||||
content: "---\nname: adapt\ndescription: Adapt designs.\n---\n",
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
deleteSkillMock.mockResolvedValue();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderSkillCatalog();
|
||||
|
||||
await screen.findByLabelText("Skill Content");
|
||||
const deleteButton = await screen.findByRole("button", { name: "Delete Skill" });
|
||||
expect(deleteButton).toBeEnabled();
|
||||
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSkillMock).toHaveBeenCalledWith("adapt");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error state when the skills API is unavailable", async () => {
|
||||
fetchSkillsMock.mockRejectedValue(new Error("404 page not found"));
|
||||
|
||||
renderSkillCatalog();
|
||||
|
||||
expect(await screen.findByText("Couldn't load skills")).toBeInTheDocument();
|
||||
expect(screen.getByText("404 page not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads the global skill catalog without any workspace context", async () => {
|
||||
mockCatalogLoaded();
|
||||
|
||||
renderSkillCatalog();
|
||||
|
||||
expect((await screen.findAllByText("adapt")).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Choose a workspace to view skill catalog.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { deleteSkill, fetchSkills, upsertSkill, type SkillCatalogSaveInput } from "../api/client";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { SkillCatalogEntry } from "../types";
|
||||
import { extractSkillMetadata } from "../utils/skillMetadata";
|
||||
import AsyncPageState from "./ui/AsyncPageState";
|
||||
import Button from "./ui/Button";
|
||||
import CatalogSidebar from "./ui/CatalogSidebar";
|
||||
import Card from "./ui/Card";
|
||||
import EditorActionBar from "./ui/EditorActionBar";
|
||||
import EditorShell from "./ui/EditorShell";
|
||||
import FormField from "./ui/FormField";
|
||||
import InsetPanel from "./ui/InsetPanel";
|
||||
import PageHero from "./ui/PageHero";
|
||||
import SummaryStat from "./ui/SummaryStat";
|
||||
import TextInput from "./ui/TextInput";
|
||||
import TextareaField from "./ui/TextareaField";
|
||||
|
||||
const newSkillSelection = "__new_skill__";
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "Request failed.";
|
||||
}
|
||||
|
||||
function formatSkillCategoryLabel(category: string) {
|
||||
return category
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
function withParsedSkillMetadata(skill: SkillCatalogEntry): SkillCatalogEntry {
|
||||
const parsed = extractSkillMetadata(skill.content);
|
||||
return {
|
||||
...skill,
|
||||
name: skill.content.trim() ? parsed.name : skill.name.trim(),
|
||||
description: skill.content.trim() ? parsed.description : skill.description.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmptySkillDraft(template: string): SkillCatalogEntry {
|
||||
return {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
category: "other",
|
||||
sort_order: 1000,
|
||||
content: template,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillDraft(skill: SkillCatalogEntry): SkillCatalogSaveInput {
|
||||
return {
|
||||
id: skill.id.trim() || undefined,
|
||||
category: skill.category.trim() || "other",
|
||||
sort_order: skill.sort_order > 0 ? skill.sort_order : 1000,
|
||||
content: skill.content,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SkillCatalogManager() {
|
||||
const { copy } = useI18n();
|
||||
const emptySkillDraft = useMemo(
|
||||
() => withParsedSkillMetadata(makeEmptySkillDraft(copy.skillsCatalog.skillContentPlaceholder)),
|
||||
[copy.skillsCatalog.skillContentPlaceholder],
|
||||
);
|
||||
const [catalogSkills, setCatalogSkills] = useState<SkillCatalogEntry[]>([]);
|
||||
const [catalogLoaded, setCatalogLoaded] = useState(false);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [selectedSkillId, setSelectedSkillId] = useState<string>(newSkillSelection);
|
||||
const [skillDraft, setSkillDraft] = useState<SkillCatalogEntry>(emptySkillDraft);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const applyCatalogSelection = useCallback((id: string, skills: SkillCatalogEntry[]) => {
|
||||
if (id === newSkillSelection) {
|
||||
setSelectedSkillId(newSkillSelection);
|
||||
setSkillDraft(emptySkillDraft);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = skills.find((skill) => skill.id === id);
|
||||
if (!selected) {
|
||||
if (skills.length > 0) {
|
||||
setSelectedSkillId(skills[0].id);
|
||||
setSkillDraft(skills[0]);
|
||||
return;
|
||||
}
|
||||
setSelectedSkillId(newSkillSelection);
|
||||
setSkillDraft(emptySkillDraft);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSkillId(selected.id);
|
||||
setSkillDraft(selected);
|
||||
}, [emptySkillDraft]);
|
||||
|
||||
const loadSkillCatalog = useCallback(async (preferredSelection?: string) => {
|
||||
setCatalogLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const skills = (await fetchSkills()).map(withParsedSkillMetadata);
|
||||
setCatalogSkills(skills);
|
||||
setCatalogLoaded(true);
|
||||
const nextSelection =
|
||||
preferredSelection && skills.some((skill) => skill.id === preferredSelection)
|
||||
? preferredSelection
|
||||
: skills[0]?.id ?? newSkillSelection;
|
||||
applyCatalogSelection(nextSelection, skills);
|
||||
} catch (err) {
|
||||
setCatalogLoaded(false);
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, [applyCatalogSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSkillCatalog();
|
||||
}, [loadSkillCatalog]);
|
||||
|
||||
const selectCatalogSkill = useCallback((id: string) => {
|
||||
applyCatalogSelection(id, catalogSkills);
|
||||
}, [applyCatalogSelection, catalogSkills]);
|
||||
|
||||
const updateCatalogField = useCallback(
|
||||
(field: keyof SkillCatalogEntry, value: SkillCatalogEntry[keyof SkillCatalogEntry]) => {
|
||||
setSkillDraft((draft) => ({
|
||||
...(field === "content"
|
||||
? withParsedSkillMetadata({
|
||||
...draft,
|
||||
content: String(value),
|
||||
})
|
||||
: {
|
||||
...draft,
|
||||
[field]: value,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveCatalogSkill = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await upsertSkill(normalizeSkillDraft(skillDraft));
|
||||
await loadSkillCatalog(saved.id);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [loadSkillCatalog, skillDraft]);
|
||||
|
||||
const handleDeleteCatalogSkill = useCallback(async () => {
|
||||
if (selectedSkillId === newSkillSelection || !skillDraft.id.trim()) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteSkill(skillDraft.id.trim());
|
||||
await loadSkillCatalog();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [loadSkillCatalog, selectedSkillId, skillDraft.id]);
|
||||
|
||||
const handleResetDraft = useCallback(() => {
|
||||
applyCatalogSelection(selectedSkillId, catalogSkills);
|
||||
}, [applyCatalogSelection, catalogSkills, selectedSkillId]);
|
||||
|
||||
const categoryCount = useMemo(
|
||||
() => new Set(catalogSkills.map((skill) => skill.category.trim() || "other")).size,
|
||||
[catalogSkills],
|
||||
);
|
||||
const selectedExistingSkill = selectedSkillId !== newSkillSelection;
|
||||
const skillCategoryLabels = copy.skillsCatalog.skillCategories as Record<string, string>;
|
||||
const fieldLabelClassName = "text-xs font-medium normal-case tracking-normal";
|
||||
const catalogItems = catalogSkills.map((skill) => ({
|
||||
id: skill.id,
|
||||
title: skill.name,
|
||||
description: (
|
||||
<>
|
||||
<p>
|
||||
{skillCategoryLabels[skill.category] ?? formatSkillCategoryLabel(skill.category)}
|
||||
</p>
|
||||
<p className="app-text-faint mt-2 line-clamp-2 text-[11px]">{skill.description}</p>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<AsyncPageState
|
||||
loading={catalogLoading}
|
||||
loadingMode="initial"
|
||||
hasData={catalogLoaded}
|
||||
error={error && !catalogLoaded ? error : undefined}
|
||||
loadingEyebrow={copy.skillsCatalog.eyebrow}
|
||||
loadingTitle={copy.skillsCatalog.loadingTitle}
|
||||
errorEyebrow={copy.skillsCatalog.eyebrow}
|
||||
errorTitle={copy.skillsCatalog.errorTitle}
|
||||
retryLabel={copy.common.retry}
|
||||
onRetry={() => void loadSkillCatalog()}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageHero
|
||||
eyebrow={copy.skillsCatalog.eyebrow}
|
||||
title={copy.skillsCatalog.heroTitle}
|
||||
description={copy.skillsCatalog.heroDetail}
|
||||
stats={
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:min-w-[26rem]">
|
||||
<SummaryStat
|
||||
label={copy.skillsCatalog.summary.totalSkills}
|
||||
value={catalogSkills.length}
|
||||
detail={copy.skillsCatalog.summary.totalSkillsDetail}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.skillsCatalog.summary.categories}
|
||||
value={categoryCount}
|
||||
detail={copy.skillsCatalog.summary.categoriesDetail}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={copy.skillsCatalog.summary.assignmentSurface}
|
||||
value={copy.skillsCatalog.summary.assignmentSurfaceValue}
|
||||
detail={copy.skillsCatalog.summary.assignmentSurfaceDetail}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden rounded-[26px]">
|
||||
<EditorShell
|
||||
sidebar={(
|
||||
<CatalogSidebar
|
||||
title={copy.skillsCatalog.catalogListLabel}
|
||||
actionLabel={copy.skillsCatalog.newSkill}
|
||||
onAction={() => selectCatalogSkill(newSkillSelection)}
|
||||
empty={copy.skillsCatalog.catalogEmpty}
|
||||
items={catalogItems}
|
||||
value={selectedSkillId}
|
||||
onChange={selectCatalogSkill}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<EditorActionBar
|
||||
error={error || undefined}
|
||||
leading={selectedExistingSkill ? (
|
||||
<Button
|
||||
variant="soft"
|
||||
tone="danger"
|
||||
size="xs"
|
||||
disabled={saving}
|
||||
onClick={handleDeleteCatalogSkill}
|
||||
>
|
||||
{copy.skillsCatalog.deleteSkill}
|
||||
</Button>
|
||||
) : undefined}
|
||||
>
|
||||
<Button variant="ghost" size="xs" disabled={saving} onClick={handleResetDraft}>
|
||||
{copy.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
tone="brand"
|
||||
size="xs"
|
||||
disabled={saving}
|
||||
onClick={handleSaveCatalogSkill}
|
||||
>
|
||||
{saving ? copy.skillsCatalog.saving : copy.skillsCatalog.saveSkill}
|
||||
</Button>
|
||||
</EditorActionBar>
|
||||
)}
|
||||
mainClassName="p-4 sm:p-5"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<InsetPanel padding="sm" className="text-sm app-text-soft">
|
||||
{copy.skillsCatalog.catalogScope}
|
||||
</InsetPanel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<FormField
|
||||
label={copy.skillsCatalog.skillMarkdownSummaryLabel}
|
||||
hint={copy.skillsCatalog.skillMarkdownHint}
|
||||
labelClassName={fieldLabelClassName}
|
||||
>
|
||||
<InsetPanel padding="sm" className="space-y-3">
|
||||
<div>
|
||||
<p className="app-text-faint text-[11px] font-medium uppercase tracking-wide">
|
||||
{copy.skillsCatalog.skillNameLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-sm app-text-primary">
|
||||
{skillDraft.name || copy.skillsCatalog.skillNameMissing}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="app-text-faint text-[11px] font-medium uppercase tracking-wide">
|
||||
{copy.skillsCatalog.skillDescriptionLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-sm app-text-soft">
|
||||
{skillDraft.description || copy.skillsCatalog.skillDescriptionMissing}
|
||||
</p>
|
||||
</div>
|
||||
</InsetPanel>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={copy.skillsCatalog.sortOrder}
|
||||
labelClassName={fieldLabelClassName}
|
||||
>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
aria-label={copy.skillsCatalog.sortOrder}
|
||||
value={skillDraft.sort_order}
|
||||
onChange={(e) =>
|
||||
updateCatalogField("sort_order", Number.parseInt(e.target.value || "0", 10) || 0)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label={copy.skillsCatalog.skillGroupLabel}
|
||||
labelClassName={fieldLabelClassName}
|
||||
>
|
||||
<TextInput
|
||||
aria-label={copy.skillsCatalog.skillGroupLabel}
|
||||
value={skillDraft.category}
|
||||
onChange={(e) => updateCatalogField("category", e.target.value)}
|
||||
placeholder={copy.skillsCatalog.skillGroupPlaceholder}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<TextareaField
|
||||
label={copy.skillsCatalog.skillContentLabel}
|
||||
labelClassName={fieldLabelClassName}
|
||||
textareaClassName="min-h-[420px] font-mono text-xs leading-relaxed"
|
||||
aria-label={copy.skillsCatalog.skillContentLabel}
|
||||
value={skillDraft.content}
|
||||
onChange={(e) => updateCatalogField("content", e.target.value)}
|
||||
placeholder={copy.skillsCatalog.skillContentPlaceholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</EditorShell>
|
||||
</Card>
|
||||
</div>
|
||||
</AsyncPageState>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ThemeSelector from "./ThemeSelector";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
|
||||
describe("ThemeSelector", () => {
|
||||
it("renders a labeled theme select and updates the active theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<ThemeSelector />, { locale: "en" });
|
||||
|
||||
const select = screen.getByRole("combobox", { name: /theme/i });
|
||||
expect(select).toHaveValue("atelier-copper");
|
||||
|
||||
await user.selectOptions(select, "mist-blue");
|
||||
|
||||
expect(select).toHaveValue("mist-blue");
|
||||
});
|
||||
|
||||
it("shows all static themes in the picker", async () => {
|
||||
renderWithProviders(<ThemeSelector />, { locale: "en" });
|
||||
|
||||
const select = screen.getByRole("combobox", { name: /theme/i });
|
||||
expect(screen.getByRole("option", { name: "Atelier Copper" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Graphite Aqua" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Midnight Plum" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Ivory Brass" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Mist Blue" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Sage Paper" })).toBeInTheDocument();
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useId } from "react";
|
||||
import { themesByAppearance, useTheme } from "../theme";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export default function ThemeSelector() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { copy } = useI18n();
|
||||
const selectId = useId();
|
||||
|
||||
return (
|
||||
<div className="app-panel flex min-h-[3.75rem] min-w-[11rem] flex-col justify-center rounded-2xl px-4 py-2">
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="app-text-soft text-[11px] font-medium uppercase tracking-[0.16em]"
|
||||
>
|
||||
{copy.themeSelector.themeTitle}
|
||||
</label>
|
||||
<select
|
||||
id={selectId}
|
||||
value={theme}
|
||||
onChange={(event) => setTheme(event.target.value as typeof theme)}
|
||||
className="app-text-primary mt-1 w-full appearance-none bg-transparent text-sm font-medium outline-none"
|
||||
>
|
||||
<optgroup label={copy.themes.appearance.darkThemes}>
|
||||
{themesByAppearance.dark.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{copy.themes[option.id].label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label={copy.themes.appearance.lightThemes}>
|
||||
{themesByAppearance.light.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{copy.themes[option.id].label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
answerHumanTask,
|
||||
confirmTopicPlan,
|
||||
createTopic,
|
||||
deleteTopic,
|
||||
fetchWorkflowBoard,
|
||||
sendMessage,
|
||||
stopTopic,
|
||||
} from "../api/client";
|
||||
import type { WorkflowBoardResponse } from "../types";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import WorkflowPrototype from "./WorkflowPrototype";
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual = await vi.importActual("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
fetchWorkflowBoard: vi.fn(),
|
||||
createTopic: vi.fn(),
|
||||
deleteTopic: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
answerHumanTask: vi.fn(),
|
||||
confirmTopicPlan: vi.fn(),
|
||||
stopTopic: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const fetchWorkflowBoardMock = vi.mocked(fetchWorkflowBoard);
|
||||
const createTopicMock = vi.mocked(createTopic);
|
||||
const deleteTopicMock = vi.mocked(deleteTopic);
|
||||
const sendMessageMock = vi.mocked(sendMessage);
|
||||
const answerHumanTaskMock = vi.mocked(answerHumanTask);
|
||||
const confirmTopicPlanMock = vi.mocked(confirmTopicPlan);
|
||||
const stopTopicMock = vi.mocked(stopTopic);
|
||||
|
||||
function makeBoard(): WorkflowBoardResponse {
|
||||
return {
|
||||
topics: [
|
||||
{
|
||||
name: "launch-dashboard",
|
||||
status: "execution",
|
||||
message_count: 3,
|
||||
latest_stage: "execution",
|
||||
latest_time: "2026-03-16T09:00:00Z",
|
||||
running_roles: [],
|
||||
waiting_roles: [],
|
||||
},
|
||||
],
|
||||
active_topic: "launch-dashboard",
|
||||
board: {
|
||||
topic: {
|
||||
name: "launch-dashboard",
|
||||
latest_stage: "execution",
|
||||
message_count: 3,
|
||||
created_at: "2026-03-16T08:00:00Z",
|
||||
updated_at: "2026-03-16T09:00:00Z",
|
||||
status: "execution",
|
||||
},
|
||||
plan: {
|
||||
version: 2,
|
||||
status: "active",
|
||||
summary_markdown: "Build the operator-facing React console.\n\nThen verify it.",
|
||||
created_at: "2026-03-16T08:05:00Z",
|
||||
confirmed_at: "2026-03-16T08:08:00Z",
|
||||
created_by_role_name: "leader",
|
||||
},
|
||||
summary: {
|
||||
running_count: 1,
|
||||
waiting_count: 1,
|
||||
active_roles: ["leader"],
|
||||
last_event_at: "2026-03-16T09:00:00Z",
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
name: "leader",
|
||||
category: "workflow",
|
||||
sort_order: 100,
|
||||
description: "Leader",
|
||||
pending_global: 0,
|
||||
session_last_used_at: "2026-03-16T09:00:00Z",
|
||||
state: "running",
|
||||
latest_inbound_at: "2026-03-16T08:59:00Z",
|
||||
latest_outbound_at: "2026-03-16T09:00:00Z",
|
||||
latest_inbound_preview: "Need ship readiness.",
|
||||
latest_outbound_preview: "Creating execution lanes.",
|
||||
current_dispatch: null,
|
||||
},
|
||||
],
|
||||
lanes: [
|
||||
{
|
||||
id: "chain_1",
|
||||
name: "UI Chain",
|
||||
slug: "ui-chain",
|
||||
purpose: "Build the operator-facing React console.",
|
||||
status: "running",
|
||||
branch_name: "chain/main/ui-chain",
|
||||
worktree_path: "/tmp/ui-chain",
|
||||
container_name: "chain-main-ui-chain",
|
||||
runtime_endpoint: "http://127.0.0.1:40123",
|
||||
started_at: "2026-03-16T08:10:00Z",
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: "task_1",
|
||||
lane_id: "chain_1",
|
||||
title: "Build UI",
|
||||
kind: "execution",
|
||||
deliverables: ["apps/web/src", "apps/web/package.json"],
|
||||
batch_key: "ui-core",
|
||||
status: "running",
|
||||
priority: 10,
|
||||
task_order: 1,
|
||||
dependencies: [],
|
||||
},
|
||||
{
|
||||
id: "task_2",
|
||||
lane_id: "chain_1",
|
||||
title: "Verify UI",
|
||||
kind: "verification",
|
||||
deliverables: ["reports/ui-check.md"],
|
||||
batch_key: "ui-verify",
|
||||
status: "draft",
|
||||
priority: 5,
|
||||
task_order: 2,
|
||||
dependencies: [{ depends_on_task_id: "task_1" }],
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
events: [
|
||||
{
|
||||
kind: "message",
|
||||
id: "msg_1",
|
||||
timestamp: "2026-03-16T08:59:00Z",
|
||||
from: "user",
|
||||
to: "leader",
|
||||
stage: "plan",
|
||||
type: "chat",
|
||||
body: "Need a clean leader console.",
|
||||
},
|
||||
{
|
||||
kind: "message",
|
||||
id: "msg_2",
|
||||
timestamp: "2026-03-16T09:00:00Z",
|
||||
from: "worker",
|
||||
to: "leader",
|
||||
stage: "execution",
|
||||
type: "summary",
|
||||
body: "UI chain is in progress.",
|
||||
},
|
||||
{
|
||||
kind: "dispatch",
|
||||
id: "run_1",
|
||||
timestamp: "2026-03-16T09:00:00Z",
|
||||
role: "worker",
|
||||
stage: "execution",
|
||||
mode: "task",
|
||||
running: true,
|
||||
started_at: "2026-03-16T08:10:00Z",
|
||||
completed_at: "",
|
||||
exit_code: 0,
|
||||
reply: "",
|
||||
error_message: "",
|
||||
},
|
||||
],
|
||||
pending_human_tasks: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowPrototype", () => {
|
||||
it("renders the leader console with chains and tasks", async () => {
|
||||
fetchWorkflowBoardMock.mockResolvedValue(makeBoard());
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Leader Console")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("UI Chain")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Execution map").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Build the operator-facing React console\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("Build UI")).toBeInTheDocument();
|
||||
expect(screen.getByText("Verify UI")).toBeInTheDocument();
|
||||
expect(screen.getByText("apps/web/src")).toBeInTheDocument();
|
||||
expect(screen.getByText(/worktree .*ui-chain/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open timeline (3)" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Need a clean leader console.")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open timeline (3)" }));
|
||||
|
||||
expect(await screen.findByText("Need a clean leader console.")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("UI chain is in progress.").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("creates a workflow topic from the leader console", async () => {
|
||||
fetchWorkflowBoardMock
|
||||
.mockResolvedValueOnce({ topics: [], active_topic: null, board: null })
|
||||
.mockResolvedValue(makeBoard());
|
||||
createTopicMock.mockResolvedValue({
|
||||
id: "topic_1",
|
||||
name: "release-prep-2026",
|
||||
space: "workflow",
|
||||
status: "execution",
|
||||
created_at: "2026-03-16T09:10:00Z",
|
||||
updated_at: "2026-03-16T09:10:00Z",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /start first topic/i }));
|
||||
await user.type(screen.getByLabelText("Topic name"), "Release Prep 2026");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTopicMock).toHaveBeenCalledWith("alpha", "release-prep-2026", "workflow");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a new message to leader", async () => {
|
||||
fetchWorkflowBoardMock.mockResolvedValue(makeBoard());
|
||||
sendMessageMock.mockResolvedValue({ status: "delivered", file: "msg_3" });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
await user.type(await screen.findByPlaceholderText("Outline the next decision, handoff, or build request..."), "Ship the final UI pass.");
|
||||
await user.click(screen.getByRole("button", { name: "Send update" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
workspace: "alpha",
|
||||
to: "leader",
|
||||
topic: "launch-dashboard",
|
||||
body: "Ship the final UI pass.",
|
||||
stage: "plan",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a pending human task", async () => {
|
||||
const board = makeBoard();
|
||||
board.board!.pending_human_tasks = [
|
||||
{
|
||||
id: "human-task-1",
|
||||
role_name: "user",
|
||||
status: "pending",
|
||||
prompt_message_id: "msg_99",
|
||||
prompt_from: "leader",
|
||||
prompt_stage: "plan",
|
||||
prompt_body: "Which deploy window should this chain target?",
|
||||
created_at: "2026-03-16T09:00:00Z",
|
||||
updated_at: "2026-03-16T09:00:00Z",
|
||||
},
|
||||
];
|
||||
fetchWorkflowBoardMock.mockResolvedValue(board);
|
||||
answerHumanTaskMock.mockResolvedValue();
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
const [humanTaskInput] = await screen.findAllByRole("textbox");
|
||||
await user.type(humanTaskInput, "Tonight after 10 PM.");
|
||||
await user.click(screen.getByRole("button", { name: "Send answer" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(answerHumanTaskMock).toHaveBeenCalledWith("human-task-1", "Tonight after 10 PM.");
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a topic after confirmation", async () => {
|
||||
fetchWorkflowBoardMock.mockResolvedValue(makeBoard());
|
||||
deleteTopicMock.mockResolvedValue();
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Delete topic" }));
|
||||
expect(await screen.findByText("Delete this topic?")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteTopicMock).toHaveBeenCalledWith("alpha", "launch-dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
it("stops a topic from the detail header", async () => {
|
||||
fetchWorkflowBoardMock.mockResolvedValue(makeBoard());
|
||||
stopTopicMock.mockResolvedValue();
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Stop topic" }));
|
||||
expect(await screen.findByText("Stop this topic?")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm stop" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stopTopicMock).toHaveBeenCalledWith("alpha", "launch-dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables compose for a cancelled topic", async () => {
|
||||
const cancelledBoard = makeBoard();
|
||||
cancelledBoard.board!.topic.status = "cancelled";
|
||||
fetchWorkflowBoardMock.mockResolvedValue(cancelledBoard);
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Stopped" })).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("Name the topic first...")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows a stopped marker in the topic rail", async () => {
|
||||
const board = makeBoard();
|
||||
board.topics[0].status = "cancelled";
|
||||
fetchWorkflowBoardMock.mockResolvedValue(board);
|
||||
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText("Stopped")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("confirms an awaiting plan from the execution map", async () => {
|
||||
const board = makeBoard();
|
||||
board.topics = [
|
||||
{
|
||||
...board.topics[0],
|
||||
status: "awaiting_confirmation",
|
||||
},
|
||||
{
|
||||
name: "approval-topic",
|
||||
status: "awaiting_confirmation",
|
||||
message_count: 1,
|
||||
latest_stage: "plan",
|
||||
latest_time: "2026-03-16T10:00:00Z",
|
||||
running_roles: [],
|
||||
waiting_roles: ["leader"],
|
||||
},
|
||||
];
|
||||
board.board!.topic.status = "awaiting_confirmation";
|
||||
board.board!.plan = {
|
||||
version: 3,
|
||||
status: "draft",
|
||||
summary_markdown: "Freeze the plan before execution.\n\n1. Create the lanes.\n2. Confirm and start.",
|
||||
created_at: "2026-03-16T09:40:00Z",
|
||||
created_by_role_name: "leader",
|
||||
};
|
||||
fetchWorkflowBoardMock.mockResolvedValue(board);
|
||||
confirmTopicPlanMock.mockResolvedValue();
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkflowPrototype workspace="alpha" />, {
|
||||
route: "/workspaces/alpha/workflow/launch-dashboard",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText("Execution map")).toHaveLength(2);
|
||||
expect(screen.getByText("Plan v3")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prepared by leader")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Review the frozen plan before execution starts")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Confirm plan" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmTopicPlanMock).toHaveBeenCalledWith("alpha", "launch-dashboard");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import {
|
||||
confirmTopicPlan,
|
||||
createTopic,
|
||||
deleteTopic,
|
||||
fetchWorkflowBoard,
|
||||
stopTopic,
|
||||
} from "../api/client";
|
||||
import type {
|
||||
WorkflowBoardResponse,
|
||||
} from "../types";
|
||||
import { usePolling } from "../hooks/usePolling";
|
||||
import AsyncPageState from "./ui/AsyncPageState";
|
||||
import AlertBanner from "./ui/AlertBanner";
|
||||
import Button from "./ui/Button";
|
||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||
import InlineComposer from "./ui/InlineComposer";
|
||||
import StatusBadge from "./ui/StatusBadge";
|
||||
import ViewState from "./ui/ViewState";
|
||||
import PageHero from "./ui/PageHero";
|
||||
import { useI18n } from "../i18n";
|
||||
import { workflowBoardChangeKey } from "../utils/pollingKeys";
|
||||
import {
|
||||
getErrorMessage,
|
||||
isExistingTopicError,
|
||||
slugifyTopicName,
|
||||
} from "../utils/topics";
|
||||
import { buildWorkflowHref, getTopicFromPathname } from "../routes";
|
||||
import { getConsoleCopy } from "./workflow-prototype/copy";
|
||||
import {
|
||||
taskStats,
|
||||
} from "./workflow-prototype/helpers";
|
||||
import {
|
||||
ComposeBar,
|
||||
ExecutionMapSection,
|
||||
HumanTaskCard,
|
||||
TimelineDrawer,
|
||||
TopicItem,
|
||||
} from "./workflow-prototype/sections";
|
||||
|
||||
interface WorkflowPrototypeProps {
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export default function WorkflowPrototype({ workspace }: WorkflowPrototypeProps) {
|
||||
const { copy, locale } = useI18n();
|
||||
const consoleCopy = useMemo(() => getConsoleCopy(locale), [locale]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const topicParam = getTopicFromPathname(location.pathname);
|
||||
const [creatingNew, setCreatingNew] = useState(false);
|
||||
const [newTopicName, setNewTopicName] = useState("");
|
||||
const [createTopicError, setCreateTopicError] = useState("");
|
||||
const [topicActionError, setTopicActionError] = useState("");
|
||||
const [deleteDialogTopic, setDeleteDialogTopic] = useState<string | null>(null);
|
||||
const [deletingTopic, setDeletingTopic] = useState(false);
|
||||
const [confirmingPlan, setConfirmingPlan] = useState(false);
|
||||
const [stoppingTopic, setStoppingTopic] = useState(false);
|
||||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
const [stopDialogOpen, setStopDialogOpen] = useState(false);
|
||||
const newTopicInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const boardFetcher = useCallback(
|
||||
() => fetchWorkflowBoard(workspace, topicParam || undefined),
|
||||
[workspace, topicParam],
|
||||
);
|
||||
const { data, loading, error, refresh } = usePolling<WorkflowBoardResponse>(
|
||||
workspace ? boardFetcher : null,
|
||||
5000,
|
||||
{ getChangeKey: workflowBoardChangeKey },
|
||||
);
|
||||
|
||||
const topics = data?.topics ?? [];
|
||||
const board = data?.board ?? null;
|
||||
const activeTopic = creatingNew ? slugifyTopicName(newTopicName) : data?.active_topic ?? null;
|
||||
const primaryHumanTask = board?.pending_human_tasks?.[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!creatingNew && !topicParam && data?.active_topic) {
|
||||
startTransition(() => navigate(buildWorkflowHref({
|
||||
workspace,
|
||||
topic: data.active_topic ?? undefined,
|
||||
}), { replace: true }));
|
||||
}
|
||||
}, [creatingNew, data?.active_topic, navigate, topicParam, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimelineOpen(false);
|
||||
}, [activeTopic]);
|
||||
|
||||
const handleSelectTopic = (topic: string) => {
|
||||
setCreatingNew(false);
|
||||
setNewTopicName("");
|
||||
setCreateTopicError("");
|
||||
setTopicActionError("");
|
||||
setDeleteDialogTopic(null);
|
||||
setStopDialogOpen(false);
|
||||
startTransition(() => navigate(buildWorkflowHref({ workspace, topic })));
|
||||
};
|
||||
|
||||
const handleNewTopic = () => {
|
||||
setCreatingNew(true);
|
||||
setNewTopicName("");
|
||||
setCreateTopicError("");
|
||||
setTopicActionError("");
|
||||
setDeleteDialogTopic(null);
|
||||
setStopDialogOpen(false);
|
||||
window.setTimeout(() => newTopicInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const handleRequestDeleteTopic = (topic: string) => {
|
||||
if (deletingTopic) return;
|
||||
setTopicActionError("");
|
||||
setDeleteDialogTopic(topic);
|
||||
};
|
||||
|
||||
const handleConfirmNewTopic = async () => {
|
||||
const slug = slugifyTopicName(newTopicName);
|
||||
if (!slug || !workspace) return;
|
||||
setCreateTopicError("");
|
||||
try {
|
||||
await createTopic(workspace, slug, "workflow");
|
||||
setCreatingNew(false);
|
||||
setNewTopicName("");
|
||||
handleSelectTopic(slug);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
if (isExistingTopicError(error)) {
|
||||
setCreatingNew(false);
|
||||
setNewTopicName("");
|
||||
handleSelectTopic(slug);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
setCreateTopicError(getErrorMessage(error, copy.workflow.createTopicFailed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTopic = async () => {
|
||||
if (!workspace || deletingTopic || !deleteDialogTopic) return;
|
||||
setDeletingTopic(true);
|
||||
setTopicActionError("");
|
||||
try {
|
||||
await deleteTopic(workspace, deleteDialogTopic);
|
||||
setDeleteDialogTopic(null);
|
||||
startTransition(() => navigate(buildWorkflowHref({ workspace }), { replace: true }));
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setTopicActionError(getErrorMessage(error, copy.workflow.deleteTopicFailed));
|
||||
} finally {
|
||||
setDeletingTopic(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTopic = async (topic: string) => {
|
||||
if (!workspace || stoppingTopic) return;
|
||||
setStoppingTopic(true);
|
||||
setTopicActionError("");
|
||||
try {
|
||||
await stopTopic(workspace, topic);
|
||||
setStopDialogOpen(false);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setTopicActionError(getErrorMessage(error, copy.workflow.stopTopicFailed));
|
||||
} finally {
|
||||
setStoppingTopic(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPlan = async (topic: string) => {
|
||||
if (!workspace || confirmingPlan) return;
|
||||
setConfirmingPlan(true);
|
||||
setTopicActionError("");
|
||||
try {
|
||||
await confirmTopicPlan(workspace, topic);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setTopicActionError(getErrorMessage(error, copy.workflow.confirmPlanFailed));
|
||||
} finally {
|
||||
setConfirmingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lanes = board?.lanes ?? [];
|
||||
const tasks = board?.tasks ?? [];
|
||||
const activityItems = (board?.events ?? []).slice().sort((left, right) =>
|
||||
right.timestamp.localeCompare(left.timestamp),
|
||||
);
|
||||
const stats = taskStats(tasks);
|
||||
const topicStopped = board?.topic.status === "cancelled";
|
||||
const topicAwaitingConfirmation = board?.topic.status === "awaiting_confirmation";
|
||||
|
||||
return (
|
||||
<AsyncPageState
|
||||
workspace={workspace}
|
||||
workspaceSubject={copy.workflow.workspaceSubject}
|
||||
loading={loading}
|
||||
loadingMode="initial"
|
||||
hasData={Boolean(data)}
|
||||
error={error}
|
||||
loadingTitle={copy.common.loading}
|
||||
errorTitle={copy.workflow.errorTitle}
|
||||
retryLabel={copy.common.retry}
|
||||
onRetry={refresh}
|
||||
>
|
||||
<div className="flex min-h-[70dvh] flex-col overflow-hidden rounded-xl border border-[color:var(--app-divider)] bg-[color:var(--app-surface-base)] lg:h-full lg:min-h-0 lg:flex-row">
|
||||
<aside className="max-h-[18rem] w-full shrink-0 overflow-y-auto border-b border-[color:var(--app-divider)] bg-[color:var(--app-surface-drawer)] lg:max-h-none lg:w-[280px] lg:border-b-0 lg:border-r">
|
||||
<div className="p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-2 px-2">
|
||||
<h2 className="app-text-soft app-overline">{copy.workflow.topics}</h2>
|
||||
<Button onClick={handleNewTopic} size="xs">{copy.workflow.newTopic}</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{creatingNew ? (
|
||||
<InlineComposer
|
||||
label={copy.workflow.topicName}
|
||||
inputId="new-topic"
|
||||
inputRef={newTopicInputRef}
|
||||
value={newTopicName}
|
||||
onChange={setNewTopicName}
|
||||
onSubmit={() => void handleConfirmNewTopic()}
|
||||
placeholder={copy.workflow.topicPlaceholder}
|
||||
hint={copy.workflow.topicHint}
|
||||
error={createTopicError}
|
||||
submitLabel={copy.workflow.newTopic}
|
||||
className="bg-[color:var(--app-surface-elevated)]"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{topicActionError ? (
|
||||
<AlertBanner tone="danger" detail={topicActionError} className="text-[12px]" />
|
||||
) : null}
|
||||
|
||||
{topics.map((topic) => (
|
||||
<TopicItem
|
||||
key={topic.name}
|
||||
topic={topic}
|
||||
isActive={!creatingNew && activeTopic === topic.name}
|
||||
onClick={() => handleSelectTopic(topic.name)}
|
||||
onDelete={() => handleRequestDeleteTopic(topic.name)}
|
||||
deleteLabel={copy.workflow.deleteTopic}
|
||||
/>
|
||||
))}
|
||||
|
||||
{topics.length === 0 && !creatingNew ? (
|
||||
<div className="rounded-2xl border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)] px-4 py-4">
|
||||
<p className="app-text-soft app-overline">{copy.workflow.emptyTopicRailTitle}</p>
|
||||
<p className="app-text-faint mt-2 text-sm leading-6">{copy.workflow.emptyTopicRailDetail}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3 sm:px-4 sm:py-4">
|
||||
{!activeTopic ? (
|
||||
<ViewState
|
||||
eyebrow={consoleCopy.heroEyebrow}
|
||||
title={consoleCopy.emptyTitle}
|
||||
detail={consoleCopy.emptyDetail}
|
||||
action={<Button onClick={handleNewTopic}>{consoleCopy.startTopic}</Button>}
|
||||
align="start"
|
||||
surface
|
||||
size="lg"
|
||||
className="flex-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto flex w-full max-w-[1500px] flex-col gap-4">
|
||||
<PageHero
|
||||
eyebrow={consoleCopy.heroEyebrow}
|
||||
title={activeTopic}
|
||||
layout="stack"
|
||||
align="start"
|
||||
contentFooter={
|
||||
board?.topic.status ? (
|
||||
<StatusBadge tone="muted" size="sm" uppercase>
|
||||
{copy.workflow.topicStatus(board.topic.status)}
|
||||
</StatusBadge>
|
||||
) : null
|
||||
}
|
||||
headerActions={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
onClick={() => setTimelineOpen(true)}
|
||||
aria-label={`${copy.workflow.overview.openTimeline} (${activityItems.length})`}
|
||||
>
|
||||
<span>{copy.workflow.overview.openTimeline}</span>
|
||||
<span className="app-count-pill px-2 py-0.5 text-[11px]">
|
||||
{activityItems.length}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="soft"
|
||||
tone="danger"
|
||||
onClick={() => setStopDialogOpen(true)}
|
||||
disabled={topicStopped || stoppingTopic || confirmingPlan}
|
||||
>
|
||||
{topicStopped
|
||||
? copy.workflow.topicStopped
|
||||
: stoppingTopic
|
||||
? copy.workflow.stoppingTopic
|
||||
: copy.workflow.stopTopic}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="app-panel rounded-2xl px-4 py-3">
|
||||
<p className="app-text-soft app-overline">{consoleCopy.summaryLanes}</p>
|
||||
<p className="app-text-primary mt-2 text-[1.75rem] font-semibold leading-none">{lanes.length}</p>
|
||||
</div>
|
||||
<div className="app-panel rounded-2xl px-4 py-3">
|
||||
<p className="app-text-soft app-overline">{consoleCopy.summaryTasks}</p>
|
||||
<p className="app-text-primary mt-2 text-[1.75rem] font-semibold leading-none">{tasks.length}</p>
|
||||
</div>
|
||||
<div className="app-panel rounded-2xl px-4 py-3">
|
||||
<p className="app-text-soft app-overline">{consoleCopy.summaryRunning}</p>
|
||||
<p className="app-text-primary mt-2 text-[1.75rem] font-semibold leading-none">{stats.running}</p>
|
||||
</div>
|
||||
<div className="app-panel rounded-2xl px-4 py-3">
|
||||
<p className="app-text-soft app-overline">{consoleCopy.summaryWaiting}</p>
|
||||
<p className="app-text-primary mt-2 text-[1.75rem] font-semibold leading-none">{stats.waiting}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{primaryHumanTask ? <HumanTaskCard task={primaryHumanTask} onAnswered={refresh} /> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<ExecutionMapSection
|
||||
lanes={lanes}
|
||||
tasks={tasks}
|
||||
consoleCopy={consoleCopy}
|
||||
action={topicAwaitingConfirmation ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{board?.plan?.version ? (
|
||||
<StatusBadge tone="muted" size="sm">
|
||||
{copy.workflow.planReview.versionLabel(board.plan.version)}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{board?.plan?.created_by_role_name ? (
|
||||
<StatusBadge tone="muted" size="sm">
|
||||
{copy.workflow.planReview.createdByLabel(board.plan.created_by_role_name)}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
<Button
|
||||
variant="solid"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
onClick={() => void handleConfirmPlan(activeTopic)}
|
||||
disabled={confirmingPlan || topicStopped || stoppingTopic}
|
||||
>
|
||||
{confirmingPlan ? copy.workflow.confirmingPlan : copy.workflow.confirmPlan}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
<ComposeBar
|
||||
workspace={workspace}
|
||||
topic={activeTopic}
|
||||
onSent={refresh}
|
||||
consoleCopy={consoleCopy}
|
||||
disabled={topicStopped || stoppingTopic || confirmingPlan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<TimelineDrawer
|
||||
open={timelineOpen}
|
||||
onOpenChange={setTimelineOpen}
|
||||
items={activityItems}
|
||||
empty={copy.workflow.timeline.emptyDetail}
|
||||
countLabel={copy.workflow.timeline.count(activityItems.length)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteDialogTopic)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteDialogTopic(null);
|
||||
}
|
||||
}}
|
||||
title={copy.workflow.deleteTopicDialogTitle}
|
||||
description={copy.workflow.deleteTopicDialogDetail}
|
||||
confirmLabel={deletingTopic ? copy.workflow.deletingTopic : copy.workflow.confirmDeleteTopic}
|
||||
cancelLabel={copy.common.cancel}
|
||||
confirmTone="danger"
|
||||
busy={deletingTopic}
|
||||
onConfirm={() => void handleDeleteTopic()}
|
||||
/>
|
||||
{activeTopic ? (
|
||||
<ConfirmDialog
|
||||
open={stopDialogOpen}
|
||||
onOpenChange={setStopDialogOpen}
|
||||
title={copy.workflow.stopTopicDialogTitle}
|
||||
description={copy.workflow.stopTopicDialogDetail}
|
||||
confirmLabel={stoppingTopic ? copy.workflow.stoppingTopic : copy.workflow.confirmStopTopic}
|
||||
cancelLabel={copy.common.cancel}
|
||||
confirmTone="danger"
|
||||
busy={stoppingTopic}
|
||||
onConfirm={() => void handleStopTopic(activeTopic)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AsyncPageState>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import WorkspaceSelector from "./WorkspaceSelector";
|
||||
|
||||
function makeWorkspace(name: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `ws-${name}`,
|
||||
name,
|
||||
slug: name,
|
||||
path: `/workspaces/${name}`,
|
||||
runtime_backend: "container",
|
||||
container_name: `codex-${name}`,
|
||||
status: "active",
|
||||
provision_state: "ready",
|
||||
provision_error: "",
|
||||
last_provisioned_at: "",
|
||||
container_state: "running",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceSelector", () => {
|
||||
it("shows a loading label while workspaces are being resolved", () => {
|
||||
renderWithProviders(
|
||||
<WorkspaceSelector value="" workspaces={[]} loading onChange={vi.fn()} />,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /loading workspaces/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets the user switch to another existing workspace", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<WorkspaceSelector
|
||||
value="alpha"
|
||||
workspaces={[makeWorkspace("alpha"), makeWorkspace("beta")]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /alpha/i }));
|
||||
|
||||
const betaOption = (await screen.findByText("beta")).closest("button");
|
||||
if (!betaOption) {
|
||||
throw new Error("beta workspace option not found");
|
||||
}
|
||||
|
||||
await user.click(betaOption);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("beta");
|
||||
});
|
||||
|
||||
it("updates the trigger label when the active workspace prop changes", async () => {
|
||||
const { rerender } = renderWithProviders(
|
||||
<WorkspaceSelector
|
||||
value="alpha"
|
||||
workspaces={[makeWorkspace("alpha"), makeWorkspace("beta")]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /alpha/i })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<WorkspaceSelector
|
||||
value="beta"
|
||||
workspaces={[makeWorkspace("alpha"), makeWorkspace("beta")]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /beta/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a labeled dialog, focuses the active project, and restores focus on escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<WorkspaceSelector
|
||||
value="alpha"
|
||||
workspaces={[makeWorkspace("alpha"), makeWorkspace("beta")]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
const trigger = await screen.findByRole("button", { name: /alpha/i });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Workspaces" });
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole("button", { name: /alpha/i })).toHaveFocus();
|
||||
});
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await user.tab();
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
}
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("keeps the current workspace selected when the next workspace has no id", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<WorkspaceSelector
|
||||
value="alpha"
|
||||
workspaces={[
|
||||
makeWorkspace("alpha"),
|
||||
makeWorkspace("beta", {
|
||||
id: "",
|
||||
provision_state: "failed",
|
||||
container_state: "missing",
|
||||
}),
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /alpha/i }));
|
||||
|
||||
const betaOption = (await screen.findByText("beta")).closest("button");
|
||||
if (!betaOption) {
|
||||
throw new Error("beta workspace option not found");
|
||||
}
|
||||
|
||||
await user.click(betaOption);
|
||||
|
||||
expect(await screen.findByText("Couldn't prepare the workspace runtime.")).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a header-managed empty state when no workspaces exist", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<WorkspaceSelector value="" workspaces={[]} onChange={vi.fn()} />,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /no workspaces/i }));
|
||||
|
||||
expect(await screen.findByText("No workspaces yet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Workspaces must be created outside the header picker.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import type { Workspace } from "../types";
|
||||
import Button from "./ui/Button";
|
||||
import PanelHeader from "./ui/PanelHeader";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverTrigger,
|
||||
} from "./ui/Popover";
|
||||
import StatusDot from "./ui/StatusDot";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
fadeScale,
|
||||
fadeUp,
|
||||
motionDuration,
|
||||
motionEase,
|
||||
motionTransition,
|
||||
} from "../utils/motion";
|
||||
import { summarizeWorkspacePath } from "../utils/workspace";
|
||||
import {
|
||||
workspaceStatusClassName,
|
||||
workspaceStatusLabel,
|
||||
} from "./workspace-selector/helpers";
|
||||
|
||||
interface WorkspaceSelectorProps {
|
||||
value: string;
|
||||
workspaces: Workspace[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onChange: (ws: string) => void;
|
||||
}
|
||||
|
||||
export default function WorkspaceSelector({
|
||||
value,
|
||||
workspaces,
|
||||
loading = false,
|
||||
error = null,
|
||||
onChange,
|
||||
}: WorkspaceSelectorProps) {
|
||||
const { copy } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectionErr, setSelectionErr] = useState("");
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const prefersReducedMotion = useReducedMotion() ?? false;
|
||||
const panelId = useId();
|
||||
const titleId = useId();
|
||||
|
||||
const focusPanelTarget = useCallback(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
const target =
|
||||
panel.querySelector<HTMLElement>("[data-active-workspace='true']")
|
||||
?? panel.querySelector<HTMLElement>("[data-workspace-option='true']");
|
||||
|
||||
(target ?? panel).focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const focusHandle = window.setTimeout(() => {
|
||||
focusPanelTarget();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(focusHandle);
|
||||
}, [focusPanelTarget, open, value, workspaces.length]);
|
||||
|
||||
const current = workspaces.find((workspace) => workspace.name === value) ?? null;
|
||||
const handleWorkspaceSelect = useCallback((nextWorkspace: Workspace) => {
|
||||
if (nextWorkspace.name === value) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!nextWorkspace.id) {
|
||||
setSelectionErr(copy.workspaceSelector.ensureWorkspaceError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionErr("");
|
||||
onChange(nextWorkspace.name);
|
||||
setOpen(false);
|
||||
}, [copy.workspaceSelector.ensureWorkspaceError, onChange, value]);
|
||||
|
||||
const currentPathSummary = summarizeWorkspacePath(
|
||||
current?.path,
|
||||
4,
|
||||
copy.workspaceSelector.noProjectPath,
|
||||
);
|
||||
const currentLabel = current?.name
|
||||
|| (loading
|
||||
? copy.workspaceSelector.loadingProjects
|
||||
: value
|
||||
? copy.workspaceSelector.missingProject(value)
|
||||
: workspaces.length === 0
|
||||
? copy.workspaceSelector.noProjects
|
||||
: copy.workspaceSelector.chooseProject);
|
||||
|
||||
if (error) {
|
||||
return <span className="app-text-danger text-xs">{copy.workspaceSelector.unavailable}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<div className="relative min-w-0 flex-1 md:flex-none">
|
||||
<PopoverTrigger asChild>
|
||||
<motion.button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={panelId}
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.992 }}
|
||||
transition={motionTransition(prefersReducedMotion, motionDuration.fast, motionEase.smooth)}
|
||||
className="app-panel group relative flex min-h-[3.75rem] w-full min-w-0 items-center justify-between gap-3 overflow-hidden rounded-2xl px-3 py-2.5 text-left text-sm transition-all hover:border-[color:var(--app-border-strong)] hover:bg-[color:var(--app-surface-muted)] md:w-[15rem] md:max-w-[16rem]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="app-overlay-glow pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="app-brand-tile relative flex h-8 w-8 shrink-0 items-center justify-center rounded-xl">
|
||||
{current ? (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0.45, scale: 0.7 }}
|
||||
animate={{ opacity: 0, scale: 1.8 }}
|
||||
transition={{ duration: 1.3, ease: "easeOut" }}
|
||||
className="absolute inset-0 rounded-xl border border-[color:var(--app-accent)]/30"
|
||||
/>
|
||||
) : null}
|
||||
<StatusDot className={`h-2 w-2 ${current ? "bg-[color:var(--app-accent)]" : "app-dot-idle"}`} />
|
||||
</div>
|
||||
<div className="relative z-10 min-w-0 space-y-0.5">
|
||||
<div className="app-text-primary truncate text-[0.98rem] font-semibold leading-tight">
|
||||
{currentLabel}
|
||||
</div>
|
||||
<div className="app-text-soft app-caption truncate leading-tight" title={current?.path ?? ""}>
|
||||
{currentPathSummary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 flex shrink-0 items-center self-stretch">
|
||||
<svg
|
||||
className={`app-text-soft h-3.5 w-3.5 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</motion.button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<PopoverPortal forceMount>
|
||||
<PopoverContent
|
||||
id={panelId}
|
||||
asChild
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
window.setTimeout(() => triggerRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
initial={fadeScale(prefersReducedMotion, 0.97)}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.97 }}
|
||||
transition={motionTransition(prefersReducedMotion, motionDuration.fast, motionEase.decisive)}
|
||||
className="app-panel app-overlay-panel z-50 w-[min(var(--radix-popover-trigger-width),calc(100vw-1rem))] overflow-hidden rounded-xl outline-none md:w-[min(18rem,calc(100vw-1rem))]"
|
||||
>
|
||||
<PanelHeader
|
||||
title={copy.workspaceSelector.projectsTitle}
|
||||
titleId={titleId}
|
||||
trailing={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{copy.common.close}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{selectionErr ? (
|
||||
<div className="px-3 pb-1">
|
||||
<div role="alert" className="rounded-xl border border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)] px-3 py-2 text-xs text-[color:var(--app-danger-text)]">
|
||||
{selectionErr}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[240px] overflow-y-auto py-1">
|
||||
{workspaces.map((workspace, index) => {
|
||||
const active = workspace.name === value;
|
||||
return (
|
||||
<motion.button
|
||||
key={workspace.id || workspace.name}
|
||||
type="button"
|
||||
data-workspace-option="true"
|
||||
data-active-workspace={active ? "true" : undefined}
|
||||
onClick={() => handleWorkspaceSelect(workspace)}
|
||||
initial={fadeUp(prefersReducedMotion, 6)}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: motionDuration.fast,
|
||||
ease: motionEase.smooth,
|
||||
delay: Math.min(index * 0.03, 0.12),
|
||||
}
|
||||
}
|
||||
className={`flex w-full items-start gap-2.5 px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
active
|
||||
? "bg-[color:var(--app-surface-muted)] app-text-primary"
|
||||
: "app-text-muted hover:bg-[color:var(--app-surface-muted)] hover:text-[color:var(--app-text)]"
|
||||
}`}
|
||||
>
|
||||
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${active ? "bg-[color:var(--app-accent)]" : "app-dot-idle"}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{workspace.name}</div>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${workspaceStatusClassName(workspace)}`}>
|
||||
{workspaceStatusLabel(workspace, copy)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-text-faint app-caption mt-0.5 truncate" title={workspace.path}>
|
||||
{summarizeWorkspacePath(workspace.path, 4, copy.workspaceSelector.noProjectPath)}
|
||||
</div>
|
||||
</div>
|
||||
{active ? (
|
||||
<svg className="app-text-muted ml-auto h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
) : null}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="px-3 py-4">
|
||||
<div className="rounded-xl border border-dashed border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] px-4 py-4 text-center">
|
||||
<p className="app-text-soft text-sm">{copy.workspaceSelector.noProjectsYet}</p>
|
||||
<p className="app-text-faint mt-2 text-xs leading-5">
|
||||
Workspaces must be created outside the header picker.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import InsetPanel from "../ui/InsetPanel";
|
||||
|
||||
interface ExecutionRunNoteProps {
|
||||
note: string;
|
||||
isError?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
runNoteLabel: string;
|
||||
replyLabel: string;
|
||||
}
|
||||
|
||||
export default function ExecutionRunNote({
|
||||
note,
|
||||
isError = false,
|
||||
id,
|
||||
className,
|
||||
runNoteLabel,
|
||||
replyLabel,
|
||||
}: ExecutionRunNoteProps) {
|
||||
return (
|
||||
<InsetPanel id={id} tone={isError ? "danger" : "neutral"} className={className}>
|
||||
<p className="app-text-soft app-overline">
|
||||
{isError ? runNoteLabel : replyLabel}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 whitespace-pre-wrap break-words text-sm leading-6 ${
|
||||
isError ? "app-text-danger" : "app-text-faint"
|
||||
}`}
|
||||
dir="auto"
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
</InsetPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ExecutionRunSummaryField {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
interface ExecutionRunSummaryProps {
|
||||
fields: ExecutionRunSummaryField[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExecutionRunSummary({
|
||||
fields,
|
||||
className,
|
||||
}: ExecutionRunSummaryProps) {
|
||||
return (
|
||||
<dl className={`grid grid-cols-2 gap-x-4 gap-y-2 text-sm ${className ?? ""}`.trim()}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className={field.fullWidth ? "col-span-2" : undefined}>
|
||||
<dt className="app-text-soft">{field.label}</dt>
|
||||
<dd className="app-text-muted mt-0.5 break-words">{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "./Button";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type AlertBannerTone = "neutral" | "danger" | "attention";
|
||||
|
||||
const toneClasses: Record<AlertBannerTone, string> = {
|
||||
neutral:
|
||||
"border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)]",
|
||||
danger:
|
||||
"border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)] text-[color:var(--app-danger-text)]",
|
||||
attention:
|
||||
"border-[color:var(--app-attention-border)] bg-[color:var(--app-attention-background)] text-[color:var(--app-attention-text)]",
|
||||
};
|
||||
|
||||
interface AlertBannerProps {
|
||||
title?: ReactNode;
|
||||
detail: ReactNode;
|
||||
actionLabel?: ReactNode;
|
||||
onAction?: () => void;
|
||||
action?: ReactNode;
|
||||
tone?: AlertBannerTone;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
detailClassName?: string;
|
||||
}
|
||||
|
||||
export default function AlertBanner({
|
||||
title,
|
||||
detail,
|
||||
actionLabel,
|
||||
onAction,
|
||||
action,
|
||||
tone = "danger",
|
||||
className,
|
||||
titleClassName,
|
||||
detailClassName,
|
||||
}: AlertBannerProps) {
|
||||
const actionNode = action ?? (
|
||||
actionLabel && onAction
|
||||
? (
|
||||
<Button onClick={onAction} variant="soft" size="xs" className="shrink-0">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cx(
|
||||
"rounded-2xl border px-4 py-3 text-sm",
|
||||
toneClasses[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{title ? (
|
||||
<p className={cx("app-text-soft app-overline", titleClassName)}>{title}</p>
|
||||
) : null}
|
||||
<div className={cx(title ? "mt-1" : "", detailClassName)}>{detail}</div>
|
||||
</div>
|
||||
{actionNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Button from "./Button";
|
||||
import AlertBanner from "./AlertBanner";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface AsyncDisclosureListProps {
|
||||
trigger: ReactNode;
|
||||
expanded: boolean;
|
||||
loading?: boolean;
|
||||
error?: ReactNode;
|
||||
retryLabel?: ReactNode;
|
||||
onToggle: () => void;
|
||||
onRetry?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AsyncDisclosureList({
|
||||
trigger,
|
||||
expanded,
|
||||
loading = false,
|
||||
error,
|
||||
retryLabel,
|
||||
onToggle,
|
||||
onRetry,
|
||||
children,
|
||||
className,
|
||||
}: AsyncDisclosureListProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
onClick={onToggle}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="min-h-11 px-1 text-left text-sm md:min-h-10 md:text-xs"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<span aria-hidden="true">{expanded ? "▾" : "▸"}</span>
|
||||
)}
|
||||
{trigger}
|
||||
</Button>
|
||||
{error ? (
|
||||
<AlertBanner
|
||||
tone="danger"
|
||||
detail={error}
|
||||
actionLabel={retryLabel}
|
||||
onAction={onRetry}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
className={cx(error ? "mt-2" : undefined)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../test/renderWithProviders";
|
||||
import AsyncPageState from "./AsyncPageState";
|
||||
|
||||
describe("AsyncPageState", () => {
|
||||
it("renders workspace required state when the subject is provided without a workspace", () => {
|
||||
renderWithProviders(
|
||||
<AsyncPageState
|
||||
workspace=""
|
||||
workspaceSubject="message history"
|
||||
loading={false}
|
||||
hasData
|
||||
>
|
||||
<div>ready</div>
|
||||
</AsyncPageState>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select a project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error state with a retry action", () => {
|
||||
const onRetry = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<AsyncPageState
|
||||
workspace="ws-1"
|
||||
loading={false}
|
||||
hasData={false}
|
||||
error="Request failed."
|
||||
errorEyebrow="Runs"
|
||||
errorTitle="Couldn't load runs"
|
||||
retryLabel="Retry"
|
||||
onRetry={onRetry}
|
||||
>
|
||||
<div>ready</div>
|
||||
</AsyncPageState>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Couldn't load runs")).toBeInTheDocument();
|
||||
screen.getByRole("button", { name: "Retry" }).click();
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders children when the async state is resolved", () => {
|
||||
renderWithProviders(
|
||||
<AsyncPageState workspace="ws-1" loading={false} hasData>
|
||||
<div>ready</div>
|
||||
</AsyncPageState>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("ready")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "./Button";
|
||||
import ViewState from "./ViewState";
|
||||
import WorkspaceRequiredState from "./WorkspaceRequiredState";
|
||||
|
||||
interface AsyncPageStateProps {
|
||||
children: ReactNode;
|
||||
workspace?: string;
|
||||
workspaceSubject?: string;
|
||||
loading: boolean;
|
||||
loadingMode?: "always" | "initial";
|
||||
hasData: boolean;
|
||||
error?: ReactNode;
|
||||
loadingEyebrow?: ReactNode;
|
||||
loadingTitle?: ReactNode;
|
||||
errorEyebrow?: ReactNode;
|
||||
errorTitle?: ReactNode;
|
||||
emptyEyebrow?: ReactNode;
|
||||
emptyTitle?: ReactNode;
|
||||
emptyDetail?: ReactNode;
|
||||
emptyAction?: ReactNode;
|
||||
retryLabel?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export default function AsyncPageState({
|
||||
children,
|
||||
workspace,
|
||||
workspaceSubject,
|
||||
loading,
|
||||
loadingMode = "always",
|
||||
hasData,
|
||||
error,
|
||||
loadingEyebrow,
|
||||
loadingTitle,
|
||||
errorEyebrow,
|
||||
errorTitle,
|
||||
emptyEyebrow,
|
||||
emptyTitle,
|
||||
emptyDetail,
|
||||
emptyAction,
|
||||
retryLabel,
|
||||
onRetry,
|
||||
}: AsyncPageStateProps) {
|
||||
if (workspaceSubject && !workspace) {
|
||||
return <WorkspaceRequiredState subject={workspaceSubject} />;
|
||||
}
|
||||
|
||||
const showLoading = loading && (loadingMode === "always" || !hasData);
|
||||
if (showLoading) {
|
||||
return (
|
||||
<ViewState
|
||||
eyebrow={loadingEyebrow}
|
||||
title={loadingTitle}
|
||||
surface
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ViewState
|
||||
tone="error"
|
||||
eyebrow={errorEyebrow}
|
||||
title={errorTitle}
|
||||
detail={error}
|
||||
align="start"
|
||||
size="lg"
|
||||
surface
|
||||
action={
|
||||
onRetry && retryLabel ? (
|
||||
<Button onClick={onRetry} variant="soft" size="xs">
|
||||
{retryLabel}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasData && emptyTitle) {
|
||||
return (
|
||||
<ViewState
|
||||
eyebrow={emptyEyebrow}
|
||||
title={emptyTitle}
|
||||
detail={emptyDetail}
|
||||
align="start"
|
||||
size="lg"
|
||||
surface
|
||||
action={emptyAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type ButtonVariant = "solid" | "soft" | "ghost";
|
||||
type ButtonTone = "neutral" | "brand" | "success" | "danger";
|
||||
type ButtonSize = "xs" | "sm" | "md" | "icon-sm" | "icon";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
tone?: ButtonTone;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
xs: "min-h-11 rounded-lg px-3 py-2 text-sm md:rounded-md md:px-2.5 md:py-1.5 md:text-[0.75rem] md:leading-[1.4]",
|
||||
sm: "min-h-11 rounded-lg px-3 py-2 text-sm md:rounded-md md:px-3 md:py-2 md:text-xs",
|
||||
md: "min-h-11 rounded-lg px-4 py-2.5 text-sm md:py-2",
|
||||
"icon-sm": "h-11 w-11 rounded-lg p-0 md:rounded-md",
|
||||
icon: "h-12 w-12 rounded-xl p-0 md:h-10 md:w-10",
|
||||
};
|
||||
|
||||
const toneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
||||
solid: {
|
||||
neutral:
|
||||
"bg-[color:var(--app-text-muted)] text-[color:var(--app-text-inverse)] hover:bg-[color:var(--app-text)]",
|
||||
brand:
|
||||
"bg-[color:var(--app-accent-warm)] text-[color:var(--app-text-inverse)] hover:bg-[color:var(--app-accent)]",
|
||||
success:
|
||||
"bg-[color:var(--app-success-strong)] text-[color:var(--app-text-inverse)] hover:bg-[color:var(--app-success-text)]",
|
||||
danger:
|
||||
"bg-[color:var(--app-danger-strong)] text-[color:var(--app-text-inverse)] hover:bg-[color:var(--app-danger-text)]",
|
||||
},
|
||||
soft: {
|
||||
neutral:
|
||||
"border border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] text-[color:var(--app-text)] hover:bg-[color:var(--app-surface-elevated)]",
|
||||
brand:
|
||||
"border border-[color:var(--app-attention-border)] bg-[color:var(--app-attention-background)] text-[color:var(--app-attention-text)] hover:bg-[color:var(--app-attention-border)]",
|
||||
success:
|
||||
"border border-[color:var(--app-success-border)] bg-[color:var(--app-success-background)] text-[color:var(--app-success-text)] hover:bg-[color:var(--app-success-border)]",
|
||||
danger:
|
||||
"border border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)] text-[color:var(--app-danger-text)] hover:bg-[color:var(--app-danger-border)]",
|
||||
},
|
||||
ghost: {
|
||||
neutral:
|
||||
"text-[color:var(--app-text-muted)] hover:bg-[color:var(--app-surface-muted)] hover:text-[color:var(--app-text)]",
|
||||
brand:
|
||||
"text-[color:var(--app-attention-text)] hover:bg-[color:var(--app-attention-background)] hover:text-[color:var(--app-text)]",
|
||||
success:
|
||||
"text-[color:var(--app-success-text)] hover:bg-[color:var(--app-success-background)] hover:text-[color:var(--app-text)]",
|
||||
danger:
|
||||
"text-[color:var(--app-danger-text)] hover:bg-[color:var(--app-danger-background)] hover:text-[color:var(--app-text)]",
|
||||
},
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
className,
|
||||
variant = "ghost",
|
||||
tone = "neutral",
|
||||
size = "sm",
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cx(
|
||||
"inline-flex touch-manipulation items-center justify-center gap-1.5 font-medium transition-[transform,background-color,border-color,color,box-shadow] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:hover:-translate-y-px motion-safe:active:translate-y-0 motion-safe:active:scale-[0.985] disabled:cursor-not-allowed disabled:opacity-40 disabled:transform-none",
|
||||
sizeClasses[size],
|
||||
toneClasses[variant][tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentPropsWithoutRef, ElementType } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type CardProps<T extends ElementType = "div"> = {
|
||||
as?: T;
|
||||
variant?: "default" | "hero";
|
||||
} & ComponentPropsWithoutRef<T>;
|
||||
|
||||
export default function Card<T extends ElementType = "div">({
|
||||
as,
|
||||
variant = "default",
|
||||
className,
|
||||
...props
|
||||
}: CardProps<T>) {
|
||||
const Component = as ?? "div";
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cx(
|
||||
variant === "hero" ? "app-panel-hero" : "app-panel",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "./Button";
|
||||
import InsetPanel from "./InsetPanel";
|
||||
import SelectableList, { type SelectableListItem } from "./SelectableList";
|
||||
|
||||
interface CatalogSidebarProps<T extends string> {
|
||||
title: ReactNode;
|
||||
actionLabel?: ReactNode;
|
||||
onAction?: () => void;
|
||||
empty: ReactNode;
|
||||
items: ReadonlyArray<SelectableListItem<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
export default function CatalogSidebar<T extends string>({
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
empty,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: CatalogSidebarProps<T>) {
|
||||
return (
|
||||
<div className="space-y-3 p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="app-text-soft text-xs font-semibold uppercase tracking-[0.18em]">
|
||||
{title}
|
||||
</p>
|
||||
{actionLabel && onAction ? (
|
||||
<Button variant="soft" size="xs" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<InsetPanel padding="sm" className="text-sm app-text-soft">
|
||||
{empty}
|
||||
</InsetPanel>
|
||||
) : (
|
||||
<SelectableList items={items} value={value} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type ChipTone =
|
||||
| "neutral"
|
||||
| "brand"
|
||||
| "attention"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "running"
|
||||
| "info"
|
||||
| "muted";
|
||||
|
||||
interface ChipProps extends ComponentPropsWithoutRef<"span"> {
|
||||
tone?: ChipTone;
|
||||
toneClassName?: string;
|
||||
size?: "xs" | "sm" | "md";
|
||||
shape?: "pill" | "rounded";
|
||||
outlined?: boolean;
|
||||
uppercase?: boolean;
|
||||
nowrap?: boolean;
|
||||
}
|
||||
|
||||
const toneClasses: Record<ChipTone, string> = {
|
||||
neutral: "app-chip-subtle",
|
||||
brand: "app-chip-brand",
|
||||
attention: "app-chip-attention",
|
||||
danger: "app-chip-danger",
|
||||
success: "app-chip-success",
|
||||
running: "app-chip-running",
|
||||
info:
|
||||
"border-[color:var(--app-info-border)] bg-[color:var(--app-info-background)] text-[color:var(--app-info-text)]",
|
||||
muted:
|
||||
"border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] text-[color:var(--app-text-muted)]",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "min-h-6 px-2 py-0.5 text-[0.75rem] leading-none",
|
||||
sm: "min-h-7 px-2.5 py-1 text-xs leading-none",
|
||||
md: "min-h-8 px-3 py-1.5 text-sm leading-none",
|
||||
};
|
||||
|
||||
const shapeClasses = {
|
||||
pill: "rounded-full",
|
||||
rounded: "rounded-md",
|
||||
};
|
||||
|
||||
export default function Chip({
|
||||
tone = "neutral",
|
||||
toneClassName,
|
||||
size = "xs",
|
||||
shape = "pill",
|
||||
outlined = false,
|
||||
uppercase = false,
|
||||
nowrap = true,
|
||||
className,
|
||||
...props
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex items-center justify-center gap-1 align-middle font-medium",
|
||||
outlined && "border",
|
||||
sizeClasses[size],
|
||||
shapeClasses[shape],
|
||||
uppercase && "uppercase tracking-[0.08em]",
|
||||
nowrap ? "whitespace-nowrap" : "whitespace-normal",
|
||||
toneClasses[tone],
|
||||
toneClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../test/renderWithProviders";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("binds the dialog role to the panel instead of a full-screen wrapper", async () => {
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
title="Stop this topic?"
|
||||
description="This action ends all running work for the topic."
|
||||
confirmLabel="Confirm stop"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Stop this topic?" });
|
||||
expect(dialog).toHaveClass("max-w-md");
|
||||
expect(dialog).not.toHaveClass("inset-0");
|
||||
expect(within(dialog).getByRole("button", { name: "Confirm stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on backdrop click and escape", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
title="Stop this topic?"
|
||||
confirmLabel="Confirm stop"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
{ locale: "en" },
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Stop this topic?" });
|
||||
const backdrop = dialog.parentElement?.previousElementSibling;
|
||||
if (!(backdrop instanceof HTMLElement)) {
|
||||
throw new Error("dialog backdrop not found");
|
||||
}
|
||||
|
||||
await user.click(backdrop);
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(onOpenChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "./Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from "./Dialog";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
confirmLabel: ReactNode;
|
||||
cancelLabel: ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmTone?: "neutral" | "brand" | "success" | "danger";
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
confirmTone = "danger",
|
||||
busy = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay asChild>
|
||||
<motion.div
|
||||
className="app-dialog-backdrop-strong fixed inset-0 z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
/>
|
||||
</DialogOverlay>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 pointer-events-none sm:p-4">
|
||||
<DialogContent asChild aria-describedby={undefined}>
|
||||
<motion.div
|
||||
className="pointer-events-auto w-full max-w-md outline-none"
|
||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: "spring", damping: 26, stiffness: 320 }}
|
||||
>
|
||||
<div className="app-overlay-panel relative overflow-hidden rounded-[30px]">
|
||||
<div className="app-overlay-glow pointer-events-none absolute inset-x-0 top-0 h-24 opacity-60" />
|
||||
<div className="relative px-6 pb-5 pt-6 sm:px-7">
|
||||
<DialogTitle className="app-text-primary text-lg font-semibold tracking-[-0.01em]">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description ? (
|
||||
<DialogDescription className="app-text-faint mt-3 max-w-[34ch] text-sm leading-6">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)]/60 px-6 py-4 sm:flex-row sm:justify-end sm:px-7">
|
||||
<DialogClose asChild>
|
||||
<Button variant="soft" size="sm" disabled={busy} className="min-w-24">
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="solid"
|
||||
tone={confirmTone}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
className="min-w-24"
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
Root as Dialog,
|
||||
Trigger as DialogTrigger,
|
||||
Portal as DialogPortal,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Close as DialogClose,
|
||||
Title as DialogTitle,
|
||||
Description as DialogDescription,
|
||||
} from "@radix-ui/react-dialog";
|
||||
@@ -0,0 +1,35 @@
|
||||
import InsetPanel from "./InsetPanel";
|
||||
|
||||
interface DiffViewerProps {
|
||||
diff: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DiffViewer({ diff, className }: DiffViewerProps) {
|
||||
if (!diff) return null;
|
||||
|
||||
return (
|
||||
<InsetPanel
|
||||
tone="code"
|
||||
padding="sm"
|
||||
className={`mt-2 max-h-96 overflow-auto font-mono text-xs leading-5 ${className ?? ""}`.trim()}
|
||||
>
|
||||
{diff.split("\n").map((line, index) => {
|
||||
let lineClassName = "app-text-faint px-3";
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
lineClassName = "app-code-line-added px-3";
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
lineClassName = "app-code-line-removed px-3";
|
||||
} else if (line.startsWith("@@")) {
|
||||
lineClassName = "app-code-line-meta px-3";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${index}-${line}`} className={lineClassName}>
|
||||
<span className="select-all whitespace-pre">{line}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</InsetPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface EditorActionBarProps {
|
||||
children: ReactNode;
|
||||
error?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EditorActionBar({
|
||||
children,
|
||||
error,
|
||||
leading,
|
||||
className,
|
||||
}: EditorActionBarProps) {
|
||||
return (
|
||||
<div className={cx("flex items-center gap-2 border-t border-[color:var(--app-divider)] px-4 py-3", className)}>
|
||||
{leading}
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="app-text-danger app-caption mr-2 mt-0 flex-1 text-xs"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface EditorShellProps {
|
||||
header?: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
bodyClassName?: string;
|
||||
sidebarClassName?: string;
|
||||
mainClassName?: string;
|
||||
}
|
||||
|
||||
export default function EditorShell({
|
||||
header,
|
||||
sidebar,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
bodyClassName,
|
||||
sidebarClassName,
|
||||
mainClassName,
|
||||
}: EditorShellProps) {
|
||||
return (
|
||||
<div className={cx("flex h-full min-h-0 flex-col overflow-hidden", className)}>
|
||||
{header}
|
||||
<div className={cx("flex min-h-0 flex-1 flex-col", contentClassName)}>
|
||||
<div className={cx("flex min-h-0 flex-1 flex-col xl:flex-row", bodyClassName)}>
|
||||
{sidebar ? (
|
||||
<aside className={cx("shrink-0 border-b border-[color:var(--app-divider)] xl:w-[18rem] xl:border-b-0 xl:border-r", sidebarClassName)}>
|
||||
{sidebar}
|
||||
</aside>
|
||||
) : null}
|
||||
<div className={cx("min-h-0 flex-1 overflow-y-auto", mainClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface FormFieldProps {
|
||||
children: ReactNode;
|
||||
label?: ReactNode;
|
||||
htmlFor?: string;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
labelClassName?: string;
|
||||
hintClassName?: string;
|
||||
errorClassName?: string;
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
children,
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
contentClassName,
|
||||
labelClassName,
|
||||
hintClassName,
|
||||
errorClassName,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className={cx("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cx("app-text-soft app-overline mb-1 block", labelClassName)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<div className={contentClassName}>{children}</div>
|
||||
{hint ? (
|
||||
<div className={cx("app-text-faint app-caption mt-1", hintClassName)}>{hint}</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div role="alert" className={cx("app-text-danger app-caption mt-2", errorClassName)}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FormEvent, ReactNode, Ref } from "react";
|
||||
import Button from "./Button";
|
||||
import InsetPanel from "./InsetPanel";
|
||||
import TextInput from "./TextInput";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface InlineComposerProps {
|
||||
label: ReactNode;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
submitLabel: ReactNode;
|
||||
disabled?: boolean;
|
||||
inputId: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
className?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export default function InlineComposer({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
hint,
|
||||
error,
|
||||
submitLabel,
|
||||
disabled = false,
|
||||
inputId,
|
||||
inputRef,
|
||||
className,
|
||||
action,
|
||||
}: InlineComposerProps) {
|
||||
return (
|
||||
<InsetPanel className={cx("space-y-2.5", className)} padding="sm" as="form" onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={inputId} className="app-text-soft app-overline mb-1 block">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
{action ?? (
|
||||
<Button type="submit" disabled={disabled || !value.trim()} size="xs" variant="solid" tone="brand">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{hint ? <div className="app-text-faint app-caption mt-1">{hint}</div> : null}
|
||||
{error ? (
|
||||
<div role="alert" className="app-text-danger app-caption mt-2">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</InsetPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ComponentPropsWithoutRef, ElementType } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type InsetPanelProps<T extends ElementType = "div"> = {
|
||||
as?: T;
|
||||
tone?: "neutral" | "danger" | "code";
|
||||
padding?: "sm" | "md";
|
||||
} & ComponentPropsWithoutRef<T>;
|
||||
|
||||
const toneClasses = {
|
||||
neutral:
|
||||
"border border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)]",
|
||||
danger:
|
||||
"border border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)]",
|
||||
code:
|
||||
"border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-code)]",
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
sm: "px-3 py-2",
|
||||
md: "p-4",
|
||||
};
|
||||
|
||||
export default function InsetPanel<T extends ElementType = "div">({
|
||||
as,
|
||||
tone = "neutral",
|
||||
padding = "md",
|
||||
className,
|
||||
...props
|
||||
}: InsetPanelProps<T>) {
|
||||
const Component = as ?? "div";
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cx(
|
||||
"rounded-lg",
|
||||
toneClasses[tone],
|
||||
paddingClasses[padding],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Card from "./Card";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface PageHeroProps {
|
||||
eyebrow?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
contentFooter?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
stats?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
layout?: "split" | "stack";
|
||||
align?: "start" | "end";
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
titleClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
statsClassName?: string;
|
||||
}
|
||||
|
||||
export default function PageHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
contentFooter,
|
||||
headerActions,
|
||||
stats,
|
||||
footer,
|
||||
layout = "split",
|
||||
align = "end",
|
||||
className,
|
||||
contentClassName,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
statsClassName,
|
||||
}: PageHeroProps) {
|
||||
return (
|
||||
<Card
|
||||
variant="hero"
|
||||
className={cx("rounded-[28px] px-5 py-5 sm:px-6 sm:py-6", className)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"gap-5",
|
||||
layout === "stack"
|
||||
? "flex flex-col"
|
||||
: "flex flex-col lg:flex-row lg:justify-between",
|
||||
layout !== "stack" && (align === "start" ? "lg:items-start" : "lg:items-end"),
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
layout === "stack" ? "max-w-5xl" : "max-w-3xl",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cx(headerActions ? "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" : undefined)}>
|
||||
<div>
|
||||
{eyebrow ? <p className="app-kicker">{eyebrow}</p> : null}
|
||||
<h2
|
||||
className={cx(
|
||||
"app-display mt-3 text-[clamp(1.7rem,3vw,2.45rem)] font-semibold leading-[1.04] text-balance app-text-primary",
|
||||
titleClassName,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p
|
||||
className={cx(
|
||||
"app-text-faint mt-3 max-w-[62ch] text-sm leading-7 sm:text-[15px]",
|
||||
descriptionClassName,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{contentFooter ? <div className="mt-3">{contentFooter}</div> : null}
|
||||
</div>
|
||||
{headerActions ? <div className="flex shrink-0 items-center gap-2">{headerActions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{stats ? <div className={statsClassName}>{stats}</div> : null}
|
||||
</div>
|
||||
{footer ? <div className="mt-5">{footer}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Card from "./Card";
|
||||
import PanelHeader from "./PanelHeader";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface PageSectionCardProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export default function PageSectionCard({
|
||||
title,
|
||||
children,
|
||||
eyebrow,
|
||||
detail,
|
||||
action,
|
||||
className,
|
||||
headerClassName,
|
||||
bodyClassName,
|
||||
}: PageSectionCardProps) {
|
||||
return (
|
||||
<Card className={cx("overflow-hidden rounded-[24px]", className)}>
|
||||
<PanelHeader
|
||||
variant="section"
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
detail={detail}
|
||||
action={action}
|
||||
className={headerClassName}
|
||||
/>
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
titleAs?: "h2" | "h3" | "div" | "span";
|
||||
eyebrow?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
action?: ReactNode;
|
||||
variant?: "compact" | "section";
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
titleClassName?: string;
|
||||
detailClassName?: string;
|
||||
actionClassName?: string;
|
||||
titleId?: string;
|
||||
}
|
||||
|
||||
export default function PanelHeader({
|
||||
title,
|
||||
titleAs,
|
||||
eyebrow,
|
||||
detail,
|
||||
leading,
|
||||
trailing,
|
||||
action,
|
||||
variant = "compact",
|
||||
className,
|
||||
contentClassName,
|
||||
titleClassName,
|
||||
detailClassName,
|
||||
actionClassName,
|
||||
titleId,
|
||||
}: PanelHeaderProps) {
|
||||
const TitleElement = titleAs ?? (titleId ? "h2" : "span");
|
||||
const trailingNode = action ?? trailing;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
variant === "section"
|
||||
? "border-b border-[color:var(--app-divider-soft)] px-5 py-5 sm:px-6"
|
||||
: "flex items-center gap-2 border-b border-[color:var(--app-divider)] px-3 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{variant === "section" ? (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className={cx("min-w-0", contentClassName)}>
|
||||
{eyebrow ? <p className="app-kicker">{eyebrow}</p> : null}
|
||||
<TitleElement
|
||||
id={titleId}
|
||||
className={cx("app-text-primary mt-2 text-lg font-semibold", titleClassName)}
|
||||
>
|
||||
{title}
|
||||
</TitleElement>
|
||||
{detail ? (
|
||||
<p className={cx("app-text-faint mt-3 text-sm leading-6", detailClassName)}>
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{trailingNode ? <div className={cx("shrink-0", actionClassName)}>{trailingNode}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{leading}
|
||||
<TitleElement
|
||||
id={titleId}
|
||||
className={cx(
|
||||
"app-overline app-text-soft",
|
||||
titleClassName,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</TitleElement>
|
||||
{trailingNode && <div className={cx("ml-auto", actionClassName)}>{trailingNode}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
Root as Popover,
|
||||
Trigger as PopoverTrigger,
|
||||
Portal as PopoverPortal,
|
||||
Content as PopoverContent,
|
||||
Anchor as PopoverAnchor,
|
||||
Close as PopoverClose,
|
||||
} from "@radix-ui/react-popover";
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ChangeEventHandler, ReactNode } from "react";
|
||||
import Button from "./Button";
|
||||
import PageSectionCard from "./PageSectionCard";
|
||||
import TextareaField from "./TextareaField";
|
||||
|
||||
interface ResponseComposerProps {
|
||||
eyebrow?: ReactNode;
|
||||
title: ReactNode;
|
||||
detail?: ReactNode;
|
||||
prompt?: ReactNode;
|
||||
promptTone?: "neutral" | "danger";
|
||||
topSlot?: ReactNode;
|
||||
textareaId?: string;
|
||||
textareaLabel?: string;
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLTextAreaElement>;
|
||||
placeholder?: string;
|
||||
error?: ReactNode;
|
||||
actionLabel: ReactNode;
|
||||
sendingLabel: ReactNode;
|
||||
sending?: boolean;
|
||||
disabled?: boolean;
|
||||
onSubmit: () => void;
|
||||
headerAction?: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export default function ResponseComposer({
|
||||
eyebrow,
|
||||
title,
|
||||
detail,
|
||||
prompt,
|
||||
promptTone = "neutral",
|
||||
topSlot,
|
||||
textareaId,
|
||||
textareaLabel,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
actionLabel,
|
||||
sendingLabel,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
onSubmit,
|
||||
headerAction,
|
||||
className,
|
||||
bodyClassName = "space-y-3 px-5 py-5 sm:px-6",
|
||||
}: ResponseComposerProps) {
|
||||
return (
|
||||
<PageSectionCard
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
detail={detail}
|
||||
action={headerAction}
|
||||
className={className}
|
||||
bodyClassName={bodyClassName}
|
||||
>
|
||||
{topSlot}
|
||||
{prompt ? (
|
||||
<div
|
||||
className={
|
||||
promptTone === "danger"
|
||||
? "rounded-2xl border border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)] px-4 py-4 whitespace-pre-wrap"
|
||||
: "rounded-2xl border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)] px-4 py-4 whitespace-pre-wrap"
|
||||
}
|
||||
>
|
||||
{prompt}
|
||||
</div>
|
||||
) : null}
|
||||
<TextareaField
|
||||
id={textareaId}
|
||||
label={textareaLabel}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
error={typeof error === "string" ? error : undefined}
|
||||
disabled={disabled || sending}
|
||||
textareaClassName="min-h-24"
|
||||
/>
|
||||
{error && typeof error !== "string" ? <div>{error}</div> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!value.trim() || disabled || sending}
|
||||
variant="solid"
|
||||
tone="brand"
|
||||
>
|
||||
{sending ? sendingLabel : actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</PageSectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
export interface SelectableListItem<T extends string> {
|
||||
id: T;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
interface SelectableListProps<T extends string> {
|
||||
items: ReadonlyArray<SelectableListItem<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
activeItemClassName?: string;
|
||||
}
|
||||
|
||||
export default function SelectableList<T extends string>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
itemClassName,
|
||||
activeItemClassName,
|
||||
}: SelectableListProps<T>) {
|
||||
return (
|
||||
<div className={cx("space-y-2", className)}>
|
||||
{items.map((item) => {
|
||||
const active = item.id === value;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onChange(item.id)}
|
||||
className={cx(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-colors",
|
||||
active
|
||||
? "border-[color:var(--app-accent)] bg-[color:var(--app-attention-background)]"
|
||||
: "border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] hover:bg-[color:var(--app-surface-elevated)]",
|
||||
itemClassName,
|
||||
active && activeItemClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium app-text-primary">{item.title}</p>
|
||||
{item.description ? (
|
||||
<div className="mt-1 text-xs app-text-soft">{item.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{item.meta ? <div className="shrink-0">{item.meta}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { stageTone } from "../../styles/tokens";
|
||||
import { useI18n } from "../../i18n";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
interface StageBadgeProps extends ComponentPropsWithoutRef<"span"> {
|
||||
stage: string;
|
||||
label?: string;
|
||||
size?: "xs" | "sm";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "xs",
|
||||
sm: "sm",
|
||||
} as const;
|
||||
|
||||
export default function StageBadge({
|
||||
stage,
|
||||
label,
|
||||
size = "xs",
|
||||
className,
|
||||
...props
|
||||
}: StageBadgeProps) {
|
||||
const { formatStageLabel } = useI18n();
|
||||
const tone = stageTone[stage];
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
size={sizeClasses[size]}
|
||||
toneClassName={
|
||||
tone?.cls
|
||||
|| "bg-[color:var(--app-surface-muted)] text-[color:var(--app-text-muted)] border-[color:var(--app-divider)]"
|
||||
}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{label ?? formatStageLabel(stage) ?? tone?.label ?? stage}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import Chip from "./Chip";
|
||||
|
||||
interface StatusBadgeProps extends ComponentPropsWithoutRef<"span"> {
|
||||
tone?:
|
||||
| "neutral"
|
||||
| "brand"
|
||||
| "attention"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "running"
|
||||
| "info"
|
||||
| "muted";
|
||||
toneClassName?: string;
|
||||
size?: "xs" | "sm" | "md";
|
||||
uppercase?: boolean;
|
||||
nowrap?: boolean;
|
||||
}
|
||||
|
||||
export default function StatusBadge({
|
||||
tone = "neutral",
|
||||
toneClassName,
|
||||
size = "xs",
|
||||
uppercase = false,
|
||||
nowrap = true,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
return (
|
||||
<Chip
|
||||
tone={tone}
|
||||
toneClassName={toneClassName}
|
||||
size={size}
|
||||
outlined
|
||||
uppercase={uppercase}
|
||||
nowrap={nowrap}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type StatusDotProps = ComponentPropsWithoutRef<"span">;
|
||||
|
||||
export default function StatusDot({ className, ...props }: StatusDotProps) {
|
||||
return <span className={cx("inline-flex rounded-full", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
type SummaryStatTone = "default" | "attention" | "muted";
|
||||
|
||||
const valueToneClasses: Record<SummaryStatTone, string> = {
|
||||
default: "app-text-primary",
|
||||
attention: "app-text-attention",
|
||||
muted: "app-text-muted",
|
||||
};
|
||||
|
||||
export default function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone = "default",
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
detail: ReactNode;
|
||||
tone?: SummaryStatTone;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"min-w-0 border-l border-[color:var(--app-divider-soft)] pl-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="app-text-soft app-overline">{label}</p>
|
||||
<p
|
||||
className={cx(
|
||||
"mt-3 text-[clamp(1.45rem,2vw,2rem)] font-semibold leading-none",
|
||||
valueToneClasses[tone],
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p className="app-text-faint mt-2 text-sm leading-6">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
export interface TabItem<T extends string> {
|
||||
id: T;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps<T extends string> {
|
||||
items: ReadonlyArray<TabItem<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
listClassName?: string;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
export default function Tabs<T extends string>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
listClassName,
|
||||
triggerClassName,
|
||||
}: TabsProps<T>) {
|
||||
return (
|
||||
<div className={cx("border-b border-[color:var(--app-divider)]", className)}>
|
||||
<div className={cx("flex", listClassName)}>
|
||||
{items.map((item) => {
|
||||
const active = item.id === value;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
disabled={item.disabled}
|
||||
onClick={() => onChange(item.id)}
|
||||
className={cx(
|
||||
"flex-1 px-4 py-2.5 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-b-2 border-[color:var(--app-accent)] app-text-primary"
|
||||
: "app-text-soft hover:app-text-primary",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
export const textInputBaseClassName =
|
||||
"w-full rounded-lg border border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] px-3 py-2 text-sm app-text-primary placeholder:app-text-faint focus:outline-none focus:ring-1 focus:ring-[color:var(--app-accent)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
interface TextInputProps extends ComponentPropsWithoutRef<"input"> {
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
||||
{ className, invalid = false, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cx(
|
||||
textInputBaseClassName,
|
||||
invalid && "border-[color:var(--app-danger-border)] focus:ring-[color:var(--app-danger-border)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default TextInput;
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from "react";
|
||||
import FormField from "./FormField";
|
||||
import { cx } from "./cx";
|
||||
import { textInputBaseClassName } from "./TextInput";
|
||||
|
||||
interface TextareaFieldProps extends ComponentPropsWithoutRef<"textarea"> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
invalid?: boolean;
|
||||
fieldClassName?: string;
|
||||
textareaClassName?: string;
|
||||
labelClassName?: string;
|
||||
hintClassName?: string;
|
||||
errorClassName?: string;
|
||||
}
|
||||
|
||||
const TextareaField = forwardRef<HTMLTextAreaElement, TextareaFieldProps>(function TextareaField(
|
||||
{
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
invalid = false,
|
||||
fieldClassName,
|
||||
textareaClassName,
|
||||
labelClassName,
|
||||
hintClassName,
|
||||
errorClassName,
|
||||
id,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
label={label}
|
||||
htmlFor={id}
|
||||
hint={hint}
|
||||
error={error}
|
||||
className={fieldClassName}
|
||||
labelClassName={labelClassName}
|
||||
hintClassName={hintClassName}
|
||||
errorClassName={errorClassName}
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cx(
|
||||
textInputBaseClassName,
|
||||
"resize-y",
|
||||
invalid && "border-[color:var(--app-danger-border)] focus:ring-[color:var(--app-danger-border)]",
|
||||
textareaClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
});
|
||||
|
||||
export default TextareaField;
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, type HTMLMotionProps } from "framer-motion";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface TopicRailItemProps {
|
||||
active?: boolean;
|
||||
activeDecoration?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
deleteButtonClassName?: string;
|
||||
deleteLabel?: string;
|
||||
motionProps?: HTMLMotionProps<"div">;
|
||||
onClick: () => void;
|
||||
onDelete?: () => void;
|
||||
trailingContent?: ReactNode;
|
||||
}
|
||||
|
||||
export default function TopicRailItem({
|
||||
active = false,
|
||||
activeDecoration,
|
||||
children,
|
||||
className,
|
||||
buttonClassName,
|
||||
deleteButtonClassName,
|
||||
deleteLabel,
|
||||
motionProps,
|
||||
onClick,
|
||||
onDelete,
|
||||
trailingContent,
|
||||
}: TopicRailItemProps) {
|
||||
const { className: motionClassName, ...restMotionProps } = motionProps ?? {};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
{...restMotionProps}
|
||||
className={cx(
|
||||
"relative flex items-center gap-2 overflow-hidden",
|
||||
className,
|
||||
motionClassName,
|
||||
)}
|
||||
>
|
||||
{active ? activeDecoration : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cx("relative z-10 min-w-0 flex-1 text-left", buttonClassName)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{trailingContent}
|
||||
{onDelete && deleteLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className={cx(
|
||||
"app-text-faint hover:app-text-danger relative z-10 flex h-11 w-11 shrink-0 touch-manipulation items-center justify-center rounded-lg transition-[background-color,color,opacity] hover:bg-[color:var(--app-surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--app-accent)] focus-visible:ring-offset-2",
|
||||
deleteButtonClassName,
|
||||
)}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="h-3.5 w-3.5"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5A.75.75 0 0 1 9.95 6Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "../../test/renderWithProviders";
|
||||
import ViewState from "./ViewState";
|
||||
|
||||
describe("ViewState", () => {
|
||||
it("renders the structured variant with eyebrow, detail, and action", () => {
|
||||
renderWithProviders(
|
||||
<ViewState
|
||||
eyebrow="Activity feed"
|
||||
title="No activity yet"
|
||||
detail="Messages will appear here after the first handoff."
|
||||
action={<button type="button">Retry</button>}
|
||||
surface
|
||||
align="start"
|
||||
size="lg"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Activity feed")).toBeInTheDocument();
|
||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Messages will appear here after the first handoff."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the children-only variant without requiring structured props", () => {
|
||||
renderWithProviders(<ViewState>Plain fallback content</ViewState>);
|
||||
|
||||
expect(screen.getByText("Plain fallback content")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No activity yet")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "./cx";
|
||||
|
||||
interface ViewStateProps {
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
action?: ReactNode;
|
||||
tone?: "neutral" | "error";
|
||||
className?: string;
|
||||
eyebrow?: ReactNode;
|
||||
align?: "center" | "start";
|
||||
surface?: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export default function ViewState({
|
||||
children,
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
tone = "neutral",
|
||||
className,
|
||||
eyebrow,
|
||||
align = "center",
|
||||
surface = false,
|
||||
size = "sm",
|
||||
}: ViewStateProps) {
|
||||
const isStructured = title || detail || action;
|
||||
const isLarge = size === "lg";
|
||||
const isStartAligned = align === "start";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex items-center justify-center py-12 sm:py-16",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isStructured ? (
|
||||
<div
|
||||
className={cx(
|
||||
"w-full",
|
||||
isLarge ? "max-w-4xl" : "max-w-xl",
|
||||
isStartAligned ? "text-left" : "text-center",
|
||||
surface && "app-panel relative overflow-hidden rounded-[28px] px-6 py-6 sm:px-8 sm:py-8",
|
||||
)}
|
||||
>
|
||||
{surface && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="app-overlay-glow pointer-events-none absolute inset-0 opacity-80"
|
||||
/>
|
||||
)}
|
||||
<div className="relative">
|
||||
{eyebrow && (
|
||||
<p className="app-kicker">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
{title && (
|
||||
<p
|
||||
className={cx(
|
||||
isLarge
|
||||
? "app-display mt-3 text-[clamp(1.75rem,2.8vw,2.6rem)] font-semibold leading-[1.05] text-balance"
|
||||
: "mt-2 text-sm font-medium",
|
||||
tone === "error" ? "app-text-danger" : "app-text-primary",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
{detail && (
|
||||
<p
|
||||
className={cx(
|
||||
isLarge
|
||||
? "mt-3 max-w-[62ch] text-[15px] leading-7 sm:text-base"
|
||||
: "mt-2 text-sm leading-relaxed",
|
||||
!isStartAligned && "mx-auto",
|
||||
tone === "error" ? "app-text-danger" : "app-text-faint",
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
{children && (
|
||||
<div
|
||||
className={cx(
|
||||
isLarge ? "mt-6" : "mt-3 text-sm",
|
||||
tone === "error" ? "app-text-danger" : "app-text-faint",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{action && (
|
||||
<div
|
||||
className={cx(
|
||||
isLarge ? "mt-6 flex flex-wrap gap-3" : "mt-4 flex",
|
||||
isStartAligned ? "justify-start" : "justify-center",
|
||||
)}
|
||||
>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={tone === "error" ? "app-text-danger" : "app-text-faint"}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ViewState from "./ViewState";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
interface WorkspaceRequiredStateProps {
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export default function WorkspaceRequiredState({
|
||||
subject = "data",
|
||||
}: WorkspaceRequiredStateProps) {
|
||||
const { copy } = useI18n();
|
||||
|
||||
return (
|
||||
<ViewState
|
||||
eyebrow={copy.workspaceRequired.eyebrow}
|
||||
title={copy.workspaceRequired.title}
|
||||
detail={copy.workspaceRequired.detail(subject)}
|
||||
align="start"
|
||||
size="lg"
|
||||
surface
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cx(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ConsoleCopy } from "./model";
|
||||
|
||||
export function getConsoleCopy(locale: string): ConsoleCopy {
|
||||
if (locale === "zh-CN") {
|
||||
return {
|
||||
heroEyebrow: "Leader 控制台",
|
||||
emptyTitle: "先创建一个主题,再把第一条消息发给 leader",
|
||||
emptyDetail:
|
||||
"一个主题对应一个明确目标。创建后,你可以直接把需求、限制或 blocker 发给 leader,让执行从这里开始推进。",
|
||||
startTopic: "开始第一个主题",
|
||||
leaderThread: "Leader 线程",
|
||||
executionFeed: "执行回报",
|
||||
executionEmpty: "当前还没有执行事件。",
|
||||
executionMapTitle: "执行画布",
|
||||
executionMapEmpty: "当前还没有可视化任务节点。",
|
||||
lanesTitle: "执行 lane",
|
||||
lanesEmpty: "调度器还没有派生 lane。",
|
||||
tasksTitle: "任务图",
|
||||
tasksEmpty: "当前主题还没有 task。",
|
||||
messageLeaderOnly: "消息会直接发送给 leader。",
|
||||
sendToLeader: "发给 Leader",
|
||||
summaryLanes: "Lanes",
|
||||
summaryTasks: "任务",
|
||||
summaryRunning: "运行中",
|
||||
summaryWaiting: "待推进",
|
||||
};
|
||||
}
|
||||
return {
|
||||
heroEyebrow: "Leader Console",
|
||||
emptyTitle: "Create a topic and send the opening message to the leader",
|
||||
emptyDetail:
|
||||
"A topic should map to one clear outcome. Once created, you can send the goal, constraints, or blockers to the leader and drive execution from here.",
|
||||
startTopic: "Start first topic",
|
||||
leaderThread: "Leader thread",
|
||||
executionFeed: "Execution feed",
|
||||
executionEmpty: "No execution events yet.",
|
||||
executionMapTitle: "Execution map",
|
||||
executionMapEmpty: "No task nodes available for the execution map yet.",
|
||||
lanesTitle: "Execution lanes",
|
||||
lanesEmpty: "The scheduler has not derived any lanes yet.",
|
||||
tasksTitle: "Task graph",
|
||||
tasksEmpty: "No tasks yet for this topic.",
|
||||
messageLeaderOnly: "Messages from this console always go to the leader.",
|
||||
sendToLeader: "Send to Leader",
|
||||
summaryLanes: "Lanes",
|
||||
summaryTasks: "Tasks",
|
||||
summaryRunning: "Running",
|
||||
summaryWaiting: "Waiting",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkflowBoardLane, WorkflowBoardTask } from "../../types";
|
||||
import { buildExecutionFlowGraph } from "./executionFlow";
|
||||
|
||||
function makeLane(id: string, name: string): WorkflowBoardLane {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug: id,
|
||||
purpose: `${name} purpose`,
|
||||
status: "ready",
|
||||
branch_name: `lane/main/${id}`,
|
||||
worktree_path: `/tmp/${id}`,
|
||||
container_name: `container-${id}`,
|
||||
runtime_endpoint: "http://127.0.0.1:40123",
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(id: string, laneId: string, dependencies: string[] = []): WorkflowBoardTask {
|
||||
return {
|
||||
id,
|
||||
lane_id: laneId,
|
||||
title: id,
|
||||
status: "ready",
|
||||
priority: 1,
|
||||
task_order: 1,
|
||||
dependencies: dependencies.map((depends_on_task_id) => ({ depends_on_task_id })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildExecutionFlowGraph", () => {
|
||||
it("places lanes in dependency order from left to right", () => {
|
||||
const lanes = [
|
||||
makeLane("lane_b", "Backend"),
|
||||
makeLane("lane_c", "QA"),
|
||||
makeLane("lane_a", "Frontend"),
|
||||
];
|
||||
const tasks = [
|
||||
makeTask("task_a", "lane_a"),
|
||||
makeTask("task_b", "lane_b", ["task_a"]),
|
||||
makeTask("task_c", "lane_c", ["task_b"]),
|
||||
];
|
||||
|
||||
const graph = buildExecutionFlowGraph(lanes, tasks);
|
||||
|
||||
expect(graph.nodes.map((node) => node.id)).toEqual(["lane_a", "lane_b", "lane_c"]);
|
||||
expect(graph.nodes.map((node) => node.position.x)).toEqual([0, 432, 864]);
|
||||
expect(graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toEqual([
|
||||
"lane_a->lane_b",
|
||||
"lane_b->lane_c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates multiple cross-lane dependencies into one edge", () => {
|
||||
const lanes = [
|
||||
makeLane("lane_a", "Frontend"),
|
||||
makeLane("lane_b", "Backend"),
|
||||
];
|
||||
const tasks = [
|
||||
makeTask("task_a1", "lane_a"),
|
||||
makeTask("task_a2", "lane_a"),
|
||||
makeTask("task_b1", "lane_b", ["task_a1", "task_a2"]),
|
||||
];
|
||||
|
||||
const graph = buildExecutionFlowGraph(lanes, tasks);
|
||||
|
||||
expect(graph.edges).toHaveLength(1);
|
||||
expect(graph.edges[0]?.label).toBe("2 deps");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import type {
|
||||
WorkflowBoardLane,
|
||||
WorkflowBoardLaneSync,
|
||||
WorkflowBoardTask,
|
||||
} from "../../types";
|
||||
import Chip from "../ui/Chip";
|
||||
import StatusBadge from "../ui/StatusBadge";
|
||||
import { layoutLanesByDependencies, statusTone } from "./helpers";
|
||||
|
||||
const LANE_WIDTH = 360;
|
||||
const LANE_GAP = 72;
|
||||
const LANE_TOP = 24;
|
||||
|
||||
interface ExecutionLaneTaskView {
|
||||
id: string;
|
||||
title: string;
|
||||
kind?: string;
|
||||
status: string;
|
||||
taskOrder: number;
|
||||
dependencyCount: number;
|
||||
dependencySummary: string | null;
|
||||
deliverables: string[];
|
||||
}
|
||||
|
||||
interface LaneFlowData extends Record<string, unknown> {
|
||||
lane: WorkflowBoardLane;
|
||||
tasks: ExecutionLaneTaskView[];
|
||||
runtimeEndpoint: string;
|
||||
branchName: string;
|
||||
worktree: string;
|
||||
headCommit: string;
|
||||
lastSync?: WorkflowBoardLaneSync | null;
|
||||
syncPreview: WorkflowBoardLaneSync[];
|
||||
}
|
||||
|
||||
type LaneFlowNode = Node<LaneFlowData, "laneColumn">;
|
||||
|
||||
function compactLabel(value?: string | null): string {
|
||||
return (value ?? "").trim() || "-";
|
||||
}
|
||||
|
||||
function compactPathTail(value?: string | null, segments = 2): string {
|
||||
const normalized = compactLabel(value);
|
||||
if (normalized === "-") return normalized;
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts.slice(-segments).join("/");
|
||||
}
|
||||
|
||||
function compactEndpoint(value?: string | null): string {
|
||||
const normalized = compactLabel(value);
|
||||
if (normalized === "-") return normalized;
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
return `${parsed.hostname}:${parsed.port || (parsed.protocol === "https:" ? "443" : "80")}`;
|
||||
} catch {
|
||||
return normalized.replace(/^https?:\/\//, "");
|
||||
}
|
||||
}
|
||||
|
||||
function compactBranchName(value?: string | null): string {
|
||||
const normalized = compactLabel(value);
|
||||
if (normalized === "-") return normalized;
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function compactCommit(value?: string | null): string {
|
||||
const normalized = compactLabel(value);
|
||||
if (normalized === "-") return normalized;
|
||||
return normalized.slice(0, 8);
|
||||
}
|
||||
|
||||
function taskMetaTone(kind?: string): "brand" | "success" | "neutral" {
|
||||
switch (kind) {
|
||||
case "gate":
|
||||
return "brand";
|
||||
case "verification":
|
||||
return "success";
|
||||
case "milestone":
|
||||
return "neutral";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
function laneTone(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "border-[color:var(--app-success-border)] bg-[color:var(--app-success-background)] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),0_18px_42px_rgba(46,125,50,0.08)]";
|
||||
case "blocked":
|
||||
case "failed":
|
||||
return "border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)] shadow-[inset_0_1px_0_rgba(255,255,255,0.22),0_18px_42px_rgba(198,40,40,0.08)]";
|
||||
case "cancelled":
|
||||
return "border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_18px_42px_rgba(15,23,42,0.05)]";
|
||||
default:
|
||||
return "border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.22),0_18px_42px_rgba(15,23,42,0.06)]";
|
||||
}
|
||||
}
|
||||
|
||||
function taskSurfaceTone(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "ring-1 ring-[color:var(--app-success-border)]";
|
||||
case "blocked":
|
||||
case "failed":
|
||||
return "ring-1 ring-[color:var(--app-danger-border)]";
|
||||
case "cancelled":
|
||||
return "opacity-70";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderDependencySummary(
|
||||
task: WorkflowBoardTask,
|
||||
taskByID: Map<string, WorkflowBoardTask>,
|
||||
) {
|
||||
const dependencyTitles = task.dependencies
|
||||
.map((dependency) => taskByID.get(dependency.depends_on_task_id)?.title)
|
||||
.filter((title): title is string => Boolean(title?.trim()));
|
||||
|
||||
if (dependencyTitles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dependencyTitles.join(" -> ");
|
||||
}
|
||||
|
||||
function estimateTaskHeight(task: ExecutionLaneTaskView) {
|
||||
let height = 148;
|
||||
if (task.dependencySummary) height += 28;
|
||||
if (task.deliverables.length > 0) {
|
||||
height += 46 + Math.ceil(task.deliverables.length / 2) * 30;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
function estimateLaneHeight(tasks: ExecutionLaneTaskView[]) {
|
||||
if (tasks.length === 0) {
|
||||
return 336;
|
||||
}
|
||||
|
||||
const taskHeights = tasks.reduce((total, task) => total + estimateTaskHeight(task), 0);
|
||||
const gaps = Math.max(0, tasks.length - 1) * 12;
|
||||
return Math.max(360, 208 + taskHeights + gaps + 28);
|
||||
}
|
||||
|
||||
interface LaneDependency {
|
||||
sourceLaneId: string;
|
||||
targetLaneId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function buildExecutionFlowGraph(
|
||||
lanes: WorkflowBoardLane[],
|
||||
tasks: WorkflowBoardTask[],
|
||||
): {
|
||||
nodes: LaneFlowNode[];
|
||||
edges: Edge[];
|
||||
} {
|
||||
const orderedLaneLayout = layoutLanesByDependencies(lanes, tasks);
|
||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||
const laneOrder = new Map(orderedLaneLayout.map(({ lane }, index) => [lane.id, index]));
|
||||
const tasksByLane = new Map<string, ExecutionLaneTaskView[]>();
|
||||
|
||||
tasks
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftIndex = laneOrder.get(left.lane_id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightIndex = laneOrder.get(right.lane_id) ?? Number.MAX_SAFE_INTEGER;
|
||||
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
|
||||
if (left.task_order !== right.task_order) return left.task_order - right.task_order;
|
||||
return right.priority - left.priority;
|
||||
})
|
||||
.forEach((task) => {
|
||||
const laneTasks = tasksByLane.get(task.lane_id) ?? [];
|
||||
laneTasks.push({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
kind: task.kind,
|
||||
status: task.status,
|
||||
taskOrder: task.task_order,
|
||||
dependencyCount: task.dependencies.length,
|
||||
dependencySummary: renderDependencySummary(task, taskById),
|
||||
deliverables: task.deliverables ?? [],
|
||||
});
|
||||
tasksByLane.set(task.lane_id, laneTasks);
|
||||
});
|
||||
|
||||
const laneDependencyCounts = new Map<string, LaneDependency>();
|
||||
for (const task of tasks) {
|
||||
for (const dependency of task.dependencies) {
|
||||
const upstreamTask = taskById.get(dependency.depends_on_task_id);
|
||||
if (!upstreamTask || upstreamTask.lane_id === task.lane_id) continue;
|
||||
const key = `${upstreamTask.lane_id}=>${task.lane_id}`;
|
||||
const existing = laneDependencyCounts.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
continue;
|
||||
}
|
||||
laneDependencyCounts.set(key, {
|
||||
sourceLaneId: upstreamTask.lane_id,
|
||||
targetLaneId: task.lane_id,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = orderedLaneLayout.map(({ lane }, index) => {
|
||||
const laneTasks = tasksByLane.get(lane.id) ?? [];
|
||||
return {
|
||||
id: lane.id,
|
||||
type: "laneColumn",
|
||||
position: {
|
||||
x: index * (LANE_WIDTH + LANE_GAP),
|
||||
y: LANE_TOP,
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
data: {
|
||||
lane,
|
||||
tasks: laneTasks,
|
||||
runtimeEndpoint: compactEndpoint(lane.runtime_endpoint),
|
||||
branchName: compactBranchName(lane.branch_name),
|
||||
worktree: compactPathTail(lane.worktree_path),
|
||||
headCommit: compactCommit(lane.head_commit),
|
||||
lastSync: lane.last_sync,
|
||||
syncPreview: (lane.sync_history ?? []).slice(0, 2),
|
||||
},
|
||||
style: {
|
||||
width: LANE_WIDTH,
|
||||
height: estimateLaneHeight(laneTasks),
|
||||
},
|
||||
} satisfies LaneFlowNode;
|
||||
});
|
||||
|
||||
const edges = Array.from(laneDependencyCounts.values())
|
||||
.sort((left, right) => {
|
||||
const leftSource = laneOrder.get(left.sourceLaneId) ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightSource = laneOrder.get(right.sourceLaneId) ?? Number.MAX_SAFE_INTEGER;
|
||||
if (leftSource !== rightSource) return leftSource - rightSource;
|
||||
const leftTarget = laneOrder.get(left.targetLaneId) ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightTarget = laneOrder.get(right.targetLaneId) ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftTarget - rightTarget;
|
||||
})
|
||||
.map((dependency) => ({
|
||||
id: `${dependency.sourceLaneId}->${dependency.targetLaneId}`,
|
||||
source: dependency.sourceLaneId,
|
||||
target: dependency.targetLaneId,
|
||||
sourceHandle: "lane-out",
|
||||
targetHandle: "lane-in",
|
||||
type: "smoothstep",
|
||||
animated: dependency.count > 1,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: "var(--app-accent-strong)",
|
||||
},
|
||||
label: dependency.count > 1 ? `${dependency.count} deps` : undefined,
|
||||
labelStyle: {
|
||||
fill: "var(--app-text-soft)",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: "var(--app-surface-overlay)",
|
||||
fillOpacity: 0.92,
|
||||
},
|
||||
style: {
|
||||
stroke: "var(--app-accent-strong)",
|
||||
strokeWidth: 1.6,
|
||||
},
|
||||
zIndex: 0,
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function LaneColumnNode({ data }: NodeProps<LaneFlowNode>) {
|
||||
const { lane, tasks, runtimeEndpoint, branchName, worktree, headCommit, lastSync, syncPreview } = data;
|
||||
|
||||
return (
|
||||
<div className={`react-flow__lane-column relative h-full w-full overflow-hidden rounded-[30px] border ${laneTone(lane.status)}`}>
|
||||
<Handle
|
||||
id="lane-in"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable={false}
|
||||
className="!h-3 !w-3 !border-[color:var(--app-surface-base)] !bg-[color:var(--app-accent-cool)]"
|
||||
/>
|
||||
<Handle
|
||||
id="lane-out"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
isConnectable={false}
|
||||
className="!h-3 !w-3 !border-[color:var(--app-surface-base)] !bg-[color:var(--app-accent-warm)]"
|
||||
/>
|
||||
<div className="border-b border-[color:var(--app-divider-soft)] px-5 py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-faint text-[10px] font-semibold uppercase tracking-[0.18em]">
|
||||
Lane
|
||||
</p>
|
||||
<p className="app-text-primary text-base font-semibold leading-6">{lane.name}</p>
|
||||
<p className="app-text-faint mt-1 line-clamp-2 text-[12px] leading-5">
|
||||
{lane.purpose || lane.branch_name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge toneClassName={statusTone(lane.status)} uppercase className="shrink-0 text-[10px] tracking-[0.14em]">
|
||||
{lane.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[11px]">
|
||||
<Chip tone="muted">tasks {tasks.length}</Chip>
|
||||
<Chip tone="muted">runtime {runtimeEndpoint}</Chip>
|
||||
<Chip tone="muted">branch {branchName}</Chip>
|
||||
<Chip tone="muted">head {headCommit}</Chip>
|
||||
{lastSync ? <Chip tone="muted">sync {compactLabel(lastSync.status)}</Chip> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-[11px]">
|
||||
<span className="app-text-faint font-semibold uppercase tracking-[0.14em]">
|
||||
{tasks.length === 1 ? "1 Task In This Lane" : `${tasks.length} Tasks In This Lane`}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:var(--app-divider-soft)]" />
|
||||
</div>
|
||||
<p className="app-text-faint mt-2 truncate text-[11px]">worktree {worktree}</p>
|
||||
{lastSync ? (
|
||||
<div className="mt-3 border-t border-[color:var(--app-divider-soft)] pt-3 text-[11px]">
|
||||
<p className="app-text-faint uppercase tracking-[0.12em]">latest materialization</p>
|
||||
<p className="app-text-primary mt-1">
|
||||
{compactLabel(lastSync.status)} from {compactLabel(lastSync.upstream_lane_id)}
|
||||
</p>
|
||||
<p className="app-text-faint mt-1 truncate">
|
||||
upstream {compactCommit(lastSync.upstream_commit)} · merge {compactCommit(lastSync.merge_commit)}
|
||||
</p>
|
||||
{lastSync.error_message ? (
|
||||
<p className="app-text-faint mt-1 line-clamp-2">{lastSync.error_message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{syncPreview.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[10px]">
|
||||
{syncPreview.map((sync) => (
|
||||
<Chip key={`${sync.task_id}-${sync.created_at}`} tone="muted">
|
||||
{compactLabel(sync.status)} {compactLabel(sync.upstream_lane_id)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-base)]/68 px-4 py-4">
|
||||
<p className="app-text-faint text-sm">No tasks have landed in this lane yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<article
|
||||
key={task.id}
|
||||
className={`overflow-hidden rounded-[24px] border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-base)] px-4 py-3.5 shadow-[0_18px_40px_rgba(15,23,42,0.10)] ${taskSurfaceTone(task.status)}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="app-text-faint text-[10px] font-semibold uppercase tracking-[0.18em]">
|
||||
Task {task.taskOrder}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:var(--app-divider-soft)]" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="app-text-primary line-clamp-2 text-[14px] font-semibold leading-5">{task.title}</p>
|
||||
<StatusBadge toneClassName={statusTone(task.status)} uppercase className="shrink-0 text-[10px] tracking-[0.14em]">
|
||||
{task.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
|
||||
<Chip tone={taskMetaTone(task.kind)} uppercase className="tracking-[0.12em]">
|
||||
{compactLabel(task.kind)}
|
||||
</Chip>
|
||||
<Chip tone="muted">deps {task.dependencyCount}</Chip>
|
||||
</div>
|
||||
{task.dependencySummary ? (
|
||||
<p className="app-text-faint mt-3 text-[12px] leading-5">
|
||||
depends on {task.dependencySummary}
|
||||
</p>
|
||||
) : null}
|
||||
{task.deliverables.length > 0 ? (
|
||||
<div className="mt-3 border-t border-[color:var(--app-divider-soft)] pt-3">
|
||||
<p className="app-text-faint text-[10px] uppercase tracking-[0.12em]">deliverables</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{task.deliverables.map((deliverable) => (
|
||||
<Chip key={deliverable} tone="muted">{compactLabel(deliverable)}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
laneColumn: LaneColumnNode,
|
||||
};
|
||||
|
||||
export function ExecutionFlowCanvas({
|
||||
lanes,
|
||||
tasks,
|
||||
}: {
|
||||
lanes: WorkflowBoardLane[];
|
||||
tasks: WorkflowBoardTask[];
|
||||
}) {
|
||||
const { nodes, edges } = buildExecutionFlowGraph(lanes, tasks);
|
||||
|
||||
return (
|
||||
<div className="react-flow__execution-shell h-[min(42rem,72vh)] min-h-[28rem] overflow-hidden rounded-[28px] border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-base)]">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.16, maxZoom: 1 }}
|
||||
minZoom={0.35}
|
||||
maxZoom={1.35}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnDrag
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="react-flow__execution-map"
|
||||
>
|
||||
<Background
|
||||
id="execution-grid"
|
||||
variant={BackgroundVariant.Lines}
|
||||
gap={40}
|
||||
size={1}
|
||||
color="var(--app-divider-soft)"
|
||||
/>
|
||||
<Controls showInteractive={false} position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkflowBoardLane, WorkflowBoardTask } from "../../types";
|
||||
import { layoutLanesByDependencies } from "./helpers";
|
||||
|
||||
function makeLane(id: string, name: string): WorkflowBoardLane {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug: id,
|
||||
status: "ready",
|
||||
branch_name: `lane/main/${id}`,
|
||||
worktree_path: `/tmp/${id}`,
|
||||
container_name: `${id}-container`,
|
||||
runtime_endpoint: "http://127.0.0.1:3000",
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(id: string, laneId: string, dependencies: string[] = []): WorkflowBoardTask {
|
||||
return {
|
||||
id,
|
||||
lane_id: laneId,
|
||||
title: id,
|
||||
status: "draft",
|
||||
priority: 1,
|
||||
task_order: 1,
|
||||
dependencies: dependencies.map((depends_on_task_id) => ({ depends_on_task_id })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("layoutLanesByDependencies", () => {
|
||||
it("orders lanes by cross-lane task dependencies", () => {
|
||||
const lanes = [
|
||||
makeLane("lane_b", "Backend"),
|
||||
makeLane("lane_c", "QA"),
|
||||
makeLane("lane_a", "Frontend"),
|
||||
];
|
||||
const tasks = [
|
||||
makeTask("task_a1", "lane_a"),
|
||||
makeTask("task_b1", "lane_b", ["task_a1"]),
|
||||
makeTask("task_c1", "lane_c", ["task_b1"]),
|
||||
];
|
||||
|
||||
const result = layoutLanesByDependencies(lanes, tasks);
|
||||
|
||||
expect(result.map((item) => item.lane.id)).toEqual(["lane_a", "lane_b", "lane_c"]);
|
||||
expect(result.map((item) => item.layer)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("keeps same-layer roots in original order", () => {
|
||||
const lanes = [
|
||||
makeLane("lane_a", "Frontend"),
|
||||
makeLane("lane_b", "Backend"),
|
||||
makeLane("lane_c", "QA"),
|
||||
];
|
||||
const tasks = [
|
||||
makeTask("task_a1", "lane_a"),
|
||||
makeTask("task_b1", "lane_b"),
|
||||
makeTask("task_c1", "lane_c", ["task_a1", "task_b1"]),
|
||||
];
|
||||
|
||||
const result = layoutLanesByDependencies(lanes, tasks);
|
||||
|
||||
expect(result.map((item) => item.lane.id)).toEqual(["lane_a", "lane_b", "lane_c"]);
|
||||
expect(result.map((item) => item.layer)).toEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
WorkflowBoard,
|
||||
WorkflowBoardLane,
|
||||
WorkflowBoardEvent,
|
||||
WorkflowBoardMessageEvent,
|
||||
WorkflowBoardTask,
|
||||
} from "../../types";
|
||||
|
||||
export function leaderMessages(board: WorkflowBoard | null) {
|
||||
if (!board) return [] as WorkflowBoardMessageEvent[];
|
||||
return board.events.filter(
|
||||
(event): event is WorkflowBoardMessageEvent =>
|
||||
event.kind === "message"
|
||||
&& (
|
||||
event.from === "leader"
|
||||
|| event.from === "user"
|
||||
|| event.from === "worker"
|
||||
|| event.to.includes("leader")
|
||||
),
|
||||
).slice().reverse();
|
||||
}
|
||||
|
||||
export function executionEvents(board: WorkflowBoard | null) {
|
||||
if (!board) return [] as WorkflowBoardEvent[];
|
||||
return board.events.filter((event) => {
|
||||
if (event.kind === "dispatch") return true;
|
||||
return event.kind === "message" && !(event.from === "leader" || event.from === "user");
|
||||
}).slice().reverse();
|
||||
}
|
||||
|
||||
export function taskStats(tasks: WorkflowBoardTask[]) {
|
||||
return tasks.reduce(
|
||||
(summary, task) => {
|
||||
if (task.status === "running") summary.running += 1;
|
||||
if (task.status === "ready" || task.status === "blocked" || task.status === "draft") summary.waiting += 1;
|
||||
return summary;
|
||||
},
|
||||
{ running: 0, waiting: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export interface OrderedLaneLayout {
|
||||
lane: WorkflowBoardLane;
|
||||
layer: number;
|
||||
indexInLayer: number;
|
||||
}
|
||||
|
||||
export function layoutLanesByDependencies(
|
||||
lanes: WorkflowBoardLane[],
|
||||
tasks: WorkflowBoardTask[],
|
||||
): OrderedLaneLayout[] {
|
||||
const laneById = new Map(lanes.map((lane) => [lane.id, lane]));
|
||||
const originalIndex = new Map(lanes.map((lane, index) => [lane.id, index]));
|
||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||
const incomingCount = new Map(lanes.map((lane) => [lane.id, 0]));
|
||||
const outgoing = new Map(lanes.map((lane) => [lane.id, new Set<string>()]));
|
||||
|
||||
for (const task of tasks) {
|
||||
for (const dependency of task.dependencies) {
|
||||
const dependencyTask = taskById.get(dependency.depends_on_task_id);
|
||||
if (!dependencyTask) continue;
|
||||
if (dependencyTask.lane_id === task.lane_id) continue;
|
||||
if (!laneById.has(dependencyTask.lane_id) || !laneById.has(task.lane_id)) continue;
|
||||
|
||||
const upstream = outgoing.get(dependencyTask.lane_id);
|
||||
if (!upstream || upstream.has(task.lane_id)) continue;
|
||||
upstream.add(task.lane_id);
|
||||
incomingCount.set(task.lane_id, (incomingCount.get(task.lane_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const takeSortedRoots = (laneIds: string[]) => laneIds
|
||||
.slice()
|
||||
.sort((left, right) => (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0));
|
||||
|
||||
const remaining = new Set(lanes.map((lane) => lane.id));
|
||||
let roots = takeSortedRoots(
|
||||
lanes
|
||||
.map((lane) => lane.id)
|
||||
.filter((laneId) => (incomingCount.get(laneId) ?? 0) === 0),
|
||||
);
|
||||
const ordered: OrderedLaneLayout[] = [];
|
||||
let layer = 0;
|
||||
|
||||
while (remaining.size > 0) {
|
||||
if (roots.length === 0) {
|
||||
roots = takeSortedRoots(Array.from(remaining));
|
||||
}
|
||||
|
||||
const nextRoots: string[] = [];
|
||||
roots.forEach((laneId, indexInLayer) => {
|
||||
if (!remaining.has(laneId)) return;
|
||||
remaining.delete(laneId);
|
||||
const lane = laneById.get(laneId);
|
||||
if (!lane) return;
|
||||
ordered.push({ lane, layer, indexInLayer });
|
||||
|
||||
(outgoing.get(laneId) ?? new Set<string>()).forEach((nextLaneId) => {
|
||||
const nextCount = (incomingCount.get(nextLaneId) ?? 0) - 1;
|
||||
incomingCount.set(nextLaneId, nextCount);
|
||||
if (nextCount === 0) {
|
||||
nextRoots.push(nextLaneId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
roots = takeSortedRoots(nextRoots);
|
||||
layer += 1;
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function statusTone(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "app-chip-success";
|
||||
case "blocked":
|
||||
case "failed":
|
||||
return "app-chip-attention";
|
||||
default:
|
||||
return "app-chip-subtle";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type ConsoleCopy = {
|
||||
heroEyebrow: string;
|
||||
emptyTitle: string;
|
||||
emptyDetail: string;
|
||||
startTopic: string;
|
||||
leaderThread: string;
|
||||
executionFeed: string;
|
||||
executionEmpty: string;
|
||||
executionMapTitle: string;
|
||||
executionMapEmpty: string;
|
||||
lanesTitle: string;
|
||||
lanesEmpty: string;
|
||||
tasksTitle: string;
|
||||
tasksEmpty: string;
|
||||
messageLeaderOnly: string;
|
||||
sendToLeader: string;
|
||||
summaryLanes: string;
|
||||
summaryTasks: string;
|
||||
summaryRunning: string;
|
||||
summaryWaiting: string;
|
||||
};
|
||||
@@ -0,0 +1,431 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
answerHumanTask,
|
||||
sendMessage,
|
||||
} from "../../api/client";
|
||||
import type {
|
||||
WorkflowBoardLane,
|
||||
WorkflowBoardDispatchEvent,
|
||||
WorkflowBoardEvent,
|
||||
WorkflowBoardMessageEvent,
|
||||
WorkflowBoardPendingHumanTask,
|
||||
WorkflowBoardTask,
|
||||
WorkflowBoardTopicSummary,
|
||||
} from "../../types";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { getMessageParagraphs } from "../../utils/messageBody";
|
||||
import Button from "../ui/Button";
|
||||
import Chip from "../ui/Chip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from "../ui/Dialog";
|
||||
import PageSectionCard from "../ui/PageSectionCard";
|
||||
import ResponseComposer from "../ui/ResponseComposer";
|
||||
import StageBadge from "../ui/StageBadge";
|
||||
import StatusBadge from "../ui/StatusBadge";
|
||||
import TopicRailItem from "../ui/TopicRailItem";
|
||||
import { ExecutionFlowCanvas } from "./executionFlow";
|
||||
import type { ConsoleCopy } from "./model";
|
||||
import { statusTone } from "./helpers";
|
||||
|
||||
function compactLabel(value?: string | null): string {
|
||||
return (value ?? "").trim() || "-";
|
||||
}
|
||||
|
||||
export function TopicItem({
|
||||
topic,
|
||||
isActive,
|
||||
onClick,
|
||||
onDelete,
|
||||
deleteLabel,
|
||||
}: {
|
||||
topic: WorkflowBoardTopicSummary;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
deleteLabel: string;
|
||||
}) {
|
||||
const { copy } = useI18n();
|
||||
|
||||
return (
|
||||
<TopicRailItem
|
||||
active={isActive}
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
deleteLabel={deleteLabel}
|
||||
trailingContent={
|
||||
topic.status === "awaiting_confirmation" ? (
|
||||
<Chip
|
||||
tone="brand"
|
||||
size="sm"
|
||||
uppercase
|
||||
className="relative z-10 shrink-0 text-[10px] font-semibold tracking-[0.16em]"
|
||||
>
|
||||
{copy.workflow.topicAwaitingApproval}
|
||||
</Chip>
|
||||
) : topic.status === "cancelled" ? (
|
||||
<Chip
|
||||
tone="danger"
|
||||
size="sm"
|
||||
uppercase
|
||||
className="relative z-10 shrink-0 text-[10px] font-semibold tracking-[0.16em]"
|
||||
>
|
||||
{copy.workflow.topicStopped}
|
||||
</Chip>
|
||||
) : null
|
||||
}
|
||||
className="group relative flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-3 text-left"
|
||||
buttonClassName="relative z-10 -my-3 flex min-h-11 min-w-0 touch-manipulation flex-1 self-stretch items-center gap-2 py-3 text-left md:my-0 md:min-h-0 md:py-0"
|
||||
deleteButtonClassName="focus-visible:ring-offset-[color:var(--app-surface-drawer)] md:opacity-70 md:hover:opacity-100"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium">{topic.name}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<StageBadge stage={topic.latest_stage} />
|
||||
<span className="app-text-faint app-caption">{copy.workflow.topicCount(topic.message_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TopicRailItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposeBar({
|
||||
workspace,
|
||||
topic,
|
||||
onSent,
|
||||
disabled,
|
||||
consoleCopy,
|
||||
}: {
|
||||
workspace: string;
|
||||
topic: string;
|
||||
onSent: () => void;
|
||||
disabled?: boolean;
|
||||
consoleCopy: ConsoleCopy;
|
||||
}) {
|
||||
const { copy, formatStageLabel } = useI18n();
|
||||
const [body, setBody] = useState("");
|
||||
const [stage, setStage] = useState("plan");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!body.trim() || !topic.trim() || sending || disabled) return;
|
||||
setSending(true);
|
||||
setErr("");
|
||||
try {
|
||||
await sendMessage({ workspace, to: "leader", topic, body: body.trim(), stage });
|
||||
setBody("");
|
||||
onSent();
|
||||
} catch (error) {
|
||||
setErr(error instanceof Error ? error.message : copy.workflow.sendFailed);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponseComposer
|
||||
eyebrow={consoleCopy.sendToLeader}
|
||||
title={copy.workflow.composer.title}
|
||||
textareaId="workflow-message"
|
||||
topSlot={(
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Chip tone="brand" size="sm">{consoleCopy.messageLeaderOnly}</Chip>
|
||||
<select
|
||||
value={stage}
|
||||
disabled={sending || disabled}
|
||||
onChange={(event) => setStage(event.target.value)}
|
||||
className="app-input px-2 py-1 text-xs"
|
||||
aria-label={copy.workflow.phase}
|
||||
>
|
||||
<option value="plan">{formatStageLabel("plan")}</option>
|
||||
<option value="execution">{formatStageLabel("execution")}</option>
|
||||
<option value="verification">{formatStageLabel("verification")}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={disabled ? copy.workflow.disabledPlaceholder : copy.workflow.enabledPlaceholder}
|
||||
error={err}
|
||||
actionLabel={copy.common.sendUpdate}
|
||||
sendingLabel={copy.common.sending}
|
||||
sending={sending}
|
||||
disabled={disabled || !topic.trim()}
|
||||
onSubmit={() => void handleSend()}
|
||||
textareaLabel={copy.workflow.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HumanTaskCard({
|
||||
task,
|
||||
onAnswered,
|
||||
}: {
|
||||
task: WorkflowBoardPendingHumanTask;
|
||||
onAnswered: () => void;
|
||||
}) {
|
||||
const { copy, formatRoleLabel } = useI18n();
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!body.trim() || sending) return;
|
||||
setSending(true);
|
||||
setErr("");
|
||||
try {
|
||||
await answerHumanTask(task.id, body.trim());
|
||||
setBody("");
|
||||
onAnswered();
|
||||
} catch (error) {
|
||||
setErr(error instanceof Error ? error.message : copy.workflow.humanTask.failed);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponseComposer
|
||||
eyebrow={copy.workflow.humanTask.eyebrow}
|
||||
title={copy.workflow.humanTask.title}
|
||||
detail={copy.workflow.humanTask.fromLabel(formatRoleLabel(task.prompt_from))}
|
||||
className="border-[color:var(--app-accent-border)]"
|
||||
prompt={task.prompt_body}
|
||||
textareaId={`human-task-${task.id}-response`}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={copy.workflow.humanTask.placeholder}
|
||||
textareaLabel={copy.workflow.humanTask.responseLabel}
|
||||
error={err}
|
||||
actionLabel={copy.workflow.humanTask.send}
|
||||
sendingLabel={copy.common.sending}
|
||||
sending={sending}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionMapSection({
|
||||
lanes,
|
||||
tasks,
|
||||
consoleCopy,
|
||||
action,
|
||||
}: {
|
||||
lanes: WorkflowBoardLane[];
|
||||
tasks: WorkflowBoardTask[];
|
||||
consoleCopy: ConsoleCopy;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PageSectionCard
|
||||
eyebrow={consoleCopy.executionMapTitle}
|
||||
title={consoleCopy.executionMapTitle}
|
||||
detail="Each lane is one isolated runtime branch. Each card inside the lane is one task. Connectors show task dependencies, not substeps."
|
||||
action={action}
|
||||
bodyClassName="p-5 sm:p-6"
|
||||
>
|
||||
{tasks.length === 0 || lanes.length === 0 ? (
|
||||
<p className="app-text-faint text-sm">{consoleCopy.executionMapEmpty}</p>
|
||||
) : (
|
||||
<ExecutionFlowCanvas lanes={lanes} tasks={tasks} />
|
||||
)}
|
||||
</PageSectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function renderActivityLabel(
|
||||
item: WorkflowBoardEvent,
|
||||
copy: ReturnType<typeof useI18n>["copy"],
|
||||
) {
|
||||
if (item.kind === "message") {
|
||||
return `${item.from} -> ${item.to}`;
|
||||
}
|
||||
return item.running
|
||||
? copy.workflow.timeline.dispatchRunning
|
||||
: copy.workflow.timeline.dispatchSettled(item.exit_code);
|
||||
}
|
||||
|
||||
function renderActivityBody(
|
||||
item: WorkflowBoardMessageEvent | WorkflowBoardDispatchEvent,
|
||||
) {
|
||||
if (item.kind === "message") {
|
||||
return getMessageParagraphs(item.body).slice(0, 2);
|
||||
}
|
||||
const summary = item.error_message?.trim() || item.reply?.trim() || "";
|
||||
return summary ? [summary] : [];
|
||||
}
|
||||
|
||||
export function ActivityTimeline({
|
||||
items,
|
||||
empty,
|
||||
countLabel,
|
||||
}: {
|
||||
items: WorkflowBoardEvent[];
|
||||
empty: string;
|
||||
countLabel: string;
|
||||
}) {
|
||||
const { copy } = useI18n();
|
||||
|
||||
return (
|
||||
<PageSectionCard
|
||||
eyebrow={copy.workflow.timeline.eyebrow}
|
||||
title={copy.workflow.timeline.title}
|
||||
detail={copy.workflow.timeline.detail}
|
||||
action={<Chip size="sm">{countLabel}</Chip>}
|
||||
bodyClassName="max-h-[min(48rem,70vh)] space-y-4 overflow-y-auto p-5 sm:p-6"
|
||||
>
|
||||
<ActivityTimelineList items={items} empty={empty} />
|
||||
</PageSectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTimelineList({
|
||||
items,
|
||||
empty,
|
||||
}: {
|
||||
items: WorkflowBoardEvent[];
|
||||
empty: string;
|
||||
}) {
|
||||
const { copy } = useI18n();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="app-text-faint text-sm">{empty}</p>;
|
||||
}
|
||||
|
||||
return items.map((item, index) => {
|
||||
const bodyLines = renderActivityBody(item);
|
||||
const isDispatch = item.kind === "dispatch";
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative pl-8">
|
||||
{index < items.length - 1 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[0.72rem] top-6 h-[calc(100%-0.25rem)] w-px bg-[color:var(--app-divider-soft)]"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute left-0 top-1.5 h-3 w-3 rounded-full border-2 border-[color:var(--app-surface-base)] ${
|
||||
isDispatch
|
||||
? item.running
|
||||
? "bg-[color:var(--app-success-text)]"
|
||||
: item.error_message
|
||||
? "bg-[color:var(--app-danger-text)]"
|
||||
: "bg-[color:var(--app-accent-warm)]"
|
||||
: "bg-[color:var(--app-accent)]"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border border-[color:var(--app-divider-soft)] bg-[color:var(--app-surface-muted)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-primary truncate text-sm font-semibold">
|
||||
{renderActivityLabel(item, copy)}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
<StageBadge stage={item.stage} />
|
||||
<Chip tone="muted">{item.kind === "message" ? item.type : item.mode}</Chip>
|
||||
</div>
|
||||
</div>
|
||||
{isDispatch ? (
|
||||
<StatusBadge toneClassName={statusTone(item.running ? "running" : item.error_message ? "failed" : "ready")}>
|
||||
{item.running ? "running" : item.error_message ? "failed" : "done"}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bodyLines.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{bodyLines.map((paragraph, bodyIndex) => (
|
||||
<p
|
||||
key={`${item.id}-${bodyIndex}`}
|
||||
className={`text-sm leading-6 ${isDispatch && item.kind === "dispatch" && item.error_message ? "app-text-danger" : "app-text-faint"}`}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-faint mt-3 text-sm leading-6">
|
||||
{isDispatch ? compactLabel(item.mode) : compactLabel(item.type)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function TimelineDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
empty,
|
||||
countLabel,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: WorkflowBoardEvent[];
|
||||
empty: string;
|
||||
countLabel: string;
|
||||
}) {
|
||||
const { copy } = useI18n();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay asChild>
|
||||
<motion.div
|
||||
className="app-dialog-backdrop fixed inset-0 z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
/>
|
||||
</DialogOverlay>
|
||||
<DialogContent asChild aria-describedby={undefined}>
|
||||
<motion.div
|
||||
className="app-drawer-surface fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-[44rem] flex-col overflow-hidden border-l border-[color:var(--app-divider)] bg-[color:var(--app-bg)]"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ type: "spring", damping: 32, stiffness: 320 }}
|
||||
>
|
||||
<div className="border-b border-[color:var(--app-divider)] px-4 py-4 sm:px-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-soft app-overline">{copy.workflow.timeline.eyebrow}</p>
|
||||
<DialogTitle className="mt-1 text-base font-semibold app-text-primary">
|
||||
{copy.workflow.timeline.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="app-text-faint mt-1 max-w-2xl text-sm leading-6">
|
||||
{copy.workflow.timeline.detail}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="xs">{copy.common.close}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<Chip size="sm">{countLabel}</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">
|
||||
<div className="space-y-4">
|
||||
<ActivityTimelineList items={items} empty={empty} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Workspace } from "../../types";
|
||||
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function workspaceStatusClassName(workspace: Workspace) {
|
||||
if (workspace.provision_state === "failed") {
|
||||
return "border-[color:var(--app-danger-border)] bg-[color:var(--app-danger-background)] text-[color:var(--app-danger-text)]";
|
||||
}
|
||||
if (workspace.provision_state === "ready") {
|
||||
return "border-[color:var(--app-success-border)] bg-[color:var(--app-success-background)] text-[color:var(--app-success-text)]";
|
||||
}
|
||||
return "border-[color:var(--app-divider)] bg-[color:var(--app-surface-muted)] app-text-soft";
|
||||
}
|
||||
|
||||
export function workspaceStatusLabel(
|
||||
workspace: Workspace,
|
||||
copy: {
|
||||
workspaceSelector: {
|
||||
statusFailed: string;
|
||||
statusReadyRunning: string;
|
||||
statusMissing: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
if (workspace.provision_state === "failed") {
|
||||
return copy.workspaceSelector.statusFailed;
|
||||
}
|
||||
if (workspace.provision_state === "ready") {
|
||||
return copy.workspaceSelector.statusReadyRunning;
|
||||
}
|
||||
return copy.workspaceSelector.statusMissing;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./copy/data";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { en, type AppCopy } from "./en";
|
||||
import { zhCN } from "./zh-CN";
|
||||
import { defaultLocale, type Locale } from "./model";
|
||||
import {
|
||||
formatRoleLabelValue,
|
||||
formatRunModeLabelValue,
|
||||
formatStageLabelValue,
|
||||
} from "./labels";
|
||||
|
||||
const copyByLocale: Record<Locale, AppCopy> = {
|
||||
en,
|
||||
"zh-CN": zhCN,
|
||||
};
|
||||
|
||||
export type { AppCopy } from "./en";
|
||||
export { defaultLocale, type Locale } from "./model";
|
||||
|
||||
export const appCopy = getCopy(defaultLocale);
|
||||
|
||||
export function isLocale(value: string | null | undefined): value is Locale {
|
||||
return value === "zh-CN" || value === "en";
|
||||
}
|
||||
|
||||
export function getCopy(locale: Locale): AppCopy {
|
||||
return copyByLocale[locale];
|
||||
}
|
||||
|
||||
export function formatStageLabel(
|
||||
stage?: string,
|
||||
locale: Locale = defaultLocale,
|
||||
): string {
|
||||
return formatStageLabelValue(stage, locale, getCopy(locale).common.fallback);
|
||||
}
|
||||
|
||||
export function formatRunModeLabel(
|
||||
mode?: string,
|
||||
locale: Locale = defaultLocale,
|
||||
): string {
|
||||
return formatRunModeLabelValue(mode, locale, getCopy(locale).common.fallback);
|
||||
}
|
||||
|
||||
export function formatRoleLabel(
|
||||
role: string,
|
||||
locale: Locale = defaultLocale,
|
||||
): string {
|
||||
return formatRoleLabelValue(role, locale);
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
export const en = {
|
||||
shell: {
|
||||
kicker: "AI delivery workflow",
|
||||
title: "Delivery Console",
|
||||
},
|
||||
tabs: {
|
||||
workflow: "Leader Console",
|
||||
roles: "Agents",
|
||||
skills: "Skills",
|
||||
executions: "Runs",
|
||||
merges: "Merge queue",
|
||||
},
|
||||
localeToggle: {
|
||||
label: "Language",
|
||||
zh: "中文",
|
||||
en: "EN",
|
||||
},
|
||||
common: {
|
||||
close: "Close",
|
||||
cancel: "Cancel",
|
||||
retry: "Retry",
|
||||
loading: "Loading...",
|
||||
loadingPage: "Loading page...",
|
||||
send: "Send",
|
||||
sending: "Sending...",
|
||||
sendUpdate: "Send update",
|
||||
create: "Create",
|
||||
creating: "Creating...",
|
||||
confirm: "Confirm",
|
||||
edit: "Edit",
|
||||
active: "Active",
|
||||
notStarted: "Not started",
|
||||
backToLatest: "Back to latest",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
showNote: "Show note",
|
||||
hideNote: "Hide note",
|
||||
latestReply: "Latest reply",
|
||||
runNote: "Run note",
|
||||
openWorkflow: "Open workflow",
|
||||
copyPath: "Copy path",
|
||||
pathCopied: "Path copied",
|
||||
noMessageContent: "No message content.",
|
||||
emptyMessage: "(empty message)",
|
||||
emptyBody: "(empty)",
|
||||
never: "Never",
|
||||
running: "Running",
|
||||
fallback: "-",
|
||||
},
|
||||
themes: {
|
||||
"atelier-copper": {
|
||||
label: "Atelier Copper",
|
||||
description: "Warm premium console",
|
||||
},
|
||||
"graphite-aqua": {
|
||||
label: "Graphite Aqua",
|
||||
description: "Cool technical slate",
|
||||
},
|
||||
"midnight-plum": {
|
||||
label: "Midnight Plum",
|
||||
description: "Moody editorial control room",
|
||||
},
|
||||
"ivory-brass": {
|
||||
label: "Ivory Brass",
|
||||
description: "Warm paper and brass",
|
||||
},
|
||||
"mist-blue": {
|
||||
label: "Mist Blue",
|
||||
description: "Crisp blue studio",
|
||||
},
|
||||
"sage-paper": {
|
||||
label: "Sage Paper",
|
||||
description: "Soft green planning desk",
|
||||
},
|
||||
appearance: {
|
||||
dark: "Dark",
|
||||
light: "Light",
|
||||
darkThemes: "Dark themes",
|
||||
lightThemes: "Light themes",
|
||||
},
|
||||
},
|
||||
themeSelector: {
|
||||
themeTitle: "Theme",
|
||||
},
|
||||
workspaceRequired: {
|
||||
eyebrow: "Project context",
|
||||
title: "Select a project",
|
||||
detail: (subject: string) =>
|
||||
`Choose a project from the picker in the top-right corner to load ${subject}.`,
|
||||
},
|
||||
globalPendingIndicator: {
|
||||
label: "Pending",
|
||||
title: (topic: string, count: number) =>
|
||||
count === 1
|
||||
? `Open pending topic ${topic}`
|
||||
: `Open ${topic} and review ${count} pending topics`,
|
||||
ariaLabel: (count: number, topic: string) =>
|
||||
count === 1
|
||||
? `1 pending item. Open topic ${topic}`
|
||||
: `${count} pending items. Open topic ${topic}`,
|
||||
modalTitle: (count: number) =>
|
||||
count === 1 ? "1 pending item" : `${count} pending items`,
|
||||
modalDetail: "These topics are waiting for plan confirmation. Choose one to open its detail view.",
|
||||
openAction: "Open",
|
||||
},
|
||||
metadata: {
|
||||
workflow: {
|
||||
label: "Leader Console",
|
||||
description:
|
||||
"Operate the leader console: user dialogue, lane orchestration, task graph, and execution state.",
|
||||
},
|
||||
roles: {
|
||||
label: "Agents",
|
||||
description:
|
||||
"Monitor leader and worker runtime state, attention, and recent activity.",
|
||||
},
|
||||
skills: {
|
||||
label: "Skills",
|
||||
description:
|
||||
"Maintain the shared skill catalog separately from per-role skill assignment.",
|
||||
},
|
||||
executions: {
|
||||
label: "Runs",
|
||||
description:
|
||||
"Inspect agent runs, outputs, and completion state across the current workspace.",
|
||||
},
|
||||
merges: {
|
||||
label: "Merge queue",
|
||||
description:
|
||||
"Review queued merges, changed files, and merge readiness before promoting work.",
|
||||
},
|
||||
workspaceSuffix: (workspace: string) => `Active workspace: ${workspace}.`,
|
||||
},
|
||||
workspaceSelector: {
|
||||
unavailable: "Workspaces unavailable",
|
||||
loadingProjects: "Loading workspaces...",
|
||||
missingProject: (value: string) => `${value} (missing)`,
|
||||
noProjects: "No workspaces",
|
||||
chooseProject: "Choose workspace",
|
||||
projectsTitle: "Workspaces",
|
||||
noProjectsYet: "No workspaces yet",
|
||||
ensureWorkspaceError: "Couldn't prepare the workspace runtime.",
|
||||
statusReadyRunning: "Ready",
|
||||
statusReadyStopped: "Ready",
|
||||
statusFailed: "Setup failed",
|
||||
statusMissing: "Not ready",
|
||||
loadProjectsError: "Couldn't load workspaces",
|
||||
noProjectPath: "No workspace path available",
|
||||
},
|
||||
runtimeStatus: {
|
||||
eyebrow: "Runtime monitor",
|
||||
title: "Container runtime",
|
||||
detail:
|
||||
"Monitor the selected workspace container and recover it here instead of coupling runtime startup to project selection.",
|
||||
openDetails: (workspace: string) => `Open runtime details for ${workspace}`,
|
||||
summaryFallback: "Open details",
|
||||
loadingTitle: "Loading runtime status...",
|
||||
errorTitle: "Couldn't load runtime status",
|
||||
fields: {
|
||||
workspace: "Workspace",
|
||||
container: "Container",
|
||||
containerState: "Container state",
|
||||
provisionState: "Provision state",
|
||||
endpoint: "Runner endpoint",
|
||||
lastProvisioned: "Last provisioned",
|
||||
error: "Provision error",
|
||||
},
|
||||
states: {
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
missing: "Missing",
|
||||
failed: "Failed",
|
||||
},
|
||||
endpointMissing: "Endpoint missing",
|
||||
refresh: "Refresh status",
|
||||
refreshing: "Refreshing...",
|
||||
start: "Start runtime",
|
||||
starting: "Starting runtime...",
|
||||
retry: "Retry runtime",
|
||||
retrying: "Retrying runtime...",
|
||||
reconcile: "Reconcile runtime",
|
||||
reconciling: "Reconciling runtime...",
|
||||
actionError: "Runtime action failed",
|
||||
},
|
||||
roleEditor: {
|
||||
title: (roleName: string) => `Edit: ${roleName}`,
|
||||
roleTab: "Role",
|
||||
roleSkillsTab: "Role Skills",
|
||||
loading: "Loading...",
|
||||
name: "Name",
|
||||
sortOrder: "Sort Order",
|
||||
description: "Description",
|
||||
descriptionPlaceholder: "Short description of what this role does",
|
||||
systemPrompt: "System Prompt",
|
||||
promptPlaceholder: "The full system prompt for this agent role...",
|
||||
codexConfig: "Codex Config",
|
||||
codexConfigPlaceholder:
|
||||
"model = \"gpt-5.4\"\napproval_policy = \"never\"\nsandbox_mode = \"workspace-write\"",
|
||||
codexAuth: "Codex Auth",
|
||||
codexAuthPlaceholder:
|
||||
"{\n \"OPENAI_API_KEY\": \"...\"\n}",
|
||||
skillsEmpty: "No skills are available for this role yet.",
|
||||
skillMissing: "Missing",
|
||||
skillsGroupingScope:
|
||||
"Skill groups are shared across roles. Edit shared skill content from the Skills page.",
|
||||
skillGroupLabel: "Group",
|
||||
skillGroupPlaceholder: "group-name",
|
||||
skillCategories: {
|
||||
adaptation: "Adaptation",
|
||||
visual_design: "Visual Design",
|
||||
consistency: "Consistency",
|
||||
quality: "Quality & Resilience",
|
||||
ux_copy: "UX & Copy",
|
||||
systemization: "Systemization",
|
||||
capability: "Capability",
|
||||
other: "Other",
|
||||
},
|
||||
saveRole: "Save Role",
|
||||
saveSkills: "Save Skills",
|
||||
saving: "Saving...",
|
||||
},
|
||||
skillsCatalog: {
|
||||
workspaceSubject: "skill catalog",
|
||||
eyebrow: "Shared skill library",
|
||||
heroTitle: "Maintain shared skills as a first-class surface, separate from role detail editing.",
|
||||
heroDetail:
|
||||
"This page owns the global skill catalog. Role detail panels stay focused on assignment and grouping, while shared skill instructions live here.",
|
||||
summary: {
|
||||
totalSkills: "Skills in catalog",
|
||||
totalSkillsDetail: "Shared entries available for role assignment.",
|
||||
categories: "Categories",
|
||||
categoriesDetail: "Unique buckets used to organize the library.",
|
||||
assignmentSurface: "Assignments live in",
|
||||
assignmentSurfaceValue: "Roles",
|
||||
assignmentSurfaceDetail: "Enable and group skills from each role's detail panel.",
|
||||
},
|
||||
loadingTitle: "Loading skills...",
|
||||
errorTitle: "Couldn't load skills",
|
||||
catalogScope:
|
||||
"Skill content is global. Changes here affect every role that enables the skill.",
|
||||
catalogListLabel: "Skill catalog",
|
||||
catalogEmpty: "No skills are stored in the database yet.",
|
||||
newSkill: "New Skill",
|
||||
deleteSkill: "Delete Skill",
|
||||
skillGroupLabel: "Group",
|
||||
skillGroupPlaceholder: "group-name",
|
||||
skillMarkdownSummaryLabel: "Parsed from Markdown",
|
||||
skillNameLabel: "Skill Name",
|
||||
skillNameMissing: "Add `name:` to the markdown frontmatter.",
|
||||
skillDescriptionLabel: "Description",
|
||||
skillDescriptionMissing: "Optional. Add `description:` if you want a short summary.",
|
||||
skillMarkdownHint:
|
||||
"The internal skill ID is generated and maintained automatically from the markdown frontmatter.",
|
||||
skillContentLabel: "Skill Content",
|
||||
skillContentPlaceholder:
|
||||
"---\nname: my-skill\ndescription: Brief summary shown in role assignment.\n---\n\nWrite the skill instructions here.",
|
||||
skillCategories: {
|
||||
adaptation: "Adaptation",
|
||||
visual_design: "Visual Design",
|
||||
consistency: "Consistency",
|
||||
quality: "Quality & Resilience",
|
||||
ux_copy: "UX & Copy",
|
||||
systemization: "Systemization",
|
||||
capability: "Capability",
|
||||
other: "Other",
|
||||
},
|
||||
sortOrder: "Sort Order",
|
||||
saveSkill: "Save Skill",
|
||||
saving: "Saving...",
|
||||
},
|
||||
roleStatus: {
|
||||
workspaceSubject: "agent status",
|
||||
listTitle: "Agents",
|
||||
listDetail: "Leader and workers currently active in this workspace.",
|
||||
conciseDescription: {
|
||||
leader: "Talks to the user, plans task graphs and derived lanes, and supervises execution.",
|
||||
worker: "Executes lane tasks inside an isolated runtime.",
|
||||
},
|
||||
states: {
|
||||
attention: "Needs attention",
|
||||
active: "Active",
|
||||
idle: "Not started",
|
||||
waiting: (count: number) => `${count} waiting`,
|
||||
},
|
||||
loadingTitle: "Loading agents...",
|
||||
errorTitle: "Couldn't load agent status",
|
||||
emptyTitle: "No agents yet",
|
||||
emptyDetail:
|
||||
"Agent status appears here after this workspace starts running leader and worker threads.",
|
||||
eyebrow: "Operational roster",
|
||||
heroTitle:
|
||||
"A live read on which agents are active, blocked, or still waiting to start.",
|
||||
heroDetail:
|
||||
"This surface keeps the orchestration layer legible: who is running, who has inbox work, and which roles have not entered the thread yet.",
|
||||
summary: {
|
||||
activeAgents: "Active agents",
|
||||
activeAgentsDetail: "Roles with a live session right now.",
|
||||
needsAttention: "Needs attention",
|
||||
needsAttentionDetail: "Roles carrying pending inbox work.",
|
||||
waitingItems: "Waiting items",
|
||||
waitingItemsDetail: "Total queued messages across the roster.",
|
||||
notStarted: "Not started",
|
||||
notStartedDetail: "Roles that have not entered the workspace yet.",
|
||||
},
|
||||
lastActive: "Last active",
|
||||
},
|
||||
messageWorkflow: {
|
||||
workspaceSubject: "message history",
|
||||
eyebrow: "Activity feed",
|
||||
loadingTitle: "Loading activity...",
|
||||
errorTitle: "Couldn't load activity",
|
||||
emptyTitle: "No activity yet",
|
||||
emptyDetail:
|
||||
"Messages appear here after the first handoff, clarification, or decision lands in Workflow.",
|
||||
heroTitle: "Every handoff, decision, and clarification in one readable stream.",
|
||||
heroDetail:
|
||||
"This view turns message routing into a working narrative, so you can skim what moved, who moved it, and where the thread is headed next.",
|
||||
summary: {
|
||||
messages: "Messages",
|
||||
messagesDetail: "Captured in the current workspace timeline.",
|
||||
expanded: "Expanded",
|
||||
expandedDetail: "Open cards for deeper reading right now.",
|
||||
},
|
||||
expandMessage: "Expand full message",
|
||||
collapseMessage: "Collapse full message",
|
||||
},
|
||||
executions: {
|
||||
workspaceSubject: "run history",
|
||||
eyebrow: "Run ledger",
|
||||
loadingTitle: "Loading runs...",
|
||||
errorTitle: "Couldn't load runs",
|
||||
emptyTitle: "No runs yet",
|
||||
emptyDetail:
|
||||
"Agent runs appear here after work starts on this project, including new threads, resumed sessions, and verification passes.",
|
||||
heroTitle: "A readable history of every agent execution across the workspace.",
|
||||
heroDetail:
|
||||
"Track who started work, which threads were resumed, and where failures or long-running executions still need attention.",
|
||||
summary: {
|
||||
running: "Running",
|
||||
runningDetail: "Live executions in progress.",
|
||||
resumed: "Resumed",
|
||||
resumedDetail: "Runs that continued an existing thread.",
|
||||
failed: "Failed",
|
||||
failedDetail: "Completed with a non-zero exit code.",
|
||||
},
|
||||
newRunsAvailable: "New runs available",
|
||||
newRunsDetail:
|
||||
"New executions arrived while you were reviewing older history.",
|
||||
topic: "Topic",
|
||||
phase: "Phase",
|
||||
thread: "Thread",
|
||||
duration: "Duration",
|
||||
started: "Started",
|
||||
recentRuns: "Recent runs",
|
||||
recentRunsTitle: "Ordered newest first for quick triage.",
|
||||
role: "Role",
|
||||
exitCode: "Exit code",
|
||||
details: "Details",
|
||||
noDetail: "-",
|
||||
mobileShowing: (visible: number, total: number) =>
|
||||
`Showing ${visible} of ${total} runs.`,
|
||||
desktopShowing: (start: number, end: number, total: number) =>
|
||||
`Showing ${start}-${end} of ${total} runs.`,
|
||||
loadOlderRuns: "Load older runs",
|
||||
pageLabel: (page: number, totalPages: number) => `Page ${page} of ${totalPages}`,
|
||||
},
|
||||
mergeQueue: {
|
||||
workspaceSubject: "merge requests",
|
||||
eyebrow: "Merge queue",
|
||||
loadingTitle: "Loading merge queue...",
|
||||
errorTitle: "Couldn't load merge queue",
|
||||
emptyTitle: "Nothing waiting to merge",
|
||||
emptyDetail:
|
||||
"Changes show up here after a build finishes and is ready for review, approval, and merge back to the main project branch.",
|
||||
heroTitle: "Review-ready changes collected in one calm, traceable queue.",
|
||||
heroDetail:
|
||||
"This surface is for final judgment: what is pending, what merged cleanly, and which requests still need human intervention before they can land.",
|
||||
summary: {
|
||||
pending: "Pending",
|
||||
pendingDetail: "Waiting for approval or merge.",
|
||||
merged: "Merged",
|
||||
mergedDetail: "Already landed back on target.",
|
||||
failed: "Failed",
|
||||
failedDetail: "Requests blocked by errors or conflicts.",
|
||||
},
|
||||
status: {
|
||||
pending: "Pending",
|
||||
merged: "Merged",
|
||||
failed: "Failed",
|
||||
},
|
||||
filesChanged: (count: number) => `${count} files changed`,
|
||||
loadDiffError: "Couldn't load file diffs.",
|
||||
failedToMerge: "Failed to merge",
|
||||
files: "Files",
|
||||
created: "Created",
|
||||
merged: "Merged",
|
||||
mergeAction: "Approve & Merge",
|
||||
merging: "Merging...",
|
||||
},
|
||||
workflow: {
|
||||
topicExamplesLabel: "Example topics",
|
||||
starterTopicsLabel: "Starter topics",
|
||||
kickoffChecklist: [
|
||||
{
|
||||
label: "Start with the owner",
|
||||
detail:
|
||||
"Send the first message to the leader so the leader can decide whether to clarify, derive lanes, or start execution.",
|
||||
},
|
||||
{
|
||||
label: "Anchor the goal",
|
||||
detail:
|
||||
"Summarize the user outcome, constraints, and done criteria the receiving role should optimize for.",
|
||||
},
|
||||
{
|
||||
label: "Keep the first handoff executable",
|
||||
detail:
|
||||
"Use a topic name and opening message that can survive multiple rounds of review, build, and verification.",
|
||||
},
|
||||
],
|
||||
topicCount: (count: number) => `${count} msg${count !== 1 ? "s" : ""}`,
|
||||
noMessageContent: "No message content.",
|
||||
recipient: "Recipient",
|
||||
phase: "Phase",
|
||||
message: "Message",
|
||||
messageHint: (shortcut: string) => `${shortcut}+Enter to send`,
|
||||
disabledPlaceholder: "Name the topic first...",
|
||||
enabledPlaceholder: "Outline the next decision, handoff, or build request...",
|
||||
sendFailed: "Send failed",
|
||||
createTopicFailed: "Couldn't create the topic.",
|
||||
deleteTopic: "Delete topic",
|
||||
confirmDeleteTopic: "Confirm delete",
|
||||
deletingTopic: "Deleting topic",
|
||||
deleteTopicFailed: "Couldn't delete the topic.",
|
||||
deleteTopicDialogTitle: "Delete this topic?",
|
||||
deleteTopicDialogDetail:
|
||||
"This will permanently remove the topic and its workflow history from the leader console.",
|
||||
stopTopic: "Stop topic",
|
||||
confirmStopTopic: "Confirm stop",
|
||||
stoppingTopic: "Stopping topic",
|
||||
stopTopicFailed: "Couldn't stop the topic.",
|
||||
stopTopicDialogTitle: "Stop this topic?",
|
||||
stopTopicDialogDetail:
|
||||
"This will halt further execution for the topic. Use this only when the topic should not continue.",
|
||||
confirmPlan: "Confirm plan",
|
||||
confirmingPlan: "Confirming plan",
|
||||
confirmPlanFailed: "Couldn't confirm the plan.",
|
||||
topicAwaitingApproval: "Awaiting approval",
|
||||
topicStopped: "Stopped",
|
||||
topicStatus: (status: string) => `Status: ${status}`,
|
||||
pendingPlanBanner: {
|
||||
title: (count: number) => count === 1 ? "1 topic is waiting for plan approval" : `${count} topics are waiting for plan approval`,
|
||||
detail: "Execution stays paused until the plan is confirmed. Open the topic details to review and approve it.",
|
||||
openTopic: (topic: string) => `Open ${topic}`,
|
||||
},
|
||||
planReview: {
|
||||
eyebrow: "Plan review",
|
||||
title: "Review the frozen plan before execution starts",
|
||||
detail: "This topic already has a proposed task graph. The execution map stays hidden until the plan is confirmed.",
|
||||
summaryTitle: "Plan summary",
|
||||
summaryEmpty: "The leader froze a plan, but no summary was saved with it.",
|
||||
proposedLanes: "Proposed lanes",
|
||||
proposedTasks: "Planned tasks",
|
||||
lanesEmpty: "No lanes have been proposed yet.",
|
||||
tasksEmpty: "No tasks have been proposed yet.",
|
||||
versionLabel: (version: number) => `Plan v${version}`,
|
||||
createdByLabel: (role: string) => `Prepared by ${role}`,
|
||||
},
|
||||
untitledTopic: "(no topic)",
|
||||
workspaceSubject: "workflow",
|
||||
errorTitle: "Couldn't load workflow",
|
||||
topics: "Topics",
|
||||
newTopic: "+ New",
|
||||
hideTopics: "Hide",
|
||||
showTopics: "Show topics",
|
||||
topicName: "Topic name",
|
||||
topicPlaceholder: "topic-name",
|
||||
topicHint: "Enter to create, Esc to cancel",
|
||||
emptyTopicRailTitle: "No topics yet",
|
||||
emptyTopicRailDetail:
|
||||
"Start the first leader thread here. The leader will turn it into a task graph, derived lanes, and execution runs.",
|
||||
srHeading: (topic: string | null) =>
|
||||
topic ? `Topic ${topic}` : "Workflow topic details",
|
||||
emptyState: {
|
||||
eyebrow: "Workflow",
|
||||
namingTitle: "Name this topic",
|
||||
initialTitle: "Launch a topic and move it through the team",
|
||||
namingDetail:
|
||||
"Give the workstream a durable topic name on the left, then send the opening message to the leader.",
|
||||
initialDetail:
|
||||
"Each topic becomes a leader-owned thread. Start with a clear goal, then let the leader define the task graph and derived lanes from there.",
|
||||
startFirstTopic: "+ Start first topic",
|
||||
whatHappensNext: "What happens next",
|
||||
strongFirstHandoff: "A strong first handoff",
|
||||
exampleOpeningTitle: "Example opening",
|
||||
creatingSideNote:
|
||||
"Keep one topic per outcome. That makes the timeline easier to read when planning, building, and verification overlap.",
|
||||
nonCreatingSideNote:
|
||||
"Keep one topic per outcome. That makes the timeline easier to read when planning, building, and verification overlap.",
|
||||
},
|
||||
runningSummary: {
|
||||
single: (role: string) => `${role} running`,
|
||||
double: (left: string, right: string) => `${left} + ${right} running`,
|
||||
multiple: (count: number) => `${count} agents running`,
|
||||
},
|
||||
topicSignals: {
|
||||
running: (count: number) => `${count} running`,
|
||||
waiting: (count: number) => `${count} queued`,
|
||||
},
|
||||
summary: {
|
||||
eyebrow: "Active topic",
|
||||
detail:
|
||||
"See who owns the next move, which handoffs are still queued, and how this topic is moving through build and verification.",
|
||||
runningLabel: "Running now",
|
||||
waitingLabel: "Queued",
|
||||
activeLabel: "In motion",
|
||||
running: (count: number) => `${count} agents are still executing.`,
|
||||
waiting: (count: number) => `${count} agents are waiting on the next handoff.`,
|
||||
active: (count: number) => `${count} roles are part of the current flow.`,
|
||||
},
|
||||
board: {
|
||||
detail:
|
||||
"Roles stay pinned to fixed swimlanes while direct handoffs stack by time, so the handoff sequence stays readable even when traffic gets dense.",
|
||||
hint:
|
||||
"Select a lane header to inspect a role, or select a handoff row to inspect the direct exchange behind it.",
|
||||
laneCount: (count: number) => `${count} msg${count !== 1 ? "s" : ""}`,
|
||||
},
|
||||
selection: {
|
||||
eyebrow: "Board context",
|
||||
emptyTitle: "Select a role or lane",
|
||||
emptyDetail:
|
||||
"Use the swimlane to choose a role header or a handoff row, then inspect the details here.",
|
||||
messageEyebrow: "Message detail",
|
||||
messageTitle: "Selected handoff",
|
||||
messageDetail:
|
||||
"Read the full message first, then use the recipient buttons to inspect each role's handling message without opening the whole lane.",
|
||||
messageBody: "Full message",
|
||||
sourceEyebrow: "Sender",
|
||||
responseEyebrow: "Recipients",
|
||||
responseTitle: (role: string) => `${role} handling message`,
|
||||
responsePending: (role: string) => `${role} has not sent a handling message yet.`,
|
||||
viewResponse: (role: string) => `View ${role} handling message`,
|
||||
laneEyebrow: "Message lane",
|
||||
laneTitle: (from: string, to: string) => `${from} to ${to}`,
|
||||
laneDetail:
|
||||
"This lane groups direct messages passed from the source role to the target role on the active topic.",
|
||||
laneMessages: "Messages on this lane",
|
||||
noLaneMessages: "No direct messages on this lane yet.",
|
||||
openLane: (role: string) => `Open ${role} lane`,
|
||||
openRole: (role: string) => `Open ${role}`,
|
||||
threadTitle: "Latest thread updates",
|
||||
threadDetail:
|
||||
"Keep the newest cross-role updates visible while you inspect a single lane.",
|
||||
showThread: "Show latest topic updates",
|
||||
hideThread: "Hide latest topic updates",
|
||||
},
|
||||
focusTabs: {
|
||||
label: "Workflow panels",
|
||||
overview: "Overview",
|
||||
roles: "Roles",
|
||||
timeline: "Timeline",
|
||||
},
|
||||
nextStep: {
|
||||
eyebrow: "Next move",
|
||||
queuedTitle: (role: string) => `${role} has the baton`,
|
||||
queuedDetail:
|
||||
"That role already has the latest handoff. Open Roles to inspect the context, or send a correction only if the plan changed.",
|
||||
runningTitle: (role: string) => `${role} is already working`,
|
||||
runningDetail:
|
||||
"Execution is in flight. Use Roles to inspect live output, or redirect the work only if priorities changed.",
|
||||
recentTitle: (role: string) => `Route the next handoff to ${role}`,
|
||||
recentDetail:
|
||||
"Based on the latest activity, this is the clearest next owner for the topic.",
|
||||
idleTitle: "Start with Product",
|
||||
idleDetail:
|
||||
"If ownership still feels fuzzy, send the next update to Product to freeze scope and route execution.",
|
||||
inspectRole: (role: string) => `Inspect ${role}`,
|
||||
},
|
||||
composer: {
|
||||
eyebrow: "Next handoff",
|
||||
title: "Send the next update first",
|
||||
detail:
|
||||
"Messages from this page go straight to the leader. The leader decides how to decompose and dispatch the work.",
|
||||
inspectRole: (role: string) => `Focus ${role}`,
|
||||
},
|
||||
humanTask: {
|
||||
eyebrow: "Waiting On You",
|
||||
title: "A role needs a human answer",
|
||||
detail:
|
||||
"This prompt was routed to the host-side user inbox instead of the Codex runtime. Answer here to push the workflow forward.",
|
||||
fromLabel: (role: string) => `${role} is waiting for your reply`,
|
||||
promptLabel: "Prompt",
|
||||
responseLabel: "Your answer",
|
||||
responseHint: (shortcut: string) => `${shortcut}+Enter to answer`,
|
||||
placeholder: "Answer the question, clarify scope, or unblock the next handoff...",
|
||||
send: "Send answer",
|
||||
failed: "Couldn't send your answer.",
|
||||
remaining: (count: number) => `${count} more pending question${count === 1 ? "" : "s"} after this one.`,
|
||||
},
|
||||
overview: {
|
||||
recentEyebrow: "Recent activity",
|
||||
recentTitle: "Latest events",
|
||||
recentDetail:
|
||||
"Scan the latest handoffs and runs here before diving into the full timeline.",
|
||||
noRecentTitle: "No events yet",
|
||||
noRecentDetail:
|
||||
"Send the opening PRD and the first role runs will show up here.",
|
||||
snapshotEyebrow: "Flow snapshot",
|
||||
snapshotTitle: "Who is moving now",
|
||||
snapshotDetail:
|
||||
"Keep the current state readable without forcing the full role graph into the default view.",
|
||||
runningTitle: "Running now",
|
||||
waitingTitle: "Waiting next",
|
||||
recentTitleShort: "Recently involved",
|
||||
emptyRunning: "No one is running right now.",
|
||||
emptyWaiting: "No queued handoff right now.",
|
||||
emptyRecentRoles: "No recent role activity yet.",
|
||||
openRoles: "Open role map",
|
||||
openTimeline: "Open timeline",
|
||||
},
|
||||
sandbox: {
|
||||
eyebrow: "Realtime swimlane",
|
||||
title: "Handoff timeline",
|
||||
detail:
|
||||
"Each role keeps a fixed lane while direct handoffs stack by time. Execution state stays in the lane headers and role details.",
|
||||
linkCount: (count: number) => `${count} lane${count !== 1 ? "s" : ""}`,
|
||||
emptyTitle: "No handoffs yet",
|
||||
emptyDetail:
|
||||
"Messages will appear here once one role hands work to another. Running and failed executions stay visible in role details.",
|
||||
},
|
||||
roleState: {
|
||||
running: "Running",
|
||||
queued: "Queued",
|
||||
recent: "Recent",
|
||||
idle: "Idle",
|
||||
},
|
||||
inspector: {
|
||||
eyebrow: "Role inspector",
|
||||
title: "Role detail",
|
||||
noSelection: "Select a role to inspect its handoffs, queue pressure, and current execution state.",
|
||||
globalInbox: "Global inbox",
|
||||
globalInboxValue: (count: number) => `${count} queued globally`,
|
||||
lastSession: (value: string) => `Last session ${value}`,
|
||||
sessionMissing: "No session recorded yet.",
|
||||
latestInbound: "Latest inbound",
|
||||
latestOutbound: "Latest outbound",
|
||||
noInbound: "No inbound handoff for this topic yet.",
|
||||
noOutbound: "No outbound handoff for this topic yet.",
|
||||
currentRun: "Current run",
|
||||
noCurrentRun: "No execution recorded for this topic yet.",
|
||||
startedAt: (value: string) => `Started ${value}`,
|
||||
completedAt: (value: string) => `Completed ${value}`,
|
||||
inFlight: "Still running…",
|
||||
exitCode: (value: number) => `Exit ${value}`,
|
||||
liveOutput: "Live output",
|
||||
waitingLiveOutput: "Waiting for live output...",
|
||||
noLiveOutput: "No live output captured yet.",
|
||||
},
|
||||
timeline: {
|
||||
eyebrow: "Event timeline",
|
||||
title: "Topic events",
|
||||
detail:
|
||||
"Messages and runs are merged into one sequence so the operational story stays readable.",
|
||||
count: (count: number) => `${count} events`,
|
||||
emptyTitle: "No events yet",
|
||||
emptyDetail:
|
||||
"Send the opening PRD to start the topic. Role runs and replies will appear here as the work begins.",
|
||||
dispatchRunning: "Run in flight",
|
||||
dispatchSettled: (exitCode: number) => (exitCode === 0 ? "Run complete" : "Run failed"),
|
||||
},
|
||||
openingHandoff: {
|
||||
eyebrow: "Opening Handoff",
|
||||
creatingTitle: "Send the opening PRD",
|
||||
regularTitle: "Route the next step",
|
||||
creatingDetail:
|
||||
"Your first message should name the outcome, who should act first, and what done looks like.",
|
||||
regularDetail:
|
||||
"Use the composer below to push this topic into the next phase with a concrete ask for the receiving role.",
|
||||
coverPoints: "Cover these points",
|
||||
exampleOpeningTitle: "Example opening",
|
||||
exampleOpeningBody:
|
||||
"Ship password reset. The leader should clarify scope, derive execution lanes, and start worker containers only after the task graph is ready.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export type DeepWiden<T> = T extends (...args: infer A) => infer R
|
||||
? (...args: A) => DeepWiden<R>
|
||||
: T extends readonly (infer U)[]
|
||||
? DeepWiden<U>[]
|
||||
: T extends object
|
||||
? { [K in keyof T]: DeepWiden<T[K]> }
|
||||
: T extends string
|
||||
? string
|
||||
: T extends number
|
||||
? number
|
||||
: T extends boolean
|
||||
? boolean
|
||||
: T;
|
||||
|
||||
export type AppCopy = DeepWiden<typeof en>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Locale } from "./model";
|
||||
|
||||
export const stageLabels = {
|
||||
en: {
|
||||
plan: "Plan",
|
||||
review: "Review",
|
||||
freeze: "Freeze",
|
||||
execution: "Build",
|
||||
verification: "Verify",
|
||||
merge_pending: "Merge",
|
||||
merged: "Merged",
|
||||
},
|
||||
"zh-CN": {
|
||||
plan: "计划",
|
||||
review: "评审",
|
||||
freeze: "冻结",
|
||||
execution: "构建",
|
||||
verification: "验证",
|
||||
merge_pending: "待合并",
|
||||
merged: "已合并",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const runModeLabels = {
|
||||
en: {
|
||||
exec: "New thread",
|
||||
resume: "Continue thread",
|
||||
},
|
||||
"zh-CN": {
|
||||
exec: "新线程",
|
||||
resume: "继续线程",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const roleLabels = {
|
||||
en: {
|
||||
user: "User",
|
||||
leader: "Leader",
|
||||
worker: "Worker",
|
||||
},
|
||||
"zh-CN": {
|
||||
user: "用户",
|
||||
leader: "Leader",
|
||||
worker: "Worker",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function humanize(value: string): string {
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
export function formatStageLabelValue(stage: string | undefined, locale: Locale, fallback: string): string {
|
||||
if (!stage) return fallback;
|
||||
return stageLabels[locale][stage as keyof (typeof stageLabels)[Locale]] ?? humanize(stage);
|
||||
}
|
||||
|
||||
export function formatRunModeLabelValue(mode: string | undefined, locale: Locale, fallback: string): string {
|
||||
if (!mode) return fallback;
|
||||
return runModeLabels[locale][mode as keyof (typeof runModeLabels)[Locale]] ?? humanize(mode);
|
||||
}
|
||||
|
||||
export function formatRoleLabelValue(role: string, locale: Locale): string {
|
||||
return roleLabels[locale][role as keyof (typeof roleLabels)[Locale]] ?? humanize(role);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Locale = "zh-CN" | "en";
|
||||
|
||||
export const defaultLocale: Locale = "zh-CN";
|
||||
@@ -0,0 +1,536 @@
|
||||
import { en, type AppCopy } from "./en";
|
||||
|
||||
export const zhCN: AppCopy = {
|
||||
...en,
|
||||
shell: {
|
||||
kicker: "AI 交付工作流",
|
||||
title: "交付控制台",
|
||||
},
|
||||
tabs: {
|
||||
workflow: "Leader 控制台",
|
||||
roles: "执行体",
|
||||
skills: "技能",
|
||||
executions: "运行",
|
||||
merges: "合并队列",
|
||||
},
|
||||
localeToggle: {
|
||||
label: "语言",
|
||||
zh: "中文",
|
||||
en: "EN",
|
||||
},
|
||||
common: {
|
||||
...en.common,
|
||||
close: "关闭",
|
||||
cancel: "取消",
|
||||
retry: "重试",
|
||||
loading: "加载中...",
|
||||
loadingPage: "页面加载中...",
|
||||
send: "发送",
|
||||
sending: "发送中...",
|
||||
sendUpdate: "发送更新",
|
||||
create: "创建",
|
||||
creating: "创建中...",
|
||||
confirm: "确认",
|
||||
edit: "编辑",
|
||||
active: "活跃",
|
||||
notStarted: "未开始",
|
||||
backToLatest: "回到最新",
|
||||
previous: "上一页",
|
||||
next: "下一页",
|
||||
showNote: "显示说明",
|
||||
hideNote: "隐藏说明",
|
||||
latestReply: "最新回复",
|
||||
runNote: "运行说明",
|
||||
openWorkflow: "打开工作流",
|
||||
copyPath: "复制路径",
|
||||
pathCopied: "路径已复制",
|
||||
noMessageContent: "没有消息内容。",
|
||||
emptyMessage: "(空消息)",
|
||||
emptyBody: "(空)",
|
||||
never: "从未",
|
||||
running: "运行中",
|
||||
fallback: "-",
|
||||
},
|
||||
workspaceRequired: {
|
||||
eyebrow: "项目上下文",
|
||||
title: "选择项目",
|
||||
detail: (subject: string) =>
|
||||
`请从右上角的项目选择器中选择一个项目以加载${subject}。`,
|
||||
},
|
||||
globalPendingIndicator: {
|
||||
label: "待处理",
|
||||
title: (topic: string, count: number) =>
|
||||
count === 1
|
||||
? `打开待确认主题 ${topic}`
|
||||
: `打开 ${topic},查看 ${count} 个待处理项`,
|
||||
ariaLabel: (count: number, topic: string) =>
|
||||
count === 1
|
||||
? `1 个待处理项,打开主题 ${topic}`
|
||||
: `${count} 个待处理项,打开主题 ${topic}`,
|
||||
modalTitle: (count: number) =>
|
||||
count === 1 ? "1 个待处理项" : `${count} 个待处理项`,
|
||||
modalDetail: "这些主题正在等待计划确认。点击其中一项可进入详情页处理。",
|
||||
openAction: "打开",
|
||||
},
|
||||
metadata: {
|
||||
workflow: {
|
||||
label: "Leader 控制台",
|
||||
description: "以 leader console 视角查看用户对话、lane 编排、task 图和执行状态。",
|
||||
},
|
||||
roles: {
|
||||
label: "执行体",
|
||||
description: "监控 leader 与 worker 运行状态、注意力和最近活动。",
|
||||
},
|
||||
skills: {
|
||||
label: "技能",
|
||||
description: "把共享技能目录从角色分配中拆出来,独立维护全局能力。",
|
||||
},
|
||||
executions: {
|
||||
label: "运行",
|
||||
description: "查看当前工作区中的 agent 运行记录、输出与完成状态。",
|
||||
},
|
||||
merges: {
|
||||
label: "合并队列",
|
||||
description: "在提升变更前查看待合并项、变更文件和 merge readiness。",
|
||||
},
|
||||
workspaceSuffix: (workspace: string) => `当前工作区:${workspace}。`,
|
||||
},
|
||||
roleStatus: {
|
||||
workspaceSubject: "角色状态",
|
||||
listTitle: "执行体",
|
||||
listDetail: "展示当前工作区里的 leader 与 worker 状态。",
|
||||
conciseDescription: {
|
||||
leader: "负责与用户对话、拆分 task graph 与 lane、并做最终决策。",
|
||||
worker: "负责在隔离执行环境里完成 task。",
|
||||
},
|
||||
states: {
|
||||
attention: "需要处理",
|
||||
active: "活跃",
|
||||
idle: "未开始",
|
||||
waiting: (count: number) => `${count} 条待处理`,
|
||||
},
|
||||
loadingTitle: "角色加载中...",
|
||||
errorTitle: "无法加载角色状态",
|
||||
emptyTitle: "还没有角色状态",
|
||||
emptyDetail:
|
||||
"当这个工作区开始运行需求挖掘或工作流线程后,角色状态会显示在这里。",
|
||||
eyebrow: "运行值班表",
|
||||
heroTitle: "实时查看哪些角色正在运行、被阻塞,或尚未开始。",
|
||||
heroDetail:
|
||||
"这个视图让编排层更易读:谁在运行、谁有收件箱任务、哪些角色还未进入线程。",
|
||||
summary: {
|
||||
activeAgents: "活跃角色",
|
||||
activeAgentsDetail: "当前有活动会话的角色。",
|
||||
needsAttention: "需要处理",
|
||||
needsAttentionDetail: "收件箱中仍有待处理任务的角色。",
|
||||
waitingItems: "待处理项",
|
||||
waitingItemsDetail: "整个角色列表中的排队消息总数。",
|
||||
notStarted: "未开始",
|
||||
notStartedDetail: "尚未进入当前工作区的角色。",
|
||||
},
|
||||
lastActive: "最近活跃",
|
||||
},
|
||||
roleEditor: {
|
||||
...en.roleEditor,
|
||||
title: (roleName: string) => `编辑:${roleName}`,
|
||||
roleTab: "角色",
|
||||
roleSkillsTab: "角色技能",
|
||||
loading: "加载中...",
|
||||
name: "名称",
|
||||
sortOrder: "排序",
|
||||
description: "说明",
|
||||
descriptionPlaceholder: "这个角色负责什么",
|
||||
systemPrompt: "系统提示词",
|
||||
promptPlaceholder: "这个角色的完整系统提示词...",
|
||||
codexConfig: "Codex 配置",
|
||||
codexConfigPlaceholder:
|
||||
"model = \"gpt-5.4\"\napproval_policy = \"never\"\nsandbox_mode = \"workspace-write\"",
|
||||
codexAuth: "Codex 认证",
|
||||
codexAuthPlaceholder:
|
||||
"{\n \"OPENAI_API_KEY\": \"...\"\n}",
|
||||
skillsEmpty: "这个角色还没有可用技能。",
|
||||
skillMissing: "缺失",
|
||||
skillsGroupingScope: "技能分组会在角色间共享。共享技能内容请在技能页编辑。",
|
||||
skillGroupLabel: "分组",
|
||||
skillGroupPlaceholder: "group-name",
|
||||
saveRole: "保存角色",
|
||||
saveSkills: "保存技能",
|
||||
saving: "保存中...",
|
||||
},
|
||||
messageWorkflow: {
|
||||
workspaceSubject: "消息历史",
|
||||
eyebrow: "活动流",
|
||||
loadingTitle: "活动加载中...",
|
||||
errorTitle: "无法加载活动",
|
||||
emptyTitle: "还没有活动",
|
||||
emptyDetail:
|
||||
"当第一条交接、澄清或决策消息进入 Workflow 后,这里就会开始显示内容。",
|
||||
heroTitle: "把每一次交接、决策和澄清变成一条可读的活动流。",
|
||||
heroDetail:
|
||||
"这个视图把消息路由变成工作叙事,让你快速看清楚发生了什么、是谁推动了它、下一步会去哪里。",
|
||||
summary: {
|
||||
messages: "消息数",
|
||||
messagesDetail: "当前工作区时间线中捕获的消息。",
|
||||
expanded: "展开中",
|
||||
expandedDetail: "当前打开以便深入阅读的卡片数量。",
|
||||
},
|
||||
expandMessage: "展开完整消息",
|
||||
collapseMessage: "收起完整消息",
|
||||
},
|
||||
executions: {
|
||||
workspaceSubject: "运行历史",
|
||||
eyebrow: "运行账本",
|
||||
loadingTitle: "运行记录加载中...",
|
||||
errorTitle: "无法加载运行记录",
|
||||
emptyTitle: "还没有运行记录",
|
||||
emptyDetail:
|
||||
"当这个项目开始工作后,这里会显示 agent 的运行记录,包括新线程、恢复会话和验证轮次。",
|
||||
heroTitle: "按时间清晰查看整个工作区里每一次 agent 执行。",
|
||||
heroDetail:
|
||||
"跟踪是谁开始了工作、哪些线程被恢复,以及哪里还存在失败或长时间运行的执行需要关注。",
|
||||
summary: {
|
||||
running: "运行中",
|
||||
runningDetail: "仍在进行中的实时执行。",
|
||||
resumed: "已恢复",
|
||||
resumedDetail: "继续既有线程的运行。",
|
||||
failed: "失败",
|
||||
failedDetail: "以非零退出码结束的运行。",
|
||||
},
|
||||
newRunsAvailable: "有新的运行记录",
|
||||
newRunsDetail: "你在查看旧历史时,有新的执行已经到达。",
|
||||
topic: "主题",
|
||||
phase: "阶段",
|
||||
thread: "线程",
|
||||
duration: "耗时",
|
||||
started: "开始时间",
|
||||
recentRuns: "最近运行",
|
||||
recentRunsTitle: "按最新优先排序,方便快速分诊。",
|
||||
role: "角色",
|
||||
exitCode: "退出码",
|
||||
details: "详情",
|
||||
noDetail: "-",
|
||||
mobileShowing: (visible: number, total: number) =>
|
||||
`显示 ${visible} / ${total} 条运行记录。`,
|
||||
desktopShowing: (start: number, end: number, total: number) =>
|
||||
`显示第 ${start}-${end} 条,共 ${total} 条运行记录。`,
|
||||
loadOlderRuns: "加载更早的运行",
|
||||
pageLabel: (page: number, totalPages: number) => `第 ${page} / ${totalPages} 页`,
|
||||
},
|
||||
mergeQueue: {
|
||||
workspaceSubject: "合并请求",
|
||||
eyebrow: "合并队列",
|
||||
loadingTitle: "合并队列加载中...",
|
||||
errorTitle: "无法加载合并队列",
|
||||
emptyTitle: "当前没有待合并项",
|
||||
emptyDetail:
|
||||
"当一次构建完成并准备好进入审查、批准和合并回主项目分支时,变更会显示在这里。",
|
||||
heroTitle: "把准备审查的变更收敛到一个冷静、可追踪的队列里。",
|
||||
heroDetail:
|
||||
"这个视图用于做最终判断:哪些还在等待、哪些已顺利合并、哪些请求仍需要人工介入后才能落地。",
|
||||
summary: {
|
||||
pending: "待处理",
|
||||
pendingDetail: "等待批准或合并。",
|
||||
merged: "已合并",
|
||||
mergedDetail: "已经落回目标分支。",
|
||||
failed: "失败",
|
||||
failedDetail: "因错误或冲突而受阻的请求。",
|
||||
},
|
||||
status: {
|
||||
pending: "待处理",
|
||||
merged: "已合并",
|
||||
failed: "失败",
|
||||
},
|
||||
filesChanged: (count: number) => `${count} 个文件变更`,
|
||||
loadDiffError: "无法加载文件 diff。",
|
||||
failedToMerge: "合并失败",
|
||||
files: "文件",
|
||||
created: "创建于",
|
||||
merged: "合并于",
|
||||
mergeAction: "批准并合并",
|
||||
merging: "合并中...",
|
||||
},
|
||||
workflow: {
|
||||
topicExamplesLabel: "示例主题",
|
||||
starterTopicsLabel: "起始主题",
|
||||
kickoffChecklist: [
|
||||
{
|
||||
label: "先找负责人",
|
||||
detail:
|
||||
"第一条消息先发给 leader,由 leader 决定是继续澄清、派生 lane,还是直接启动执行。",
|
||||
},
|
||||
{
|
||||
label: "锚定目标",
|
||||
detail:
|
||||
"总结用户结果、约束和完成标准,让接收方知道自己该优化什么。",
|
||||
},
|
||||
{
|
||||
label: "让首次交接可执行",
|
||||
detail:
|
||||
"使用能够跨越多轮评审、构建和验证的主题名与开场消息。",
|
||||
},
|
||||
],
|
||||
topicCount: (count: number) => `${count} 条消息`,
|
||||
noMessageContent: "暂无消息内容。",
|
||||
recipient: "接收方",
|
||||
phase: "阶段",
|
||||
message: "消息",
|
||||
messageHint: (shortcut: string) => `${shortcut}+Enter 发送`,
|
||||
disabledPlaceholder: "先给主题命名...",
|
||||
enabledPlaceholder: "概述下一步决策、交接或构建请求...",
|
||||
sendFailed: "发送失败",
|
||||
createTopicFailed: "创建主题失败。",
|
||||
deleteTopic: "删除主题",
|
||||
confirmDeleteTopic: "确认删除",
|
||||
deletingTopic: "删除主题中",
|
||||
deleteTopicFailed: "删除主题失败。",
|
||||
deleteTopicDialogTitle: "要删除这个主题吗?",
|
||||
deleteTopicDialogDetail:
|
||||
"确认后会从 Leader 控制台永久移除这个主题及其工作流历史。",
|
||||
stopTopic: "停止主题",
|
||||
confirmStopTopic: "确认停止",
|
||||
stoppingTopic: "停止主题中",
|
||||
stopTopicFailed: "停止主题失败。",
|
||||
stopTopicDialogTitle: "要停止这个主题吗?",
|
||||
stopTopicDialogDetail:
|
||||
"确认后会终止这个主题后续的执行流程。只有在确认不该继续推进时再执行。",
|
||||
confirmPlan: "确认计划",
|
||||
confirmingPlan: "确认计划中",
|
||||
confirmPlanFailed: "确认计划失败。",
|
||||
topicAwaitingApproval: "待确认",
|
||||
topicStopped: "已停止",
|
||||
topicStatus: (status: string) => `状态:${status}`,
|
||||
pendingPlanBanner: {
|
||||
title: (count: number) => count === 1 ? "有 1 个主题等待计划确认" : `有 ${count} 个主题等待计划确认`,
|
||||
detail: "计划确认前不会开始执行。点击具体主题进入详情页查看并确认。",
|
||||
openTopic: (topic: string) => `打开 ${topic}`,
|
||||
},
|
||||
planReview: {
|
||||
eyebrow: "计划确认",
|
||||
title: "先确认冻结计划,再开始执行",
|
||||
detail: "这个主题已经生成候选 task graph。在计划确认之前,执行画布会被隐藏。",
|
||||
summaryTitle: "计划摘要",
|
||||
summaryEmpty: "Leader 已冻结计划,但没有写入计划摘要。",
|
||||
proposedLanes: "候选 lane",
|
||||
proposedTasks: "计划中的任务",
|
||||
lanesEmpty: "当前还没有候选 lane。",
|
||||
tasksEmpty: "当前还没有候选任务。",
|
||||
versionLabel: (version: number) => `计划 v${version}`,
|
||||
createdByLabel: (role: string) => `由 ${role} 生成`,
|
||||
},
|
||||
untitledTopic: "(未命名主题)",
|
||||
workspaceSubject: "工作流",
|
||||
errorTitle: "无法加载工作流",
|
||||
topics: "主题",
|
||||
newTopic: "+ 新建",
|
||||
hideTopics: "收起",
|
||||
showTopics: "展开主题",
|
||||
topicName: "主题名",
|
||||
topicPlaceholder: "topic-name",
|
||||
topicHint: "回车创建,Esc 取消",
|
||||
emptyTopicRailTitle: "还没有主题",
|
||||
emptyTopicRailDetail:
|
||||
"从这里发起第一条 leader 线程,后续由 leader 拆分成 task graph、lane 和执行链路。",
|
||||
srHeading: (topic: string | null) =>
|
||||
topic ? `主题 ${topic}` : "工作流主题详情",
|
||||
emptyState: {
|
||||
eyebrow: "工作流",
|
||||
namingTitle: "给这个主题命名",
|
||||
initialTitle: "发起一个主题,并推动它穿过整个团队",
|
||||
namingDetail:
|
||||
"先在左侧给主题起一个稳定的名字,再发送第一条发给 leader 的开场消息。",
|
||||
initialDetail:
|
||||
"每个主题都会变成 leader 持有的主线程。先明确目标,再让 leader 决定如何拆解 task graph 和 lane。",
|
||||
startFirstTopic: "+ 开始第一个主题",
|
||||
whatHappensNext: "接下来会发生什么",
|
||||
strongFirstHandoff: "一条强有力的首次交接",
|
||||
exampleOpeningTitle: "示例开场",
|
||||
creatingSideNote:
|
||||
"尽量一个主题对应一个结果。这样在计划、构建和验证交叠时,时间线也会更清晰。",
|
||||
nonCreatingSideNote:
|
||||
"尽量一个主题对应一个结果。这样在计划、构建和验证交叠时,时间线也会更清晰。",
|
||||
},
|
||||
runningSummary: {
|
||||
single: (role: string) => `${role} 正在运行`,
|
||||
double: (left: string, right: string) => `${left} + ${right} 正在运行`,
|
||||
multiple: (count: number) => `${count} 个角色正在运行`,
|
||||
},
|
||||
topicSignals: {
|
||||
running: (count: number) => `${count} 个运行中`,
|
||||
waiting: (count: number) => `${count} 个排队中`,
|
||||
},
|
||||
summary: {
|
||||
eyebrow: "当前主题",
|
||||
detail:
|
||||
"先看谁该接手、谁还在排队、这个主题是否已经从计划推进到构建和验证,而不是先读整段消息历史。",
|
||||
runningLabel: "运行中",
|
||||
waitingLabel: "排队中",
|
||||
activeLabel: "推进中",
|
||||
running: (count: number) => `${count} 个角色仍在执行。`,
|
||||
waiting: (count: number) => `${count} 个角色正在等待下一次交接。`,
|
||||
active: (count: number) => `${count} 个角色仍参与当前流转。`,
|
||||
},
|
||||
board: {
|
||||
detail:
|
||||
"每个角色固定占据一条泳道,直接交接按时间往下叠放,这样活动变多时也还能看清交接顺序。",
|
||||
hint:
|
||||
"点角色列头查看执行上下文,点交接条目查看这次往返背后的消息记录。",
|
||||
laneCount: (count: number) => `${count} 条消息`,
|
||||
},
|
||||
selection: {
|
||||
eyebrow: "当前上下文",
|
||||
emptyTitle: "选择一个角色或链路",
|
||||
emptyDetail:
|
||||
"在泳道里点一个角色列头或交接条目,这里就会显示对应的上下文。",
|
||||
messageEyebrow: "消息详情",
|
||||
messageTitle: "当前交接消息",
|
||||
messageDetail:
|
||||
"先把这条消息完整看完,再用收件角色按钮看各自的处理消息;只有在需要更多上下文时再打开链路。",
|
||||
messageBody: "完整消息",
|
||||
sourceEyebrow: "发送方",
|
||||
responseEyebrow: "接收方",
|
||||
responseTitle: (role: string) => `${role} 的处理消息`,
|
||||
responsePending: (role: string) => `${role} 还没有发出处理消息。`,
|
||||
viewResponse: (role: string) => `查看 ${role} 处理消息`,
|
||||
laneEyebrow: "消息链路",
|
||||
laneTitle: (from: string, to: string) => `${from} 到 ${to}`,
|
||||
laneDetail:
|
||||
"这条链路聚合的是当前主题里从源角色直接发给目标角色的消息。",
|
||||
laneMessages: "链路上的消息",
|
||||
noLaneMessages: "这条链路上还没有直接消息。",
|
||||
openLane: (role: string) => `打开 ${role} 链路`,
|
||||
openRole: (role: string) => `打开 ${role}`,
|
||||
threadTitle: "主题最新更新",
|
||||
threadDetail:
|
||||
"在查看单条链路时,也保持整条线程的最新跨角色更新可见。",
|
||||
showThread: "展开主题最新更新",
|
||||
hideThread: "收起主题最新更新",
|
||||
},
|
||||
focusTabs: {
|
||||
label: "工作流视图",
|
||||
overview: "概览",
|
||||
roles: "角色",
|
||||
timeline: "时间线",
|
||||
},
|
||||
nextStep: {
|
||||
eyebrow: "下一步",
|
||||
queuedTitle: (role: string) => `${role} 已接到球`,
|
||||
queuedDetail:
|
||||
"这个角色已经拿到最新交接。先去角色视图看上下文,只有在计划变化时再补发修正消息。",
|
||||
runningTitle: (role: string) => `${role} 正在处理`,
|
||||
runningDetail:
|
||||
"执行已经开始。先到角色视图看实时输出,只有在优先级变化时再改派。",
|
||||
recentTitle: (role: string) => `下一条交接优先发给 ${role}`,
|
||||
recentDetail:
|
||||
"按当前主题的最新活动来看,它是最清晰的下一位负责人。",
|
||||
idleTitle: "先交给 Product",
|
||||
idleDetail:
|
||||
"如果负责人还不明确,先把下一条更新发给 Product,由它冻结范围并路由执行。",
|
||||
inspectRole: (role: string) => `查看 ${role}`,
|
||||
},
|
||||
composer: {
|
||||
eyebrow: "发送交接",
|
||||
title: "先发下一条更新",
|
||||
detail:
|
||||
"这里的消息会直接发给 leader,由 leader 决定如何拆解和派发。",
|
||||
inspectRole: (role: string) => `聚焦 ${role}`,
|
||||
},
|
||||
humanTask: {
|
||||
eyebrow: "等待你处理",
|
||||
title: "有角色正在等你回答",
|
||||
detail:
|
||||
"这条请求已经被路由到宿主机侧的人类交互通道,而不是 Codex runtime。直接在这里回复即可继续推进工作流。",
|
||||
fromLabel: (role: string) => `${role} 正在等待你的回复`,
|
||||
promptLabel: "对你的请求",
|
||||
responseLabel: "你的回答",
|
||||
responseHint: (shortcut: string) => `${shortcut}+Enter 回答`,
|
||||
placeholder: "补充问题答案、澄清范围,或解除下一步阻塞...",
|
||||
send: "发送回答",
|
||||
failed: "发送回答失败。",
|
||||
remaining: (count: number) => `完成这条后,还有 ${count} 条待回答请求。`,
|
||||
},
|
||||
overview: {
|
||||
recentEyebrow: "最近活动",
|
||||
recentTitle: "最新事件",
|
||||
recentDetail:
|
||||
"先扫一眼最近的交接和运行,再决定要不要进入完整时间线。",
|
||||
noRecentTitle: "还没有事件",
|
||||
noRecentDetail:
|
||||
"先发出开场简报,第一批角色运行记录就会出现在这里。",
|
||||
snapshotEyebrow: "流转快照",
|
||||
snapshotTitle: "谁正在推进",
|
||||
snapshotDetail:
|
||||
"默认先让当前状态变得可读,而不是一上来就把完整角色图塞满首屏。",
|
||||
runningTitle: "运行中",
|
||||
waitingTitle: "等待接手",
|
||||
recentTitleShort: "最近参与",
|
||||
emptyRunning: "当前没有角色在运行。",
|
||||
emptyWaiting: "当前没有待接手的交接。",
|
||||
emptyRecentRoles: "还没有最近参与的角色。",
|
||||
openRoles: "打开角色图",
|
||||
openTimeline: "打开时间线",
|
||||
},
|
||||
sandbox: {
|
||||
eyebrow: "实时泳道",
|
||||
title: "交接时间泳道",
|
||||
detail:
|
||||
"每个角色各占一条固定泳道,直接交接按时间往下排;执行状态留在列头和角色详情里。",
|
||||
linkCount: (count: number) => `${count} 条链路`,
|
||||
emptyTitle: "还没有交接",
|
||||
emptyDetail:
|
||||
"只要有角色把工作交给下一个角色,这里就会出现消息条目。运行和失败状态仍会显示在角色详情里。",
|
||||
},
|
||||
roleState: {
|
||||
running: "运行中",
|
||||
queued: "排队中",
|
||||
recent: "最近参与",
|
||||
idle: "空闲",
|
||||
},
|
||||
inspector: {
|
||||
eyebrow: "角色检查器",
|
||||
title: "角色详情",
|
||||
noSelection: "选择一个角色,查看它的交接、队列压力和当前执行状态。",
|
||||
globalInbox: "全局收件箱",
|
||||
globalInboxValue: (count: number) => `全局待处理 ${count} 条`,
|
||||
lastSession: (value: string) => `最近会话 ${value}`,
|
||||
sessionMissing: "还没有记录到会话。",
|
||||
latestInbound: "最近收到",
|
||||
latestOutbound: "最近发出",
|
||||
noInbound: "这个主题下还没有发给它的交接。",
|
||||
noOutbound: "这个主题下它还没有发出交接。",
|
||||
currentRun: "当前运行",
|
||||
noCurrentRun: "这个主题下还没有运行记录。",
|
||||
startedAt: (value: string) => `开始于 ${value}`,
|
||||
completedAt: (value: string) => `完成于 ${value}`,
|
||||
inFlight: "仍在运行…",
|
||||
exitCode: (value: number) => `退出码 ${value}`,
|
||||
liveOutput: "实时输出",
|
||||
waitingLiveOutput: "等待实时输出...",
|
||||
noLiveOutput: "暂未捕获到实时输出。",
|
||||
},
|
||||
timeline: {
|
||||
eyebrow: "事件时间线",
|
||||
title: "主题事件",
|
||||
detail:
|
||||
"把消息和运行记录放在同一条时间线上,这样当前主题的操作故事才不会被拆散。",
|
||||
count: (count: number) => `${count} 条事件`,
|
||||
emptyTitle: "还没有事件",
|
||||
emptyDetail:
|
||||
"先发出开场简报,主题就会开始出现交接、运行和回复事件。",
|
||||
dispatchRunning: "运行中",
|
||||
dispatchSettled: (exitCode: number) => (exitCode === 0 ? "运行完成" : "运行失败"),
|
||||
},
|
||||
openingHandoff: {
|
||||
eyebrow: "开场交接",
|
||||
creatingTitle: "发送开场简报",
|
||||
regularTitle: "推动下一步",
|
||||
creatingDetail:
|
||||
"第一条消息应该写清目标结果、谁先行动,以及什么才算完成。",
|
||||
regularDetail:
|
||||
"用下面的编辑器把这个主题推进到下一阶段,并给接收方一个明确请求。",
|
||||
coverPoints: "请覆盖这些点",
|
||||
exampleOpeningTitle: "示例开场",
|
||||
exampleOpeningBody:
|
||||
"上线密码重置。Leader 先把范围讲清,再派生执行 lane;task 图准备好后,再启动 worker 容器执行。",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { useLocation } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import { useDocumentMetadata } from "./useDocumentMetadata";
|
||||
import { getWorkspaceFromPathname } from "../routes";
|
||||
|
||||
function MetadataProbe() {
|
||||
const location = useLocation();
|
||||
|
||||
useDocumentMetadata(location.pathname, getWorkspaceFromPathname(location.pathname));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useDocumentMetadata", () => {
|
||||
it("updates the document title and meta tags for the active route", async () => {
|
||||
renderWithProviders(<MetadataProbe />, {
|
||||
route: "/workspaces/phonesite/workflow",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Leader Console · phonesite | Delivery Console");
|
||||
});
|
||||
|
||||
expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe(
|
||||
"Operate the leader console: user dialogue, lane orchestration, task graph, and execution state. Active workspace: phonesite.",
|
||||
);
|
||||
expect(document.head.querySelector('meta[name="theme-color"]')?.getAttribute("content")).toBe(
|
||||
"#120e13",
|
||||
);
|
||||
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute("content")).toBe(
|
||||
"Leader Console · phonesite | Delivery Console",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { getDocumentMetadata, upsertMetaTag } from "../metadata";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export function useDocumentMetadata(pathname: string, workspace: string) {
|
||||
const { activeTheme, resolvedAppearance } = useTheme();
|
||||
const { copy, locale } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const metadata = getDocumentMetadata(pathname, workspace, locale);
|
||||
document.title = metadata.title;
|
||||
|
||||
upsertMetaTag({ name: "description" }, metadata.description);
|
||||
upsertMetaTag({ name: "application-name" }, copy.shell.title);
|
||||
upsertMetaTag({ name: "apple-mobile-web-app-title" }, copy.shell.title);
|
||||
upsertMetaTag({ name: "theme-color" }, activeTheme.swatches[0]);
|
||||
upsertMetaTag(
|
||||
{ name: "color-scheme" },
|
||||
resolvedAppearance === "dark" ? "dark light" : "light dark",
|
||||
);
|
||||
upsertMetaTag({ property: "og:title" }, metadata.title);
|
||||
upsertMetaTag({ property: "og:description" }, metadata.description);
|
||||
}, [activeTheme, copy.shell.title, locale, pathname, resolvedAppearance, workspace]);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePolling } from "./usePolling";
|
||||
|
||||
function setDocumentHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => hidden,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setDocumentHidden(false);
|
||||
});
|
||||
|
||||
describe("usePolling", () => {
|
||||
it("returns an idle state when no fetch function is provided", () => {
|
||||
const { result } = renderHook(() => usePolling(null));
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("fetches immediately and polls again on the next interval", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchFn = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("first")
|
||||
.mockResolvedValueOnce("second");
|
||||
|
||||
const { result } = renderHook(() => usePolling(fetchFn, 1000));
|
||||
|
||||
await act(async () => {
|
||||
await flushAsync();
|
||||
});
|
||||
expect(result.current.data).toBe("first");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await flushAsync();
|
||||
});
|
||||
expect(result.current.data).toBe("second");
|
||||
});
|
||||
|
||||
it("keeps the existing data reference when a custom change key is unchanged", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchFn = vi
|
||||
.fn<() => Promise<{ items: Array<{ id: string }> }>>()
|
||||
.mockResolvedValueOnce({ items: [{ id: "same" }] })
|
||||
.mockResolvedValueOnce({ items: [{ id: "same" }] });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling(fetchFn, 1000, {
|
||||
getChangeKey: (value) => value.items.map((item) => item.id).join("|"),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
const firstData = result.current.data;
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe(firstData);
|
||||
});
|
||||
|
||||
it("waits while the document is hidden and resumes on visibility change", async () => {
|
||||
const fetchFn = vi.fn<() => Promise<string>>().mockResolvedValue("visible");
|
||||
setDocumentHidden(true);
|
||||
|
||||
const { result } = renderHook(() => usePolling(fetchFn, 1000));
|
||||
|
||||
await act(async () => {
|
||||
await flushAsync();
|
||||
});
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
setDocumentHidden(false);
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.data).toBe("visible");
|
||||
});
|
||||
|
||||
it("does not start another request while one is already in flight", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveFetch: ((value: string) => void) | null = null;
|
||||
const fetchFn = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderHook(() => usePolling(fetchFn, 1000));
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch?.("done");
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await flushAsync();
|
||||
});
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts the latest fetcher immediately and ignores stale results when the source changes", async () => {
|
||||
let resolveFirst: ((value: string) => void) | null = null;
|
||||
let resolveSecond: ((value: string) => void) | null = null;
|
||||
const firstFetch = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const secondFetch = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fetchFn }) => usePolling(fetchFn, 1000),
|
||||
{ initialProps: { fetchFn: firstFetch as (() => Promise<string>) | null } },
|
||||
);
|
||||
|
||||
expect(firstFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
rerender({ fetchFn: secondFetch });
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
expect(secondFetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst?.("stale");
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveSecond?.("fresh");
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe("fresh");
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type PollFetcher<T> = () => Promise<T>;
|
||||
|
||||
interface UsePollingOptions<T> {
|
||||
getChangeKey?: (value: T) => unknown;
|
||||
}
|
||||
|
||||
interface UsePollingResult<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const UNSET_CHANGE_KEY = Symbol("unset-change-key");
|
||||
|
||||
function getDefaultChangeKey(value: unknown): unknown {
|
||||
if (value === null) return null;
|
||||
if (typeof value === "object") return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function usePolling<T>(
|
||||
fetchFn: PollFetcher<T> | null,
|
||||
interval: number = 5000,
|
||||
options?: UsePollingOptions<T>,
|
||||
): UsePollingResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(fetchFn !== null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dataRef = useRef<T | null>(null);
|
||||
const fetchRef = useRef(fetchFn);
|
||||
const getChangeKeyRef = useRef(options?.getChangeKey);
|
||||
const changeKeyRef = useRef<unknown>(UNSET_CHANGE_KEY);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const inFlightRef = useRef(false);
|
||||
const runFetchRef = useRef<() => void>(() => {});
|
||||
|
||||
fetchRef.current = fetchFn;
|
||||
getChangeKeyRef.current = options?.getChangeKey;
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchFn) {
|
||||
requestIdRef.current += 1;
|
||||
inFlightRef.current = false;
|
||||
clearTimer();
|
||||
changeKeyRef.current = UNSET_CHANGE_KEY;
|
||||
dataRef.current = null;
|
||||
setData(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clearTimer, fetchFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchFn) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
const schedule = () => {
|
||||
clearTimer();
|
||||
if (!active || document.hidden) return;
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
runFetchRef.current();
|
||||
}, interval);
|
||||
};
|
||||
|
||||
runFetchRef.current = async () => {
|
||||
const currentFetch = fetchRef.current;
|
||||
if (
|
||||
!active
|
||||
|| !currentFetch
|
||||
|| document.hidden
|
||||
|| inFlightRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
inFlightRef.current = true;
|
||||
const requestId = ++requestIdRef.current;
|
||||
let shouldSchedule = false;
|
||||
|
||||
try {
|
||||
const result = await currentFetch();
|
||||
if (!active || requestId !== requestIdRef.current) return;
|
||||
|
||||
const nextChangeKey = getChangeKeyRef.current
|
||||
? getChangeKeyRef.current(result)
|
||||
: getDefaultChangeKey(result);
|
||||
|
||||
if (!Object.is(nextChangeKey, changeKeyRef.current)) {
|
||||
changeKeyRef.current = nextChangeKey;
|
||||
dataRef.current = result;
|
||||
setData(result);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!active || requestId !== requestIdRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
const requestStillCurrent = active && requestId === requestIdRef.current;
|
||||
inFlightRef.current = false;
|
||||
if (requestStillCurrent) {
|
||||
setLoading(false);
|
||||
shouldSchedule = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSchedule) {
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
setLoading((current) => current || dataRef.current === null);
|
||||
runFetchRef.current();
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
runFetchRef.current();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
requestIdRef.current += 1;
|
||||
inFlightRef.current = false;
|
||||
clearTimer();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [clearTimer, fetchFn, interval]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!fetchRef.current || document.hidden) return;
|
||||
clearTimer();
|
||||
setLoading((current) => current || dataRef.current === null);
|
||||
runFetchRef.current();
|
||||
}, [clearTimer]);
|
||||
|
||||
return { data, loading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, useLocation } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDocumentMetadata } from "./hooks/useDocumentMetadata";
|
||||
import { I18nProvider, useI18n } from "./i18n";
|
||||
import { ThemeProvider } from "./theme";
|
||||
import { getWorkspaceFromPathname } from "./routes";
|
||||
|
||||
function I18nProbe() {
|
||||
const {
|
||||
locale,
|
||||
setLocale,
|
||||
copy,
|
||||
formatRelativeTime,
|
||||
formatRunModeLabel,
|
||||
formatStageLabel,
|
||||
} = useI18n();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{locale}</div>
|
||||
<div>{copy.tabs.workflow}</div>
|
||||
<div>{formatStageLabel("execution")}</div>
|
||||
<div>{formatRunModeLabel("resume")}</div>
|
||||
<div>{formatRelativeTime("2026-03-10T11:59:55Z")}</div>
|
||||
<button type="button" onClick={() => setLocale("en")}>
|
||||
Switch to English
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataProbe() {
|
||||
const location = useLocation();
|
||||
const { setLocale } = useI18n();
|
||||
|
||||
useDocumentMetadata(location.pathname, getWorkspaceFromPathname(location.pathname));
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setLocale("en")}>
|
||||
Switch metadata
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
window.localStorage.clear();
|
||||
document.documentElement.lang = "";
|
||||
document.title = "";
|
||||
});
|
||||
|
||||
describe("i18n", () => {
|
||||
it("defaults to zh-CN, persists locale changes, and updates bound formatters", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-10T12:00:00Z"));
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<I18nProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("zh-CN")).toBeInTheDocument();
|
||||
expect(screen.getByText("Leader 控制台")).toBeInTheDocument();
|
||||
expect(screen.getByText("构建")).toBeInTheDocument();
|
||||
expect(screen.getByText("继续线程")).toBeInTheDocument();
|
||||
expect(screen.getByText("现在")).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
expect(window.localStorage.getItem("inbox-dashboard-locale")).toBe("zh-CN");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch to English" }));
|
||||
|
||||
expect(screen.getByText("en")).toBeInTheDocument();
|
||||
expect(screen.getByText("Leader Console")).toBeInTheDocument();
|
||||
expect(screen.getByText("Build")).toBeInTheDocument();
|
||||
expect(screen.getByText("Continue thread")).toBeInTheDocument();
|
||||
expect(screen.getByText("now")).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
expect(window.localStorage.getItem("inbox-dashboard-locale")).toBe("en");
|
||||
});
|
||||
|
||||
it("reads the persisted locale on first render", () => {
|
||||
window.localStorage.setItem("inbox-dashboard-locale", "en");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-10T12:00:00Z"));
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<I18nProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("en")).toBeInTheDocument();
|
||||
expect(screen.getByText("Leader Console")).toBeInTheDocument();
|
||||
expect(screen.getByText("Build")).toBeInTheDocument();
|
||||
expect(screen.getByText("Continue thread")).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
it("updates document metadata when the locale changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<MemoryRouter initialEntries={["/workspaces/phonesite/workflow"]}>
|
||||
<MetadataProbe />
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Leader 控制台 · phonesite | 交付控制台");
|
||||
});
|
||||
expect(
|
||||
document.head.querySelector('meta[name="description"]')?.getAttribute("content"),
|
||||
).toBe("以 leader console 视角查看用户对话、lane 编排、task 图和执行状态。 当前工作区:phonesite。");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Switch metadata" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Leader Console · phonesite | Delivery Console");
|
||||
});
|
||||
expect(
|
||||
document.head.querySelector('meta[name="description"]')?.getAttribute("content"),
|
||||
).toBe(
|
||||
"Operate the leader console: user dialogue, lane orchestration, task graph, and execution state. Active workspace: phonesite.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import {
|
||||
defaultLocale,
|
||||
formatRoleLabel,
|
||||
formatRunModeLabel,
|
||||
formatStageLabel,
|
||||
getCopy,
|
||||
isLocale,
|
||||
type AppCopy,
|
||||
type Locale,
|
||||
} from "./copy";
|
||||
import {
|
||||
formatAbsoluteDateTime,
|
||||
formatClockTime,
|
||||
formatDuration,
|
||||
formatInboxDateTime,
|
||||
formatInboxTime,
|
||||
formatInboxTimestampLabel,
|
||||
formatRelativeTime,
|
||||
formatTimestampLabel,
|
||||
} from "./utils/format";
|
||||
|
||||
const storageKey = "inbox-dashboard-locale";
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: Dispatch<SetStateAction<Locale>>;
|
||||
copy: AppCopy;
|
||||
formatStageLabel: (stage?: string) => string;
|
||||
formatRunModeLabel: (mode?: string) => string;
|
||||
formatRoleLabel: (role: string) => string;
|
||||
formatAbsoluteDateTime: (value: string | Date, fallback?: string) => string;
|
||||
formatTimestampLabel: (value: string | Date, fallback?: string) => string;
|
||||
formatClockTime: (value: string | Date, fallback?: string) => string;
|
||||
formatInboxTime: (file: string) => string;
|
||||
formatInboxTimestampLabel: (file: string) => string;
|
||||
formatInboxDateTime: (file: string) => string;
|
||||
formatRelativeTime: (value: string, fallback?: string) => string;
|
||||
formatDuration: (start: string, end?: string) => string;
|
||||
}
|
||||
|
||||
export function getStoredLocale(): Locale {
|
||||
if (typeof window === "undefined") return defaultLocale;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
return isLocale(stored) ? stored : defaultLocale;
|
||||
} catch {
|
||||
return defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultContext: I18nContextValue = {
|
||||
locale: defaultLocale,
|
||||
setLocale: () => undefined,
|
||||
copy: getCopy(defaultLocale),
|
||||
formatStageLabel: (stage) => formatStageLabel(stage, defaultLocale),
|
||||
formatRunModeLabel: (mode) => formatRunModeLabel(mode, defaultLocale),
|
||||
formatRoleLabel: (role) => formatRoleLabel(role, defaultLocale),
|
||||
formatAbsoluteDateTime: (value, fallback) =>
|
||||
formatAbsoluteDateTime(value, defaultLocale, fallback),
|
||||
formatTimestampLabel: (value, fallback) =>
|
||||
formatTimestampLabel(value, defaultLocale, fallback),
|
||||
formatClockTime: (value, fallback) => formatClockTime(value, defaultLocale, fallback),
|
||||
formatInboxTime: (file) => formatInboxTime(file, defaultLocale),
|
||||
formatInboxTimestampLabel: (file) =>
|
||||
formatInboxTimestampLabel(file, defaultLocale),
|
||||
formatInboxDateTime: (file) => formatInboxDateTime(file, defaultLocale),
|
||||
formatRelativeTime: (value, fallback) =>
|
||||
formatRelativeTime(value, defaultLocale, fallback),
|
||||
formatDuration: (start, end) => formatDuration(start, end, defaultLocale),
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>(defaultContext);
|
||||
|
||||
export function I18nProvider({
|
||||
children,
|
||||
initialLocale,
|
||||
persist = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
persist?: boolean;
|
||||
}) {
|
||||
const [locale, setLocale] = useState<Locale>(() =>
|
||||
initialLocale ?? (persist ? getStoredLocale() : defaultLocale),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = "ltr";
|
||||
}
|
||||
|
||||
if (!persist || typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, locale);
|
||||
} catch {
|
||||
// Ignore storage failures and keep the in-memory preference.
|
||||
}
|
||||
}, [locale, persist]);
|
||||
|
||||
const copy = useMemo(() => getCopy(locale), [locale]);
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
copy,
|
||||
formatStageLabel: (stage) => formatStageLabel(stage, locale),
|
||||
formatRunModeLabel: (mode) => formatRunModeLabel(mode, locale),
|
||||
formatRoleLabel: (role) => formatRoleLabel(role, locale),
|
||||
formatAbsoluteDateTime: (value, fallback) =>
|
||||
formatAbsoluteDateTime(value, locale, fallback),
|
||||
formatTimestampLabel: (value, fallback) =>
|
||||
formatTimestampLabel(value, locale, fallback),
|
||||
formatClockTime: (value, fallback) => formatClockTime(value, locale, fallback),
|
||||
formatInboxTime: (file) => formatInboxTime(file, locale),
|
||||
formatInboxTimestampLabel: (file) => formatInboxTimestampLabel(file, locale),
|
||||
formatInboxDateTime: (file) => formatInboxDateTime(file, locale),
|
||||
formatRelativeTime: (value, fallback) =>
|
||||
formatRelativeTime(value, locale, fallback),
|
||||
formatDuration: (start, end) => formatDuration(start, end, locale),
|
||||
}),
|
||||
[copy, locale],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@import "@xyflow/react/dist/style.css";
|
||||
@import "./styles/theme-vars.css";
|
||||
@import "./styles/app-shell.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.react-flow__execution-map {
|
||||
--xy-background-pattern-color-props: var(--app-divider-soft);
|
||||
background:
|
||||
radial-gradient(circle at top, color-mix(in srgb, var(--app-surface-muted) 92%, transparent), transparent 62%),
|
||||
var(--app-surface-base);
|
||||
}
|
||||
|
||||
.react-flow__execution-map .react-flow__renderer,
|
||||
.react-flow__execution-map .react-flow__pane {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.react-flow__execution-map .react-flow__controls {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-divider-soft);
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--app-surface-overlay) 92%, transparent);
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.react-flow__execution-map .react-flow__controls-button {
|
||||
border-bottom-color: var(--app-divider-soft);
|
||||
background: transparent;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.react-flow__execution-map .react-flow__controls-button:hover {
|
||||
background: color-mix(in srgb, var(--app-surface-muted) 88%, transparent);
|
||||
}
|
||||
|
||||
.react-flow__execution-map .react-flow__edge-path,
|
||||
.react-flow__execution-map .react-flow__connection-path {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchTopicRecords, fetchWorkspaces } from "../api/client";
|
||||
import { renderWithProviders } from "../test/renderWithProviders";
|
||||
import DashboardLayout from "./DashboardLayout";
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual = await vi.importActual("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
fetchTopicRecords: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
const fetchTopicRecordsMock = vi.mocked(fetchTopicRecords);
|
||||
const fetchWorkspacesMock = vi.mocked(fetchWorkspaces);
|
||||
|
||||
vi.mock("../components/ThemeSelector", () => ({
|
||||
default: function ThemeSelectorMock() {
|
||||
return <div>theme</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/WorkspaceSelector", () => ({
|
||||
default: function WorkspaceSelectorMock(props: {
|
||||
value: string;
|
||||
workspaces: Array<{ name: string }>;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
workspace:{props.value}:{props.workspaces.length}:{props.loading ? "loading" : "ready"}:{props.error ?? ""}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
function makeWorkspace(name: string) {
|
||||
return {
|
||||
id: `ws-${name}`,
|
||||
name,
|
||||
path: `/workspaces/${name}`,
|
||||
runtime_backend: "host",
|
||||
provision_state: "ready",
|
||||
container_state: "",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchTopicRecordsMock.mockResolvedValue([]);
|
||||
fetchWorkspacesMock.mockResolvedValue([makeWorkspace("alpha")]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("DashboardLayout", () => {
|
||||
it("renders primary navigation as links and marks the active page", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/workflow" element={<div>Workflow page</div>} />
|
||||
<Route path="/workspaces/:workspace/workflow" element={<div>Workflow page</div>} />
|
||||
<Route path="/roles" element={<div>Roles page</div>} />
|
||||
<Route path="/workspaces/:workspace/roles" element={<div>Roles page</div>} />
|
||||
<Route path="/skills" element={<div>Skills page</div>} />
|
||||
<Route path="/executions" element={<div>Executions page</div>} />
|
||||
<Route path="/workspaces/:workspace/executions" element={<div>Executions page</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
{ route: "/workspaces/alpha/roles", locale: "en" },
|
||||
);
|
||||
|
||||
const nav = await screen.findByRole("navigation", { name: "Primary" });
|
||||
const rolesLink = within(nav).getByRole("link", { name: "Agents" });
|
||||
const skillsLink = within(nav).getByRole("link", { name: "Skills" });
|
||||
const workflowLink = within(nav).getByRole("link", { name: "Leader Console" });
|
||||
|
||||
expect(rolesLink).toHaveAttribute("aria-current", "page");
|
||||
expect(rolesLink).toHaveAttribute("href", "/workspaces/alpha/roles");
|
||||
expect(skillsLink).toHaveAttribute("href", "/skills");
|
||||
expect(workflowLink).toHaveAttribute("href", "/workspaces/alpha/workflow");
|
||||
});
|
||||
|
||||
it("shows a visible language toggle in the header and updates shell copy immediately", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/workflow" element={<div>Workflow page</div>} />
|
||||
<Route path="/workspaces/:workspace/workflow" element={<div>Workflow page</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
{ route: "/workspaces/alpha/workflow" },
|
||||
);
|
||||
|
||||
const select = screen.getByRole("combobox", { name: "语言" });
|
||||
expect(select).toHaveValue("zh-CN");
|
||||
expect(screen.getByRole("link", { name: "Leader 控制台" })).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(select, "en");
|
||||
|
||||
expect(screen.getByRole("combobox", { name: /Language/i })).toHaveValue("en");
|
||||
expect(screen.getByRole("link", { name: "Leader Console" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces an invalid workspace in the URL before rendering the page", async () => {
|
||||
fetchWorkspacesMock.mockResolvedValue([makeWorkspace("alpha")]);
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/workflow" element={<div>Workflow page</div>} />
|
||||
<Route path="/workspaces/:workspace/workflow" element={<div>Workflow page</div>} />
|
||||
<Route path="/workspaces/:workspace/workflow/:topic" element={<div>Workflow page</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
{ route: "/workspaces/blog/workflow/launch", locale: "en" },
|
||||
);
|
||||
|
||||
const workflowLink = await screen.findByRole("link", { name: "Leader Console" });
|
||||
expect(workflowLink).toHaveAttribute("href", "/workspaces/alpha/workflow");
|
||||
expect(await screen.findByText("Workflow page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the same workspace header on global tabs and auto-selects a workspace", async () => {
|
||||
fetchWorkspacesMock.mockResolvedValue([makeWorkspace("alpha")]);
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/roles" element={<div>Roles page</div>} />
|
||||
<Route path="/workspaces/:workspace/roles" element={<div>Roles page</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
{ route: "/roles", locale: "en" },
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Roles page")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Agents" })).toHaveAttribute("href", "/workspaces/alpha/roles");
|
||||
expect(screen.getByRole("link", { name: "Leader Console" })).toHaveAttribute("href", "/workspaces/alpha/workflow");
|
||||
});
|
||||
|
||||
it("shows a global pending indicator and routes to the newest pending workflow topic", async () => {
|
||||
fetchTopicRecordsMock.mockResolvedValue([
|
||||
{
|
||||
name: "approval-latest",
|
||||
status: "awaiting_confirmation",
|
||||
space: "workflow",
|
||||
created_at: "2026-03-17T09:00:00Z",
|
||||
updated_at: "2026-03-17T10:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "approval-older",
|
||||
status: "awaiting_confirmation",
|
||||
space: "workflow",
|
||||
created_at: "2026-03-17T08:00:00Z",
|
||||
updated_at: "2026-03-17T09:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/workspaces/:workspace/roles" element={<div>Roles page</div>} />
|
||||
<Route path="/workspaces/:workspace/workflow/:topic" element={<div>Workflow detail page</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
{ route: "/workspaces/alpha/roles", locale: "en" },
|
||||
);
|
||||
|
||||
const pendingButton = await screen.findByRole("button", {
|
||||
name: "2 pending items. Open topic approval-latest",
|
||||
});
|
||||
expect(pendingButton).toHaveTextContent("2");
|
||||
|
||||
await user.click(pendingButton);
|
||||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 pending items")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /approval-latest/i })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /approval-latest/i }));
|
||||
|
||||
expect(await screen.findByText("Workflow detail page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("caps the global pending indicator at 99 plus", async () => {
|
||||
fetchTopicRecordsMock.mockResolvedValue(Array.from({ length: 120 }, (_, index) => ({
|
||||
name: `approval-${index}`,
|
||||
status: "awaiting_confirmation",
|
||||
space: "workflow",
|
||||
created_at: `2026-03-17T09:${String(index % 60).padStart(2, "0")}:00Z`,
|
||||
updated_at: `2026-03-17T10:${String(index % 60).padStart(2, "0")}:00Z`,
|
||||
})));
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/workspaces/:workspace/roles" element={<div>Roles page</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
{ route: "/workspaces/alpha/roles", locale: "en" },
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", {
|
||||
name: "120 pending items. Open topic approval-0",
|
||||
})).toHaveTextContent("99+");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { fetchTopicRecords, fetchWorkspaces } from "../api/client";
|
||||
import Button from "../components/ui/Button";
|
||||
import ThemeSelector from "../components/ThemeSelector";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from "../components/ui/Dialog";
|
||||
import LocaleSelect from "../components/LocaleSelect";
|
||||
import WorkspaceSelector from "../components/WorkspaceSelector";
|
||||
import type { Workspace } from "../types";
|
||||
import { useDocumentMetadata } from "../hooks/useDocumentMetadata";
|
||||
import { usePolling } from "../hooks/usePolling";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
buildSectionHref,
|
||||
buildWorkflowHref,
|
||||
dashboardSections,
|
||||
getSectionFromPathname,
|
||||
getTopicFromPathname,
|
||||
getWorkspaceFromPathname,
|
||||
isWorkspaceScopedSection,
|
||||
} from "../routes";
|
||||
import {
|
||||
fadeUp,
|
||||
motionDuration,
|
||||
motionEase,
|
||||
motionTransition,
|
||||
} from "../utils/motion";
|
||||
import { topicRecordListChangeKey } from "../utils/pollingKeys";
|
||||
|
||||
function formatPendingCount(count: number): string {
|
||||
if (count <= 0) return "0";
|
||||
if (count > 99) return "99+";
|
||||
return String(count);
|
||||
}
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { copy, formatRelativeTime } = useI18n();
|
||||
const workspace = getWorkspaceFromPathname(location.pathname);
|
||||
const topic = getTopicFromPathname(location.pathname);
|
||||
const activeTab = getSectionFromPathname(location.pathname);
|
||||
const sectionNeedsWorkspace = isWorkspaceScopedSection(activeTab);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [workspacesLoading, setWorkspacesLoading] = useState(true);
|
||||
const [workspacesError, setWorkspacesError] = useState<string | null>(null);
|
||||
const [globalWorkspace, setGlobalWorkspace] = useState("");
|
||||
const [pendingDialogOpen, setPendingDialogOpen] = useState(false);
|
||||
const prefersReducedMotion = useReducedMotion() ?? false;
|
||||
|
||||
const matchedWorkspace = workspaces.find((item) => item.name === workspace) ?? null;
|
||||
const firstWorkspace = workspaces[0]?.name ?? "";
|
||||
const selectedGlobalWorkspace = workspaces.find((item) => item.name === globalWorkspace)?.name ?? "";
|
||||
const currentWorkspace = sectionNeedsWorkspace
|
||||
? (matchedWorkspace?.name ?? firstWorkspace)
|
||||
: (selectedGlobalWorkspace || matchedWorkspace?.name || firstWorkspace);
|
||||
const tabs = dashboardSections.map((section) => ({
|
||||
...section,
|
||||
label: copy.tabs[section.id],
|
||||
}));
|
||||
const pendingFetcher = useCallback(
|
||||
() => fetchTopicRecords(currentWorkspace, "workflow"),
|
||||
[currentWorkspace],
|
||||
);
|
||||
|
||||
useDocumentMetadata(location.pathname, workspace);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWorkspacesLoading(true);
|
||||
setWorkspacesError(null);
|
||||
|
||||
fetchWorkspaces()
|
||||
.then((nextWorkspaces) => {
|
||||
if (cancelled) return;
|
||||
setWorkspaces(nextWorkspaces);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setWorkspacesError(
|
||||
error instanceof Error ? error.message : copy.workspaceSelector.loadProjectsError,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setWorkspacesLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [copy.workspaceSelector.loadProjectsError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (matchedWorkspace?.name) {
|
||||
if (globalWorkspace !== matchedWorkspace.name) {
|
||||
setGlobalWorkspace(matchedWorkspace.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
if (!workspacesLoading && globalWorkspace) {
|
||||
setGlobalWorkspace("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedGlobalWorkspace) {
|
||||
setGlobalWorkspace(firstWorkspace);
|
||||
}
|
||||
}, [
|
||||
firstWorkspace,
|
||||
globalWorkspace,
|
||||
matchedWorkspace?.name,
|
||||
selectedGlobalWorkspace,
|
||||
workspaces.length,
|
||||
workspacesLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionNeedsWorkspace || workspacesLoading || workspacesError || !currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
if (matchedWorkspace && workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(
|
||||
activeTab === "workflow"
|
||||
? buildWorkflowHref({ workspace: currentWorkspace, topic })
|
||||
: buildSectionHref(activeTab, currentWorkspace),
|
||||
{ replace: true },
|
||||
);
|
||||
}, [
|
||||
activeTab,
|
||||
currentWorkspace,
|
||||
matchedWorkspace,
|
||||
navigate,
|
||||
sectionNeedsWorkspace,
|
||||
topic,
|
||||
workspace,
|
||||
workspacesError,
|
||||
workspacesLoading,
|
||||
]);
|
||||
|
||||
const fullHeightRoute = activeTab === "workflow";
|
||||
const mainClassName = fullHeightRoute
|
||||
? "flex-1 min-h-0 overflow-visible lg:flex lg:flex-col lg:overflow-hidden"
|
||||
: "flex-1 min-h-0 overflow-visible lg:overflow-y-auto";
|
||||
const contentClassName = `mx-auto w-full max-w-7xl px-4 sm:px-6 ${fullHeightRoute ? "py-4 lg:flex lg:h-full lg:min-h-0 lg:flex-col" : "py-6"}`;
|
||||
const outletClassName = fullHeightRoute ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "w-full";
|
||||
|
||||
const handleWorkspaceChange = useCallback((ws: string) => {
|
||||
if (ws === currentWorkspace) return;
|
||||
setGlobalWorkspace(ws);
|
||||
if (!sectionNeedsWorkspace) {
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
activeTab === "workflow"
|
||||
? buildWorkflowHref({ workspace: ws, topic })
|
||||
: buildSectionHref(activeTab, ws),
|
||||
);
|
||||
}, [activeTab, currentWorkspace, navigate, sectionNeedsWorkspace, topic]);
|
||||
|
||||
const workspaceReady = sectionNeedsWorkspace
|
||||
? Boolean(workspace && (matchedWorkspace || workspacesError))
|
||||
: true;
|
||||
const { data: pendingTopicRecords } = usePolling(
|
||||
workspaceReady && currentWorkspace && activeTab !== "workflow" ? pendingFetcher : null,
|
||||
5000,
|
||||
{ getChangeKey: topicRecordListChangeKey },
|
||||
);
|
||||
const pendingTopics = (pendingTopicRecords ?? []).filter(
|
||||
(item) => item.status === "awaiting_confirmation",
|
||||
);
|
||||
const firstPendingTopic = pendingTopics[0]?.name ?? "";
|
||||
const pendingCount = pendingTopics.length;
|
||||
const pendingCountLabel = formatPendingCount(pendingCount);
|
||||
const showPendingIndicator =
|
||||
activeTab !== "workflow" && workspaceReady && pendingCount > 0 && firstPendingTopic;
|
||||
const handlePendingIndicatorClick = useCallback(() => {
|
||||
setPendingDialogOpen(true);
|
||||
}, []);
|
||||
const handlePendingTopicClick = useCallback((pendingTopic: string) => {
|
||||
if (!workspace || !pendingTopic) return;
|
||||
setPendingDialogOpen(false);
|
||||
navigate(buildWorkflowHref({ workspace, topic: pendingTopic }));
|
||||
}, [navigate, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingCount > 0) return;
|
||||
setPendingDialogOpen(false);
|
||||
}, [pendingCount]);
|
||||
|
||||
return (
|
||||
<div className="app-shell flex min-h-screen flex-col overflow-x-clip lg:h-[100dvh] lg:overflow-hidden">
|
||||
<header className="app-header-shell sticky top-0 z-30 shrink-0">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="app-scrollbar-hidden min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||||
<nav aria-label="Primary">
|
||||
<ul className="flex min-w-max items-center gap-1 py-1">
|
||||
{tabs.map((tab) => {
|
||||
const href = buildSectionHref(tab.id, currentWorkspace);
|
||||
|
||||
return (
|
||||
<li key={tab.id} className="shrink-0">
|
||||
<NavLink
|
||||
to={href}
|
||||
className={({ isActive }) => `relative isolate inline-flex min-h-12 items-center justify-center rounded-2xl px-4 py-3 text-center text-sm font-medium leading-none touch-manipulation transition-colors sm:min-h-11 sm:rounded-full sm:px-4 sm:py-2.5 sm:text-left ${
|
||||
isActive
|
||||
? "app-text-primary"
|
||||
: "app-text-soft hover:bg-[color:var(--app-chip-subtle-background)] hover:text-[color:var(--app-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<>
|
||||
<motion.span
|
||||
layoutId="activeTabSurface"
|
||||
className="absolute inset-0 rounded-full app-tab-active"
|
||||
transition={motionTransition(prefersReducedMotion, motionDuration.base, motionEase.decisive)}
|
||||
/>
|
||||
<motion.div
|
||||
layoutId="activeTabRule"
|
||||
className="absolute inset-x-4 -bottom-px h-px bg-[color:var(--app-accent)]"
|
||||
transition={motionTransition(prefersReducedMotion, motionDuration.base, motionEase.decisive)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="relative z-10">{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-stretch justify-end gap-2 sm:max-w-[42rem] lg:max-w-none">
|
||||
<div className="min-w-[min(17rem,100%)] flex-1 lg:min-w-[18rem] lg:flex-none">
|
||||
<WorkspaceSelector
|
||||
value={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
loading={workspacesLoading}
|
||||
error={workspacesError}
|
||||
onChange={handleWorkspaceChange}
|
||||
/>
|
||||
</div>
|
||||
<LocaleSelect />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={mainClassName}>
|
||||
<div className={contentClassName}>
|
||||
{workspaceReady ? (
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={fadeUp(prefersReducedMotion, 8)}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={motionTransition(prefersReducedMotion, motionDuration.base, motionEase.decisive)}
|
||||
className={outletClassName}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
) : (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="app-panel app-text-soft flex min-h-40 items-center justify-center rounded-[28px] px-6 py-12 text-sm"
|
||||
>
|
||||
{workspacesError
|
||||
? copy.workspaceSelector.unavailable
|
||||
: copy.workspaceSelector.loadingProjects}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{showPendingIndicator ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePendingIndicatorClick}
|
||||
title={copy.globalPendingIndicator.title(firstPendingTopic, pendingCount)}
|
||||
aria-label={copy.globalPendingIndicator.ariaLabel(pendingCount, firstPendingTopic)}
|
||||
className="app-floating-shadow fixed bottom-4 left-4 z-40 flex h-12 w-12 touch-manipulation items-center justify-center rounded-full border border-[color:var(--app-attention-border)] bg-[color:var(--app-surface-overlay)]/96 text-center backdrop-blur-sm transition-[transform,background-color,border-color,box-shadow] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-0.5 hover:bg-[color:var(--app-attention-background)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--app-accent)] focus-visible:ring-offset-2 sm:bottom-5 sm:left-5"
|
||||
>
|
||||
<span className="app-text-attention block text-[1.125rem] font-semibold leading-none tracking-[-0.04em]">
|
||||
{pendingCountLabel}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{copy.globalPendingIndicator.label}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<Dialog open={pendingDialogOpen} onOpenChange={setPendingDialogOpen}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay asChild>
|
||||
<motion.div
|
||||
className="app-dialog-backdrop fixed inset-0 z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
/>
|
||||
</DialogOverlay>
|
||||
<DialogContent asChild aria-describedby={undefined}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<motion.div
|
||||
className="app-panel flex w-full max-w-[32rem] flex-col overflow-hidden rounded-[28px] border border-[color:var(--app-divider)] bg-[color:var(--app-surface-overlay)]"
|
||||
initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="border-b border-[color:var(--app-divider)] px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-soft text-[11px] font-semibold uppercase tracking-[0.18em]">
|
||||
{copy.globalPendingIndicator.label}
|
||||
</p>
|
||||
<DialogTitle className="app-text-primary mt-1 text-base font-semibold">
|
||||
{copy.globalPendingIndicator.modalTitle(pendingCount)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="app-text-faint mt-1 text-sm leading-6">
|
||||
{copy.globalPendingIndicator.modalDetail}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="xs">{copy.common.close}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[min(28rem,60vh)] overflow-y-auto px-4 py-4">
|
||||
<div className="space-y-2">
|
||||
{pendingTopics.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => handlePendingTopicClick(item.name)}
|
||||
className="app-panel-muted w-full rounded-[22px] px-4 py-3 text-left transition-[transform,background-color,border-color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-0.5 hover:bg-[color:var(--app-surface-elevated)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--app-accent)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-primary truncate text-sm font-semibold">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="app-text-faint mt-2 text-xs">
|
||||
{item.description?.trim()
|
||||
|| formatRelativeTime(item.updated_at, copy.common.fallback)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="app-text-attention shrink-0 text-xs font-medium">
|
||||
{copy.globalPendingIndicator.openAction}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ThemeProvider } from "./theme";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defaultLocale, getCopy, type Locale } from "./copy";
|
||||
import { getSectionFromPathname } from "./routes";
|
||||
|
||||
function getWorkspaceSuffix(workspace?: string): string {
|
||||
const trimmed = workspace?.trim();
|
||||
return trimmed ? ` · ${trimmed}` : "";
|
||||
}
|
||||
|
||||
export function getDocumentMetadata(
|
||||
pathname: string,
|
||||
workspace?: string,
|
||||
locale: Locale = defaultLocale,
|
||||
) {
|
||||
const copy = getCopy(locale);
|
||||
const section = getSectionFromPathname(pathname);
|
||||
const workspaceLabel = workspace?.trim();
|
||||
const meta = copy.metadata[section];
|
||||
|
||||
return {
|
||||
section,
|
||||
title: `${meta.label}${getWorkspaceSuffix(workspaceLabel)} | ${copy.shell.title}`,
|
||||
description: workspaceLabel
|
||||
? `${meta.description} ${copy.metadata.workspaceSuffix(workspaceLabel)}`
|
||||
: meta.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertMetaTag(
|
||||
selector: { name: string } | { property: string },
|
||||
content: string,
|
||||
) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const query =
|
||||
"name" in selector
|
||||
? `meta[name="${selector.name}"]`
|
||||
: `meta[property="${selector.property}"]`;
|
||||
|
||||
let element = document.head.querySelector<HTMLMetaElement>(query);
|
||||
if (!element) {
|
||||
element = document.createElement("meta");
|
||||
if ("name" in selector) {
|
||||
element.setAttribute("name", selector.name);
|
||||
} else {
|
||||
element.setAttribute("property", selector.property);
|
||||
}
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
|
||||
element.setAttribute("content", content);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSectionHref,
|
||||
buildWorkflowHref,
|
||||
isWorkspaceScopedSection,
|
||||
} from "./routes";
|
||||
|
||||
describe("dashboard routes", () => {
|
||||
it("keeps global sections out of workspace-scoped urls", () => {
|
||||
expect(isWorkspaceScopedSection("skills")).toBe(false);
|
||||
expect(buildSectionHref("skills", "alpha")).toBe("/skills");
|
||||
});
|
||||
|
||||
it("builds workspace workflow urls only for workspace-scoped sections", () => {
|
||||
expect(isWorkspaceScopedSection("workflow")).toBe(true);
|
||||
expect(buildSectionHref("roles", "alpha")).toBe("/workspaces/alpha/roles");
|
||||
expect(buildWorkflowHref({ workspace: "alpha", topic: "signup-flow" })).toBe(
|
||||
"/workspaces/alpha/workflow/signup-flow",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export type SectionId =
|
||||
| "workflow"
|
||||
| "roles"
|
||||
| "skills"
|
||||
| "executions";
|
||||
|
||||
export const workspaceRoutePrefix = "/workspaces";
|
||||
|
||||
export const dashboardSections = [
|
||||
{ id: "workflow", path: "/workflow", workspaceScoped: true },
|
||||
{ id: "roles", path: "/roles", workspaceScoped: true },
|
||||
{ id: "skills", path: "/skills", workspaceScoped: false },
|
||||
{ id: "executions", path: "/executions", workspaceScoped: true },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: SectionId;
|
||||
path: string;
|
||||
workspaceScoped: boolean;
|
||||
}>;
|
||||
|
||||
const sectionPathMap = Object.fromEntries(
|
||||
dashboardSections.map((section) => [section.id, section.path]),
|
||||
) as Record<SectionId, string>;
|
||||
const sectionConfigMap = Object.fromEntries(
|
||||
dashboardSections.map((section) => [section.id, section]),
|
||||
) as Record<SectionId, (typeof dashboardSections)[number]>;
|
||||
|
||||
const sectionIds = new Set<SectionId>(dashboardSections.map((section) => section.id));
|
||||
|
||||
export function isSectionId(value: string | undefined): value is SectionId {
|
||||
return Boolean(value && sectionIds.has(value as SectionId));
|
||||
}
|
||||
|
||||
export function isWorkspaceScopedSection(section: SectionId): boolean {
|
||||
return sectionConfigMap[section].workspaceScoped;
|
||||
}
|
||||
|
||||
export function getSectionFromPathname(pathname: string): SectionId {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const sectionSegment = segments[0] === "workspaces" ? segments[2] : segments[0];
|
||||
return isSectionId(sectionSegment) ? sectionSegment : "workflow";
|
||||
}
|
||||
|
||||
export function buildSectionHref(section: SectionId, workspace?: string) {
|
||||
const trimmedWorkspace = workspace?.trim();
|
||||
if (!trimmedWorkspace || !isWorkspaceScopedSection(section)) {
|
||||
return sectionPathMap[section];
|
||||
}
|
||||
return `${workspaceRoutePrefix}/${encodeURIComponent(trimmedWorkspace)}${sectionPathMap[section]}`;
|
||||
}
|
||||
|
||||
export function buildWorkflowHref(options?: {
|
||||
workspace?: string;
|
||||
topic?: string;
|
||||
}) {
|
||||
const workspace = options?.workspace?.trim();
|
||||
const topic = options?.topic?.trim();
|
||||
const base = buildSectionHref("workflow", workspace);
|
||||
if (!topic) {
|
||||
return base;
|
||||
}
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
export function getWorkspaceFromPathname(pathname: string) {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments[0] !== "workspaces") {
|
||||
return "";
|
||||
}
|
||||
return decodeURIComponent(segments[1] ?? "");
|
||||
}
|
||||
|
||||
export function getTopicFromPathname(pathname: string) {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments[0] === "workspaces") {
|
||||
if (segments[2] !== "workflow") {
|
||||
return "";
|
||||
}
|
||||
return decodeURIComponent(segments[3] ?? "");
|
||||
}
|
||||
if (segments[0] !== "workflow") {
|
||||
return "";
|
||||
}
|
||||
return decodeURIComponent(segments[1] ?? "");
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
@layer components {
|
||||
.app-shell {
|
||||
background: var(--app-shell-background);
|
||||
color: var(--app-text);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 600ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-panel {
|
||||
background: color-mix(in srgb, var(--app-surface) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--app-border) 92%, white 8%);
|
||||
box-shadow: var(--app-panel-shadow);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 900ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-panel-hero {
|
||||
background: var(--app-panel-hero-background);
|
||||
border: 1px solid var(--app-panel-hero-border);
|
||||
box-shadow: var(--app-panel-hero-shadow);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 900ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-panel-muted {
|
||||
background: var(--app-surface-muted);
|
||||
border: 1px solid color-mix(in srgb, var(--app-border) 88%, white 12%);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 900ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-text-primary {
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.app-text-muted {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.app-text-soft {
|
||||
color: var(--app-text-soft);
|
||||
}
|
||||
|
||||
.app-text-faint {
|
||||
color: var(--app-text-faint);
|
||||
}
|
||||
|
||||
.app-text-attention {
|
||||
color: var(--app-attention-text);
|
||||
}
|
||||
|
||||
.app-text-success {
|
||||
color: var(--app-success-text);
|
||||
}
|
||||
|
||||
.app-text-danger {
|
||||
color: var(--app-danger-text);
|
||||
}
|
||||
|
||||
.app-text-info {
|
||||
color: var(--app-info-text);
|
||||
}
|
||||
|
||||
.app-text-idle {
|
||||
color: var(--app-idle-text);
|
||||
}
|
||||
|
||||
.app-header-shell {
|
||||
border-bottom: 1px solid var(--app-header-border);
|
||||
background: var(--app-header-background);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 900ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-brand-tile {
|
||||
background: var(--app-brand-tile-background);
|
||||
border: 1px solid var(--app-brand-tile-border);
|
||||
box-shadow: var(--app-brand-tile-shadow);
|
||||
}
|
||||
|
||||
.app-overlay-glow {
|
||||
background: var(--app-overlay-highlight);
|
||||
}
|
||||
|
||||
.app-overlay-panel {
|
||||
background: var(--app-surface-overlay);
|
||||
border: 1px solid var(--app-overlay-panel-border);
|
||||
box-shadow: var(--app-overlay-panel-shadow);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 900ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-dialog-backdrop {
|
||||
background: var(--app-overlay-backdrop);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.app-dialog-backdrop-strong {
|
||||
background: var(--app-overlay-backdrop-strong);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.app-floating-shadow {
|
||||
box-shadow: var(--app-floating-shadow);
|
||||
}
|
||||
|
||||
.app-selected-surface {
|
||||
background: var(--app-selection-background);
|
||||
border: 1px solid var(--app-selection-border);
|
||||
box-shadow: var(--app-selection-shadow);
|
||||
transition:
|
||||
background 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 900ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 900ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-accent-rail {
|
||||
background: var(--app-accent-rail-background);
|
||||
}
|
||||
|
||||
.app-divider {
|
||||
background: var(--app-divider);
|
||||
}
|
||||
|
||||
.app-drawer-surface {
|
||||
background: var(--app-surface-drawer);
|
||||
border-top: 1px solid var(--app-divider);
|
||||
box-shadow: var(--app-drawer-shadow);
|
||||
}
|
||||
|
||||
.app-table-head {
|
||||
background: var(--app-table-head-background);
|
||||
}
|
||||
|
||||
.app-chip-subtle {
|
||||
background: var(--app-chip-subtle-background);
|
||||
border: 1px solid var(--app-chip-subtle-border);
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.app-count-pill {
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 9999px;
|
||||
color: currentColor;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-chip-attention {
|
||||
background: var(--app-attention-background);
|
||||
border: 1px solid var(--app-attention-border);
|
||||
color: var(--app-attention-text);
|
||||
}
|
||||
|
||||
.app-chip-success {
|
||||
background: var(--app-success-background);
|
||||
border: 1px solid var(--app-success-border);
|
||||
color: var(--app-success-text);
|
||||
}
|
||||
|
||||
.app-chip-running {
|
||||
background: var(--app-running-background);
|
||||
color: var(--app-running-text);
|
||||
}
|
||||
|
||||
.app-dot-attention {
|
||||
background: var(--app-attention-dot);
|
||||
}
|
||||
|
||||
.app-dot-success {
|
||||
background: var(--app-success-strong);
|
||||
}
|
||||
|
||||
.app-dot-danger {
|
||||
background: var(--app-danger-strong);
|
||||
}
|
||||
|
||||
.app-dot-idle {
|
||||
background: var(--app-idle-dot);
|
||||
}
|
||||
|
||||
.app-code-line-added {
|
||||
background: var(--app-diff-added-background);
|
||||
color: var(--app-diff-added-text);
|
||||
}
|
||||
|
||||
.app-code-line-removed {
|
||||
background: var(--app-diff-removed-background);
|
||||
color: var(--app-diff-removed-text);
|
||||
}
|
||||
|
||||
.app-code-line-meta {
|
||||
background: var(--app-diff-meta-background);
|
||||
color: var(--app-diff-meta-text);
|
||||
}
|
||||
|
||||
.app-input {
|
||||
@apply min-h-11 rounded-lg border px-3 py-2 text-sm outline-none transition-colors touch-manipulation md:min-h-10;
|
||||
background: color-mix(in srgb, var(--app-surface-elevated) 92%, black);
|
||||
border-color: color-mix(in srgb, var(--app-border) 90%, white 10%);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.app-input::placeholder {
|
||||
color: var(--app-text-faint);
|
||||
}
|
||||
|
||||
.app-input:focus {
|
||||
border-color: color-mix(in srgb, var(--app-accent) 50%, white);
|
||||
}
|
||||
|
||||
.app-input:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--app-accent) 82%, white);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--app-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.app-inline-input {
|
||||
@apply rounded-md bg-transparent px-1.5 py-1 outline-none transition-[background-color,box-shadow];
|
||||
}
|
||||
|
||||
.app-inline-input:focus-visible {
|
||||
background: color-mix(in srgb, var(--app-surface-muted) 88%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-accent) 78%, white);
|
||||
}
|
||||
|
||||
.app-overline {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-caption {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-caption-medium {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-caption-mono {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-tab-active {
|
||||
background: var(--app-tab-active-background);
|
||||
border: 1px solid var(--app-tab-active-border);
|
||||
box-shadow: var(--app-tab-active-shadow);
|
||||
}
|
||||
|
||||
.app-kicker {
|
||||
color: color-mix(in srgb, var(--app-accent-warm) 72%, white);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-display {
|
||||
font-family: var(--app-font-display);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.app-scrollbar-hidden {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.app-scrollbar-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-reading {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "./app-shell-base.css";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user