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