chore(repo): reinitialize repository

This commit is contained in:
2026-03-18 11:29:54 +08:00
commit 24871e213a
288 changed files with 44369 additions and 0 deletions
+21
View File
@@ -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*
+135
View File
@@ -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"}'
```
### DashboardReact UI
```bash
cd dashboard && npm install && npm run dev # Vite 开发服务器
cd dashboard && npm run build # 生产构建 → dist/
```
开发模式下,Dashboard 会将 `/api` 代理到 `http://localhost:3000`
### Apps
```bash
# phonesiteNext.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 CLIGo`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 行为。
- 新模块就位后,优先删除废弃代码。
+94
View File
@@ -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/`.
+80
View File
@@ -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.
+80
View File
@@ -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`
## 当前目标
让每个模块边界清晰、职责单一,并且默认就是“方便使用、方便测试”的状态。
+34
View File
@@ -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
```
+38
View File
@@ -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",
},
},
);
+25
View File
@@ -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>
+6365
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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"
}
}
+23
View File
@@ -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"] },
},
],
});
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+86
View File
@@ -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>
);
}
+563
View File
@@ -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",
}),
);
});
});
+222
View File
@@ -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",
);
}
+97
View File
@@ -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));
}
}
+320
View File
@@ -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 ?? [];
}
+249
View File
@@ -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);
});
});
+481
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+24
View File
@@ -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);
});
});
+412
View File
@@ -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();
});
});
+228
View File
@@ -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}</>;
}
+79
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+50
View File
@@ -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}
/>
);
}
+89
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+31
View File
@@ -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();
});
});
+115
View File
@@ -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
/>
);
}
+3
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export * from "./copy/data";
+47
View File
@@ -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);
}
+701
View File
@@ -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>;
+67
View File
@@ -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);
}
+3
View File
@@ -0,0 +1,3 @@
export type Locale = "zh-CN" | "en";
export const defaultLocale: Locale = "zh-CN";
+536
View File
@@ -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]);
}
+178
View File
@@ -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);
});
});
+155
View File
@@ -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 };
}
+133
View File
@@ -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.",
);
});
});
+143
View File
@@ -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);
}
+44
View File
@@ -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+");
});
});
+382
View File
@@ -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>
);
}
+19
View File
@@ -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>,
);
+51
View File
@@ -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);
}
+21
View File
@@ -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",
);
});
});
+84
View File
@@ -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] ?? "");
}
+295
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
@import "./app-shell-base.css";

Some files were not shown because too many files have changed in this diff Show More