mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
Add benchmark results for fast3 and fast4, implement KeepAliveLspBridge, and add tests for staged strategies
- Added new benchmark result files: compare_2026-02-09_score_fast3.json and compare_2026-02-09_score_fast4.json. - Implemented KeepAliveLspBridge to maintain a persistent LSP connection across multiple queries, improving performance. - Created unit tests for staged clustering strategies in test_staged_stage3_fast_strategies.py, ensuring correct behavior of score and dir_rr strategies.
This commit is contained in:
313
ccw/frontend/ENSOAI_INTEGRATION_PLAN.md
Normal file
313
ccw/frontend/ENSOAI_INTEGRATION_PLAN.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# EnsoAI → CCW Frontend 集成计划(架构识别 / 多 CLI / 远程连接 / 功能盘点)
|
||||
|
||||
日期:2026-02-09
|
||||
目标:把 `G:\github_lib\EnsoAI` 的“多路智能并行工作流”核心体验,按 CCW 现有能力边界,规划性集成到 `D:\Claude_dms3\ccw\frontend`(Web Dashboard)。
|
||||
|
||||
> 注:由于本环境 `read_file` 工具仅允许读取 `D:\Claude_dms3`,EnsoAI 代码引用路径来自本地检索结果与 PowerShell 阅读,不影响结论准确性。
|
||||
|
||||
---
|
||||
|
||||
## 1) EnsoAI 架构速览(你要找的“架构骨架”)
|
||||
|
||||
EnsoAI 是一个 Electron 桌面应用,典型三段式:
|
||||
|
||||
- **Main Process(主进程)**:`G:\github_lib\EnsoAI\src\main`
|
||||
- IPC handlers、系统服务集成、Git/PTY/远程共享/代理、Claude 生态集成(Provider、MCP、IDE Bridge)
|
||||
- **Preload(桥接层)**:`G:\github_lib\EnsoAI\src\preload`
|
||||
- `window.electronAPI.*` 暴露给渲染进程(settings、hapi、cloudflared、mcp、git、terminal 等)
|
||||
- **Renderer(UI)**:`G:\github_lib\EnsoAI\src\renderer`
|
||||
- React UI:Worktree/Git、Monaco 编辑器、xterm 终端、多 Agent Chat、设置面板等
|
||||
- **Shared(共享)**:`G:\github_lib\EnsoAI\src\shared`
|
||||
- types/i18n/ipc channel 等
|
||||
|
||||
核心思路:**把“多 Agent 并行”落到“多 worktree + 多终端会话 + 可恢复的 AI session”上**,并提供一揽子系统集成(Claude IDE Bridge / MCP / 远程共享 / proxy)。
|
||||
|
||||
---
|
||||
|
||||
## 2) EnsoAI 如何调用多 CLI(重点:两条路径)
|
||||
|
||||
### 2.1 非交互任务调用(更像“工具/能力调用”)
|
||||
|
||||
入口:`G:\github_lib\EnsoAI\src\main\services\ai\providers.ts`
|
||||
|
||||
- 按 provider 选择 CLI:
|
||||
- Claude:`claude -p --output-format ... --model ... [--session-id] [--no-session-persistence]`
|
||||
- Codex:`codex exec -m <model> ...`(prompt 走 stdin)
|
||||
- Gemini:`gemini -o ... -m ... --yolo`(prompt 走 stdin)
|
||||
- Cursor:`agent -p --output-format ... --model ...`(不支持部分 claude flags)
|
||||
- 统一策略:**通过用户 shell 包装 spawn**,prompt 统一写 stdin;实现了 kill 逻辑与输出解析(尤其 Claude JSON 输出的版本兼容)。
|
||||
|
||||
适用场景:commit message 生成、code review、分支命名等“非交互式一次性任务”。
|
||||
|
||||
### 2.2 交互终端调用(更像“人机对话/操作台”)
|
||||
|
||||
入口:`G:\github_lib\EnsoAI\src\renderer\components\chat\AgentTerminal.tsx`
|
||||
|
||||
关键点:
|
||||
|
||||
- **会话恢复**:`--resume <id>` / `--session-id <id>`(并对 Cursor/Claude initialized 的差异做了兼容)
|
||||
- **IDE 集成**:Claude 追加 `--ide`
|
||||
- **环境包装(environment wrapper)**:
|
||||
- `native`:直接执行 `<agentCommand> ...`
|
||||
- `hapi`:`hapi <agent> ...` 或 `npx -y @twsxtd/hapi <agent> ...`,并注入 `CLI_API_TOKEN`
|
||||
- `happy`:`happy <agent> ...`(同理)
|
||||
- **跨平台兼容**:
|
||||
- WSL:用 `wsl.exe -e sh -lc ...` 进入登录 shell
|
||||
- PowerShell:用 `& { <command> }` 避免 `--session-id` 被 PowerShell 当成自身参数
|
||||
- **tmux 支持(非 Windows + Claude)**:把会话包进 tmux,保证“断开 UI 也能续接”一类体验
|
||||
|
||||
适用场景:多 Agent 终端式并行对话、跨会话恢复、长任务驻留。
|
||||
|
||||
### 2.3 CLI 探测/安装(多 CLI 的“可用性治理”)
|
||||
|
||||
- 探测:`G:\github_lib\EnsoAI\src\main\services\cli\CliDetector.ts`
|
||||
- 内置:`claude/codex/droid/gemini/auggie/cursor-agent/opencode`
|
||||
- 通过 PTY 执行 `<cmd> --version`,Windows 放宽超时
|
||||
- 安装(EnsoAI 自身 CLI,用于打开目录等):`G:\github_lib\EnsoAI\src\main\services\cli\CliInstaller.ts`
|
||||
- 跨平台脚本生成(macOS/Windows/Linux),以及可能的管理员权限处理
|
||||
|
||||
---
|
||||
|
||||
## 3) EnsoAI 的“远程连接 / 系统集成”到底做了什么
|
||||
|
||||
### 3.1 Claude IDE Bridge(Claude Code CLI 的 IDE 集成)
|
||||
|
||||
入口:`G:\github_lib\EnsoAI\src\main\services\claude\ClaudeIdeBridge.ts`
|
||||
|
||||
机制要点:
|
||||
|
||||
- 本机启动 WebSocket server(绑定 127.0.0.1,随机端口)
|
||||
- 写 lock file 到:
|
||||
- `~/.claude/ide/<port>.lock`(或 `CLAUDE_CONFIG_DIR/ide/<port>.lock`)
|
||||
- payload 含:`pid/workspaceFolders/ideName/transport/ws/runningInWindows/authToken`
|
||||
- Claude Code 客户端连接时需 header:
|
||||
- `x-claude-code-ide-authorization: <authToken>`
|
||||
- 可选 `x-claude-code-workspace` 辅助路由
|
||||
- 可向 Claude Code 广播通知:
|
||||
- `selection_changed`
|
||||
- `at_mentioned`
|
||||
- 为“运行 Claude CLI 子进程”注入 env:
|
||||
- `ENABLE_IDE_INTEGRATION=true`
|
||||
- `CLAUDE_CODE_SSE_PORT=<port>`(配合 `claude --ide`)
|
||||
|
||||
这属于“系统集成层”,不是普通 API 配置;它解决的是:**Claude Code CLI 与 IDE/宿主之间的状态互通**。
|
||||
|
||||
### 3.2 MCP Servers 管理(与 Claude 生态配置打通)
|
||||
|
||||
入口:`G:\github_lib\EnsoAI\src\main\services\claude\McpManager.ts`
|
||||
|
||||
- 读写 `~/.claude.json` 的 `mcpServers`
|
||||
- 支持:
|
||||
- stdio MCP:`command/args/env`(UI 可编辑)
|
||||
- HTTP/SSE MCP:`type/url/headers`(一般保留原配置,避免破坏)
|
||||
|
||||
### 3.3 远程共享(Hapi)与公网暴露(Cloudflared)
|
||||
|
||||
- Hapi:`G:\github_lib\EnsoAI\src\main\services\hapi\HapiServerManager.ts` + `src/main/ipc/hapi.ts`
|
||||
- `hapi server` 或 fallback `npx -y @twsxtd/hapi server`
|
||||
- env:`WEBAPP_PORT/CLI_API_TOKEN/TELEGRAM_BOT_TOKEN/WEBAPP_URL/ALLOWED_CHAT_IDS`
|
||||
- UI 文案明确:**Web + Telegram 远程共享 agent sessions**
|
||||
- Cloudflared:`G:\github_lib\EnsoAI\src\main\services\hapi\CloudflaredManager.ts`
|
||||
- 安装到 userData/bin(避开 asar 只读)
|
||||
- 支持 quick tunnel / token auth tunnel,可选 `--protocol http2`
|
||||
|
||||
### 3.4 Proxy(代理)
|
||||
|
||||
入口:`G:\github_lib\EnsoAI\src\main\services\proxy\ProxyConfig.ts`
|
||||
|
||||
- Electron session 级代理 + 子进程环境变量注入(HTTP(S)_PROXY / NO_PROXY)
|
||||
- 内置代理联通性测试(HEAD 请求多个探测 URL)
|
||||
|
||||
---
|
||||
|
||||
## 4) EnsoAI 功能点全量清单(按模块)
|
||||
|
||||
> 这里是“罗列所有功能点”的汇总视图;后续集成计划会用它做映射。
|
||||
|
||||
### 4.1 多 Agent / 多 CLI(核心卖点)
|
||||
|
||||
- 多 Agent 终端会话并行(Claude/Codex/Gemini + 额外探测 Droid/Auggie/Cursor/OpenCode)
|
||||
- 会话持久化与恢复(session-id / resume)
|
||||
- CLI 可用性探测(installed/version/timeout)
|
||||
- 环境包装:native +(hapi/happy)+ tmux + WSL/PowerShell 兼容
|
||||
|
||||
### 4.2 Git / Worktree 工作流(核心载体)
|
||||
|
||||
- Worktree 创建/切换/删除;分支为“一等公民”
|
||||
- Source Control 面板:
|
||||
- 文件变更列表、stage/unstage、commit、history、branch 切换
|
||||
- diff viewer / diff review modal
|
||||
- 三栏 Merge Editor(冲突解决)
|
||||
- 远端同步:fetch/pull/push 等
|
||||
|
||||
### 4.3 编辑器与文件系统
|
||||
|
||||
- Monaco 编辑器、多标签、拖拽排序
|
||||
- 文件树 CRUD(新建/重命名/删除)
|
||||
- 预览:Markdown / PDF / Image
|
||||
- 未保存变更保护、冲突提示
|
||||
|
||||
### 4.4 AI 辅助开发
|
||||
|
||||
- AI commit message 生成
|
||||
- AI code review(围绕 diff 的审查/优化)
|
||||
- 分支命名辅助(推测)
|
||||
|
||||
### 4.5 Claude 生态集成
|
||||
|
||||
- Claude Provider 管理(监听/应用 `~/.claude/settings.json`)
|
||||
- MCP servers 管理(`~/.claude.json`,stdio + HTTP/SSE)
|
||||
- Prompts / Plugins 管理
|
||||
- Claude IDE Bridge(WS + lock file + selection/@ 通知)
|
||||
|
||||
### 4.6 远程与网络
|
||||
|
||||
- Hapi remote sharing(Web + Telegram)
|
||||
- Cloudflared tunnel 暴露本地服务
|
||||
- 代理设置与测试
|
||||
|
||||
### 4.7 通用体验
|
||||
|
||||
- 多窗口、多工作空间
|
||||
- 主题同步(终端主题)
|
||||
- 快捷键/命令面板
|
||||
- 设置 JSON 持久化
|
||||
- Web Inspector、更新通知
|
||||
|
||||
---
|
||||
|
||||
## 5) CCW Frontend 基线(与 EnsoAI 的可复用对齐点)
|
||||
|
||||
CCW 前端已具备的关键“载体/底座”:
|
||||
|
||||
- **CLI Viewer(多 pane)**:`ccw/frontend/src/pages/CliViewerPage.tsx`
|
||||
- 已支持 split/grid 布局、聚焦 pane、执行恢复与 WebSocket 输出接入
|
||||
- **CLI Endpoints / Installations**:
|
||||
- `ccw/frontend/src/pages/EndpointsPage.tsx`(litellm/custom/wrapper)
|
||||
- `ccw/frontend/src/pages/InstallationsPage.tsx`(install/upgrade/uninstall)
|
||||
- **MCP Manager(含 Cross-CLI)**:`ccw/frontend/src/pages/McpManagerPage.tsx`
|
||||
- **后端 CLI executor 体系**:`ccw/src/tools/cli-executor-core.ts` + `ccw/src/core/routes/cli-routes.ts`
|
||||
- 已有 active executions 恢复、history/sqlite、tool availability、wrapper/tools 路由等能力
|
||||
|
||||
CCW 与 EnsoAI 的主要差异(决定“集成方式”):
|
||||
|
||||
- EnsoAI 是“桌面 IDE/工作台”,CCW 是“Web Dashboard + Orchestrator”
|
||||
- EnsoAI 的 Worktree/Git/Editor/PTY 属于重资产 IDE 能力;CCW 目前不承担这些(除非明确要扩域)
|
||||
- EnsoAI 的 IDE Bridge / Hapi/Cloudflared 属于系统集成层;CCW 当前需要新增后端能力才能对齐
|
||||
|
||||
---
|
||||
|
||||
## 6) 集成策略(把 EnsoAI 优势带到 CCW,但不把 CCW 变成 IDE)
|
||||
|
||||
优先级建议:
|
||||
|
||||
1) **先移植“多 Agent 并行调度 + 可恢复输出体验”**(与 CCW 现有 CLI viewer/cli executor 高度匹配)
|
||||
2) 再做 **“多 CLI 治理与配置同步”体验收敛**(Installations/Endpoints/MCP 的信息架构对齐)
|
||||
3) 最后才考虑 **系统集成层**(Claude IDE Bridge、远程共享、tunnel/proxy 等)
|
||||
|
||||
---
|
||||
|
||||
## 7) Roadmap(集成到 `ccw/frontend` 的分期落地)
|
||||
|
||||
### Phase 0 — 对齐与设计(1~2 天)
|
||||
|
||||
- 产出:统一术语与信息架构
|
||||
- Agent / Tool / Endpoint / Wrapper 的定义
|
||||
- “并行会话”= 多 execution 并发 + CLI viewer 多 pane 展示
|
||||
- 产出:UI 草图(Agent Matrix + 单 Agent Details + 快捷动作)
|
||||
|
||||
### Phase 1(MVP)— Agent Matrix(最接近 EnsoAI 卖点,且最可落地)
|
||||
|
||||
目标:在 CCW 中“一屏管理多 Agent 并行执行”,并可一键把多个 execution 打开到 CLI Viewer 的多个 pane。
|
||||
|
||||
前端交付(建议):
|
||||
|
||||
- 新页面:`/agents`(或并入 `/cli-viewer` 的“Matrix 模式”)
|
||||
- 卡片:每个 Agent(claude/codex/gemini/...)展示 installed/version/status
|
||||
- 快捷动作:
|
||||
- Run prompt(选择 mode:analysis/plan/review…)
|
||||
- Open in CLI Viewer(自动分配 pane)
|
||||
- Resume / View history(打开对应 conversation/execution)
|
||||
- 并行启动:一次选多个 Agent → 并发触发多次执行 → 同步创建 viewer tabs
|
||||
- 复用:`cliStreamStore` + `viewerStore`
|
||||
|
||||
后端依赖(最小化):
|
||||
|
||||
- 若现有 `/api/cli/*` 已满足执行与 active 恢复:仅需补一个 “agents list/status” 聚合接口(可由现有 installations/status 拼装)
|
||||
|
||||
验收标准:
|
||||
|
||||
- 同一 prompt 可对多个 agent 并行执行,输出稳定落到 CLI Viewer 多 pane
|
||||
- 刷新页面后仍可恢复 running executions(复用 `/api/cli/active`)
|
||||
- 每个 execution 可追溯到 history(已有 sqlite/history 体系)
|
||||
|
||||
### Phase 2 — Wrapper/Endpoint 体验收敛(对齐 EnsoAI 的 environment wrapper 思路)
|
||||
|
||||
目标:把 EnsoAI 的 `native/hapi/happy/tmux/wsl` 思路映射成 CCW 的 endpoint/wrapper 配置,形成一致的“运行环境选择”体验。
|
||||
|
||||
前端交付(建议):
|
||||
|
||||
- 在 Agent Matrix 卡片中加入:
|
||||
- “运行环境”下拉(native / wrapper endpoints)
|
||||
- “会话策略”选项(new / resume / no-persistence 等,取决于后端 executor 支持)
|
||||
- 在 EndpointsPage 中标注 “可用于哪些 agent/tool” 的适配关系(避免用户创建了 wrapper 但不知道生效范围)
|
||||
|
||||
后端依赖:
|
||||
|
||||
- wrapper endpoints 的能力需要明确:是否等价于 EnsoAI 的 `hapi/happy` 前缀包装?(如果是,需在 cli executor 的 command builder 支持这一类 wrapper)
|
||||
|
||||
### Phase 3 — Claude IDE Bridge(可选,大改动,系统集成层)
|
||||
|
||||
目标:在 CCW(Node 服务)侧实现类似 EnsoAI 的 IDE Bridge,并在前端提供状态面板。
|
||||
|
||||
前端交付(建议):
|
||||
|
||||
- Settings 新增 “IDE Integration(Claude Code)”:
|
||||
- Bridge enable/disable
|
||||
- 当前端口、lock file 路径、连接状态、最近一次 selection/@ 通知
|
||||
- “复制 token / 复制诊断信息”按钮
|
||||
|
||||
后端依赖:
|
||||
|
||||
- 需要在 CCW server 里增加 WS server + lock file 写入逻辑(参考 EnsoAI 的 `ClaudeIdeBridge.ts`)
|
||||
- 需要明确 Claude Code CLI 的兼容要求(协议版本、headers、env key)
|
||||
|
||||
### Phase 4 — 远程共享与公网暴露(可选,偏产品功能)
|
||||
|
||||
目标:把 EnsoAI 的 Hapi + Cloudflared 能力变成 CCW 的“远程访问/协作”能力。
|
||||
|
||||
前端交付(建议):
|
||||
|
||||
- Settings 新增 “Remote Sharing”:
|
||||
- 启停服务、端口、token、允许列表
|
||||
- Cloudflared 安装/启停、URL 展示、一键复制
|
||||
|
||||
后端依赖:
|
||||
|
||||
- 需要引入 hapi/cloudflared 管理模块(或选择更贴近 CCW 的方案,例如仅 cloudflared 暴露 CCW server)
|
||||
- 需要明确安全边界(认证、token 轮换、日志脱敏、默认不开放)
|
||||
|
||||
---
|
||||
|
||||
## 8) EnsoAI 功能 → CCW 现状 → 集成建议(映射表)
|
||||
|
||||
| EnsoAI 功能 | CCW 现状 | 建议集成方式(优先级) |
|
||||
|---|---|---|
|
||||
| 多 Agent 并行会话 | 有 CLI viewer + executor,但缺“一屏矩阵” | Phase 1:新增 Agent Matrix + 自动 pane 分配 |
|
||||
| CLI 探测/安装 | 有 InstallationsPage | Phase 1:把 Installations 信息在 Matrix 中复用展示 |
|
||||
| environment wrapper(hapi/happy/tmux/wsl) | 有 wrapper endpoints 概念 | Phase 2:把 wrapper endpoints 显式接到 Agent 执行选择 |
|
||||
| Claude MCP servers 管理 | 有 MCP Manager(含 Cross-CLI) | 现有功能可继续增强(模板/同步策略) |
|
||||
| Claude IDE Bridge | 暂无 | Phase 3(可选)系统集成层模块 + Settings 面板 |
|
||||
| Hapi remote sharing + Cloudflared | 暂无 | Phase 4(可选) |
|
||||
| Worktree/Git/Editor/Merge | 暂无(且是 IDE 域) | 默认不集成;若要做需单独立项 |
|
||||
|
||||
---
|
||||
|
||||
## 9) 下一步(如果你要我继续落地)
|
||||
|
||||
你可以选择一个切入点,我可以直接在 `ccw/frontend` 开工实现:
|
||||
|
||||
1) **Phase 1(推荐)**:新增 `/agents` 页面(Agent Matrix),复用现有 `/api/cli/*` 与 CLI viewer
|
||||
2) **Phase 2**:把 wrapper endpoints 打通到“Agent 运行环境选择”
|
||||
3) **Phase 3/4**:需要先确认产品边界与安全策略,再做后端设计
|
||||
|
||||
232
ccw/frontend/ISSUE_BOARD_NATIVE_CLI_ORCHESTRATOR_PLAN.md
Normal file
232
ccw/frontend/ISSUE_BOARD_NATIVE_CLI_ORCHESTRATOR_PLAN.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# CCW Issue 看板 × 原生 CLI 窗口 × 队列管理 × 编排器(tmux-like)集成规划
|
||||
|
||||
日期:2026-02-09
|
||||
输入参考:`.workflow/.analysis/ANL-codemoss-issue-panel-2026-02-09/report.md`(CodeMoss 看板/会话机制)
|
||||
|
||||
## 0) 已确认的关键决策(来自本次讨论)
|
||||
|
||||
- **终端形态**:Web 内嵌终端(`xterm.js`)+ 后端 **PTY**(需要 TTY 行为)
|
||||
- **默认会话壳**:先启动 `bash`(在 Windows 下优先走 WSL bash,其次 Git-Bash;都不可用时再降级)
|
||||
- **连续性**:用 `resumeKey` 作为“逻辑会话键”(可映射到 CLI 的 `--resume/--continue/--session-id` 等)
|
||||
- **Queue 增强优先级**:先做 **执行面**(Queue item → 投递到某个 CLI 会话并执行),管理面后置
|
||||
- **resumeKey 映射**:两种策略都要支持(`nativeResume` 与 `promptConcat`,见 4.6)
|
||||
|
||||
## 1) 要做的“产品形态”(一句话)
|
||||
|
||||
把 CCW 的 `Issues + Queue + Orchestrator + CLI Viewer` 变成一个统一的 **Issue Workbench**:
|
||||
|
||||
- 中间:Issue 看板(Kanban)+ Queue 管理(可视化/可执行/可重排)
|
||||
- 右侧:可切换的 **CLI 会话窗口**(Claude/Codex/Gemini/…),Web 内嵌终端承载,能向不同会话发送消息/命令
|
||||
- 编排器:能把节点输出/指令 **路由** 到某个 CLI 会话(类似 tmux 的 send-keys 到某个 pane / session)
|
||||
|
||||
---
|
||||
|
||||
## 2) 当前 CCW 已具备的关键底座(可直接复用)
|
||||
|
||||
### 2.1 IssueHub 的“右侧抽屉”模式
|
||||
|
||||
- IssueHub tabs:`ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx`(目前:issues/queue/discovery)
|
||||
- IssueDrawer(右侧抽屉 + tabs):`ccw/frontend/src/components/issue/hub/IssueDrawer.tsx`
|
||||
- SolutionDrawer(右侧抽屉):`ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx`
|
||||
|
||||
这让“右侧弹窗/抽屉内嵌 CLI 窗口”的 UI 成本很低:新增一个 tab 或替换为更通用的 drawer 即可。
|
||||
|
||||
### 2.2 Kanban DnD 组件
|
||||
|
||||
- `ccw/frontend/src/components/shared/KanbanBoard.tsx`(@hello-pangea/dnd)
|
||||
|
||||
可以直接用于 Issue Board(以及 Queue Board)。
|
||||
|
||||
### 2.3 “原生 CLI 输出”的 L0 形态已经存在(流式 + WS)
|
||||
|
||||
- 后端:`ccw/src/core/routes/cli-routes.ts`
|
||||
- `/api/cli/execute` 支持 streaming,并 WS 广播 `CLI_EXECUTION_STARTED` + `CLI_OUTPUT`(以及 completed/error)
|
||||
- 前端:`ccw/frontend/src/pages/CliViewerPage.tsx` + `ccw/frontend/src/stores/cliStreamStore.ts`
|
||||
- 多 pane 输出、执行恢复(`/api/cli/active`)
|
||||
|
||||
这套能力仍然有价值(例如:一次性非 TTY 执行、回放、聚合输出)。但本次已确认 MVP 需要 **PTY + xterm** 的真终端体验,因此会新增一套 “CLI Session(PTY)” 的生命周期与 WS 双向通道。
|
||||
|
||||
### 2.4 编排器右侧滑出 ExecutionMonitor 可复用交互
|
||||
|
||||
- `ccw/frontend/src/pages/orchestrator/ExecutionMonitor.tsx`
|
||||
|
||||
可作为 Issue 看板右侧“CLI/执行监控”面板的交互模板(布局、滚动、自动跟随、固定宽度/可变宽度)。
|
||||
|
||||
---
|
||||
|
||||
## 3) 参考:CodeMoss 的关键设计点(值得吸收)
|
||||
|
||||
来自 `.workflow/.analysis/ANL-codemoss-issue-panel-2026-02-09/report.md`:
|
||||
|
||||
1) **Kanban task 数据里直接挂执行关联字段**(threadId/engineType/modelId/autoStart/branchName 等)
|
||||
2) **拖到 inprogress 可触发自动动作**(启动会话/执行)
|
||||
3) “原生 CLI”更像 **长驻进程/协议**(codex app-server JSON-lines),UI 只是事件的消费者
|
||||
|
||||
CCW 的“差异现实”:
|
||||
|
||||
- CCW 是 Web Dashboard,不是 Tauri/Electron;但我们仍然选择 **node-pty + xterm.js**(后端持有 PTY 会话,前端 attach 显示),以满足“原生 CLI 窗口 / tmux-like 路由”的需求。
|
||||
|
||||
---
|
||||
|
||||
## 4) 目标能力拆解(功能点清单)
|
||||
|
||||
### 4.1 Issue Board(Kanban)
|
||||
|
||||
- 新增 tab:`board`(`/issues?tab=board`)
|
||||
- 列:`open` / `in_progress` / `resolved` / `completed`(`closed` 可独立列或筛选隐藏)
|
||||
- 支持:
|
||||
- 列内排序(sortOrder)
|
||||
- 跨列拖拽(更新 issue.status)
|
||||
- 选中卡片 → 右侧 Issue Workbench Drawer
|
||||
- 自动动作(对齐 CodeMoss):
|
||||
- 从非 `in_progress` 拖到 `in_progress`:触发 `onDragToInProgress(issue)`(可配置)
|
||||
|
||||
### 4.2 右侧 “原生 CLI 窗口”(PTY + xterm.js)
|
||||
|
||||
右侧 drawer 增加 `Terminal`/`CLI` tab(或将 IssueDrawer 升级为 “IssueWorkbenchDrawer”):
|
||||
|
||||
- 会话列表(per issue / per queue / global)
|
||||
- 多 CLI:Claude/Codex/Gemini/…
|
||||
- 每个会话绑定:
|
||||
- `sessionKey`:后端 PTY 会话 ID(用于 attach/close/send/resize)
|
||||
- `resumeKey`:逻辑连续性(用于生成 CLI 的 resume/continue 参数,或用于生成“同一会话下的命令模板”)
|
||||
- `tool/model/workingDir/envWrapper`(与 `ENSOAI_INTEGRATION_PLAN.md` 的能力对齐)
|
||||
- 终端能力(MVP)
|
||||
- attach:打开/切换某个会话的 xterm 视图
|
||||
- send:向会话发送文本(send-keys / paste)并可选择自动回车
|
||||
- resize:前端尺寸变化时同步 PTY size
|
||||
- close:关闭会话(释放进程与资源)
|
||||
- 操作
|
||||
- `Send`:把“指令/消息”直接投递到当前 PTY 会话(tmux send-keys 语义)
|
||||
- `Open in CLI Viewer`:可选(如果保留 `/api/cli/execute` 回放/聚合,则用于打开对应 execution;否则用于打开独立 CLI Viewer 的“会话视图”)
|
||||
|
||||
### 4.3 队列管理增强(Queue Workbench)
|
||||
|
||||
队列本身已有 API 结构(多队列 index + active_queue_ids、merge/split/activate 等,见 `ccw/src/core/routes/issue-routes.ts`),但前端仍偏“展示 + 少量操作”。
|
||||
|
||||
建议增强项:
|
||||
|
||||
- 多队列:列表/切换 active queues(UI 对接 `/api/queue/history` + `/api/queue/activate`)
|
||||
- 可视化重排:
|
||||
- 列表重排(按 execution_group 分组,组内 drag reorder)
|
||||
- 将 item 拖到不同 execution_group(更新 execution_group + execution_order)
|
||||
- 依赖图/阻塞原因(depends_on / blocked)
|
||||
- 与执行联动:
|
||||
- item `Execute in Session`(把 queue item 直接投递到指定 CLI 会话并执行;本次确认优先做这个)
|
||||
- item `Send to Orchestrator`(创建/触发一个 flow 执行)
|
||||
|
||||
### 4.4 编排器(Orchestrator)与 CLI 会话的“tmux-like 路由”
|
||||
|
||||
目标:编排器不只“跑一次性 CLI”,还能“向某个会话发送消息/指令”。
|
||||
|
||||
建议抽象:`CliMux`(CLI Multiplexer)
|
||||
|
||||
- **MVP(本次确认)CliMuxPersistent**:以 PTY 会话为核心,提供 `send(sessionKey, text)` / `resize` / `close`
|
||||
- **补充(可选)CliMuxStateless**:保留 `/api/cli/execute` 作为非 TTY 执行与审计回放通道
|
||||
|
||||
编排器侧最小改动思路:
|
||||
|
||||
- 继续保持 node 数据模型统一(prompt-template),但新增可选字段:
|
||||
- `targetSessionKey?: string`(或 `issueId` + `sessionName` 组合)
|
||||
- `delivery?: 'newExecution' | 'sendToSession'`(默认 newExecution)
|
||||
- FlowExecutor 在执行节点时:
|
||||
- `delivery=newExecution`:沿用现有 executeCliTool
|
||||
- `delivery=sendToSession`:调用 CliMux.send(targetSessionKey, instruction)
|
||||
|
||||
这样就形成“像 tmux 一样把消息打到指定 pane/session”的能力。
|
||||
|
||||
### 4.5 后端 CLI Session(PTY)协议草案(用于前后端对齐)
|
||||
|
||||
目标:让前端的 xterm 以 “attach 到 sessionKey” 的方式工作;Queue/Orchestrator 以 “send 到 sessionKey” 的方式工作。
|
||||
|
||||
- REST(建议)
|
||||
- `POST /api/cli-sessions`:创建会话(入参:`tool/model/workingDir/envWrapper/resumeKey/resumeStrategy/shell=bash`;出参:`sessionKey`)
|
||||
- `GET /api/cli-sessions`:列出会话(用于切换/恢复)
|
||||
- `POST /api/cli-sessions/:sessionKey/send`:发送文本(支持 `appendNewline`)
|
||||
- `POST /api/cli-sessions/:sessionKey/execute`:按 tool + resumeStrategy 生成并发送“可执行命令”(Queue/Orchestrator 走这个更稳)
|
||||
- `POST /api/cli-sessions/:sessionKey/resize`:同步 rows/cols
|
||||
- `POST /api/cli-sessions/:sessionKey/close`:关闭并回收
|
||||
- WS 事件(建议)
|
||||
- `CLI_SESSION_CREATED` / `CLI_SESSION_CLOSED`
|
||||
- `CLI_SESSION_OUTPUT`(`sessionKey`, `data`, `stream=stdout|stderr`)
|
||||
- `CLI_SESSION_ERROR`(`sessionKey`, `message`)
|
||||
- 兼容策略
|
||||
- 现有 `/api/cli/execute` + `CLI_OUTPUT` 保留:用于非 TTY 一次性执行、可审计回放、与现有 `CliViewerPage` 兼容
|
||||
- 新增“会话视图”可先复用 `cliStreamStore` 的 pane 结构(key 从 `transactionId` 扩展到 `sessionKey`)
|
||||
|
||||
### 4.6 `resumeKey` 映射策略(两种都实现)
|
||||
|
||||
`resumeKey` 的定位:一个跨 UI/Queue/Orchestrator 的“逻辑会话键”。同一个 `resumeKey` 下可能会对应:
|
||||
|
||||
- **策略 A:`nativeResume`(优先)**
|
||||
- 由 CLI 自己保存与恢复上下文(例如:`--resume/--continue/--session-id`)
|
||||
- 优点:上下文更可靠,输出格式更稳定;适合 Claude/Codex 这类有明确 session 概念的 CLI
|
||||
- 风险:不同 CLI 旗标/行为差异较大,需要 per-tool adapter(并要跟版本兼容)
|
||||
|
||||
- **策略 B:`promptConcat`(必须支持)**
|
||||
- CCW 自己维护 `resumeKey -> transcript/context`,每次执行把“历史 + 本次指令”拼成 prompt
|
||||
- 优点:对 CLI 依赖更少;即使 CLI 不支持 resume 也能连续;适合 Gemini/其它工具
|
||||
- 风险:token 成本、上下文截断策略、以及敏感信息/审计(需要明确 retention)
|
||||
|
||||
落地建议:后端新增 `ToolAdapter`(按 `tool` 输出“命令模板 + resume 参数 + prompt 注入方式”),`/api/cli-sessions/:sessionKey/execute` 统一走 adapter,Queue/Orchestrator 不直接拼 shell 命令。
|
||||
|
||||
---
|
||||
|
||||
## 5) 数据模型建议(对齐 CodeMoss,但保持 CCW 简洁)
|
||||
|
||||
### 5.1 Issue 扩展字段(建议在后端 issue schema 中落盘)
|
||||
|
||||
在 `Issue` 增加:
|
||||
|
||||
- `sortOrder?: number`(用于 board 列内排序)
|
||||
- `sessions?: Array<{ sessionKey: string; resumeKey: string; tool: string; model?: string; workingDir?: string; envWrapper?: string; createdAt: string }>`
|
||||
|
||||
注意:
|
||||
|
||||
- 后端 `ccw/src/commands/issue.ts` 中已经出现 `status: 'queued'` 等状态迹象;前端 `Issue['status']` 需要对齐,否则 board/filters 会漏状态。
|
||||
|
||||
### 5.2 QueueItem 与 Session 的绑定(可选)
|
||||
|
||||
- `QueueItem.sessionKey?: string`(允许把某条 queue item 固定投递到某会话)
|
||||
|
||||
---
|
||||
|
||||
## 6) 分期交付(从快到慢)
|
||||
|
||||
### Phase 1(2~6 天):PTY 会话底座 + Drawer 内嵌终端 + Queue “执行面”
|
||||
|
||||
- [BE] 新增 `CLI Session (PTY)`:create/list/attach/close/send/resize(WS 双向:output + input + resize)
|
||||
- [FE] IssueDrawer 增加 `Terminal` tab:xterm attach 会话、切换会话、发送文本、resize、close
|
||||
- [FE] QueuePanel 增加 `Execute in Session`:选/新建 sessionKey,将 queue item 渲染成可执行指令投递到会话
|
||||
- [Auto] 支持“拖到 in_progress 自动执行”:触发 `Execute in Session`(可配置开关)
|
||||
|
||||
### Phase 2(2~5 天):Issue Board(Kanban)+ Queue 管理面(多队列/重排/分组)
|
||||
|
||||
- [UI] IssueHubTabs 增加 `board`,IssueBoard 复用 `KanbanBoard`(状态列/排序/拖拽)
|
||||
- [UI] QueuePanel 增加多队列切换、execution_group 分组与 drag reorder、blocked/depends_on 可视化
|
||||
- [API] 若缺:跨 group 更新/排序落盘所需 endpoints(与现有 `/api/queue/reorder` 区分)
|
||||
|
||||
### Phase 3(3~8 天):编排器与 CliMux 集成(tmux-like 路由)
|
||||
|
||||
- [Core] 引入 `CliMuxPersistent`(PTY 会话:send/resize/close)
|
||||
- [Orchestrator] prompt-template 增加 `targetSessionKey/delivery`
|
||||
- [UI] ExecutionMonitor 增加“路由到会话”的快捷操作(选择 session)
|
||||
|
||||
### Phase 4(长期):安全、隔离、可观测、远程连接
|
||||
|
||||
- 资源隔离与安全策略(命令/路径白名单、审计日志、速率限制)
|
||||
- 会话回收(空闲超时、最大并发、OOM 保护)
|
||||
- 远程连接(可选):会话分享、只读 attach、与外部 agent/daemon 的桥接(对齐 EnsoAI 的 Hapi/Cloudflared 思路)
|
||||
|
||||
---
|
||||
|
||||
## 7) 尚未决策(下一轮需要明确)
|
||||
|
||||
1) `bash` 的落地方式(跨平台):
|
||||
- Linux/macOS:`bash -l`(或用户配置的 default shell)
|
||||
- Windows:优先 `wsl.exe -e bash -li`,其次 Git-Bash(`bash.exe`),都不可用时是否允许降级到 `pwsh`
|
||||
2) per-tool 的“nativeResume”参数与触发方式(需要按版本验证):
|
||||
- Claude:`--resume/--continue/--session-id` 的选择,以及在 PTY 下如何稳定触发一次“发送”(命令式 `-p` vs 交互式 send-keys)
|
||||
- Codex/Gemini:各自的 session/resume 旗标与输出格式(是否能稳定 machine-readable)
|
||||
3) 安全边界:
|
||||
- 是否需要 per-workspace 的路径白名单、命令白名单、以及审计日志(特别是 Queue/Orchestrator 自动投递)
|
||||
@@ -55,7 +55,9 @@
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.0"
|
||||
"zustand": "^5.0.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
|
||||
444
ccw/frontend/src/components/issue/hub/IssueBoardPanel.tsx
Normal file
444
ccw/frontend/src/components/issue/hub/IssueBoardPanel.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
// ========================================
|
||||
// Issue Board Panel
|
||||
// ========================================
|
||||
// Kanban board view for issues (status-driven) with local ordering.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { AlertCircle, LayoutGrid } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
|
||||
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
|
||||
import { IssueCard } from '@/components/shared/IssueCard';
|
||||
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIssues, useIssueMutations } from '@/hooks';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { createCliSession, executeInCliSession } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
|
||||
type IssueBoardStatus = Issue['status'];
|
||||
type ToolName = 'claude' | 'codex' | 'gemini';
|
||||
type ResumeStrategy = 'nativeResume' | 'promptConcat';
|
||||
|
||||
const BOARD_COLUMNS: Array<{ id: IssueBoardStatus; titleKey: string }> = [
|
||||
{ id: 'open', titleKey: 'issues.status.open' },
|
||||
{ id: 'in_progress', titleKey: 'issues.status.inProgress' },
|
||||
{ id: 'resolved', titleKey: 'issues.status.resolved' },
|
||||
{ id: 'completed', titleKey: 'issues.status.completed' },
|
||||
{ id: 'closed', titleKey: 'issues.status.closed' },
|
||||
];
|
||||
|
||||
type BoardOrder = Partial<Record<IssueBoardStatus, string[]>>;
|
||||
|
||||
function storageKey(projectPath: string | null | undefined): string {
|
||||
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
|
||||
return `ccw.issueBoard.order:${base}`;
|
||||
}
|
||||
|
||||
interface AutoStartConfig {
|
||||
enabled: boolean;
|
||||
tool: ToolName;
|
||||
mode: 'analysis' | 'write';
|
||||
resumeStrategy: ResumeStrategy;
|
||||
}
|
||||
|
||||
function autoStartStorageKey(projectPath: string | null | undefined): string {
|
||||
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
|
||||
return `ccw.issueBoard.autoStart:${base}`;
|
||||
}
|
||||
|
||||
function safeParseAutoStart(value: string | null): AutoStartConfig {
|
||||
const defaults: AutoStartConfig = {
|
||||
enabled: false,
|
||||
tool: 'claude',
|
||||
mode: 'write',
|
||||
resumeStrategy: 'nativeResume',
|
||||
};
|
||||
if (!value) return defaults;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<AutoStartConfig>;
|
||||
return {
|
||||
enabled: Boolean(parsed.enabled),
|
||||
tool: parsed.tool === 'codex' || parsed.tool === 'gemini' ? parsed.tool : 'claude',
|
||||
mode: parsed.mode === 'analysis' ? 'analysis' : 'write',
|
||||
resumeStrategy: parsed.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume',
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseOrder(value: string | null): BoardOrder {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
return parsed as BoardOrder;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
order: BoardOrder,
|
||||
formatTitle: (statusId: IssueBoardStatus) => string
|
||||
): KanbanColumn<Issue & KanbanItem>[] {
|
||||
const byId = new Map(issues.map((i) => [i.id, i]));
|
||||
const columns: KanbanColumn<Issue & KanbanItem>[] = [];
|
||||
|
||||
for (const col of BOARD_COLUMNS) {
|
||||
const desired = (order[col.id] ?? []).map((id) => byId.get(id)).filter(Boolean) as Issue[];
|
||||
const desiredIds = new Set(desired.map((i) => i.id));
|
||||
|
||||
const remaining = issues
|
||||
.filter((i) => i.status === col.id && !desiredIds.has(i.id))
|
||||
.sort((a, b) => {
|
||||
const at = a.updatedAt || a.createdAt;
|
||||
const bt = b.updatedAt || b.createdAt;
|
||||
return bt.localeCompare(at);
|
||||
});
|
||||
|
||||
const items = [...desired, ...remaining].map((issue) => ({
|
||||
...issue,
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
}));
|
||||
|
||||
columns.push({
|
||||
id: col.id,
|
||||
title: formatTitle(col.id),
|
||||
items,
|
||||
icon: <LayoutGrid className="w-4 h-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function syncOrderWithIssues(prev: BoardOrder, issues: Issue[]): BoardOrder {
|
||||
const statusById = new Map(issues.map((i) => [i.id, i.status]));
|
||||
const next: BoardOrder = {};
|
||||
|
||||
for (const { id: status } of BOARD_COLUMNS) {
|
||||
const existing = prev[status] ?? [];
|
||||
const filtered = existing.filter((id) => statusById.get(id) === status);
|
||||
const present = new Set(filtered);
|
||||
|
||||
const missing = issues
|
||||
.filter((i) => i.status === status && !present.has(i.id))
|
||||
.map((i) => i.id);
|
||||
|
||||
next[status] = [...filtered, ...missing];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function reorderIds(list: string[], from: number, to: number): string[] {
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved === undefined) return list;
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildIssueAutoPrompt(issue: Issue): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Issue: ${issue.id}`);
|
||||
lines.push(`Status: ${issue.status}`);
|
||||
lines.push(`Priority: ${issue.priority}`);
|
||||
lines.push('');
|
||||
lines.push(`Title: ${issue.title}`);
|
||||
if (issue.context) {
|
||||
lines.push('');
|
||||
lines.push('Context:');
|
||||
lines.push(String(issue.context));
|
||||
}
|
||||
|
||||
if (Array.isArray(issue.solutions) && issue.solutions.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Solutions:');
|
||||
for (const s of issue.solutions) {
|
||||
lines.push(`- [${s.status}] ${s.description}`);
|
||||
if (s.approach) lines.push(` Approach: ${s.approach}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Instruction:');
|
||||
lines.push(
|
||||
'Start working on this issue in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function IssueBoardPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const { issues, isLoading, error } = useIssues();
|
||||
const { updateIssue } = useIssueMutations();
|
||||
|
||||
const [order, setOrder] = useState<BoardOrder>({});
|
||||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||
const [drawerInitialTab, setDrawerInitialTab] = useState<'overview' | 'terminal'>('overview');
|
||||
const [optimisticError, setOptimisticError] = useState<string | null>(null);
|
||||
const [autoStart, setAutoStart] = useState<AutoStartConfig>(() => safeParseAutoStart(null));
|
||||
|
||||
// Load order when project changes
|
||||
useEffect(() => {
|
||||
const key = storageKey(projectPath);
|
||||
const loaded = safeParseOrder(localStorage.getItem(key));
|
||||
setOrder(loaded);
|
||||
}, [projectPath]);
|
||||
|
||||
// Load auto-start config when project changes
|
||||
useEffect(() => {
|
||||
const key = autoStartStorageKey(projectPath);
|
||||
setAutoStart(safeParseAutoStart(localStorage.getItem(key)));
|
||||
}, [projectPath]);
|
||||
|
||||
// Keep order consistent with current issues (status moves, deletions, new issues)
|
||||
useEffect(() => {
|
||||
setOrder((prev) => syncOrderWithIssues(prev, issues));
|
||||
}, [issues]);
|
||||
|
||||
// Persist order
|
||||
useEffect(() => {
|
||||
const key = storageKey(projectPath);
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(order));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}, [order, projectPath]);
|
||||
|
||||
// Persist auto-start config
|
||||
useEffect(() => {
|
||||
const key = autoStartStorageKey(projectPath);
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(autoStart));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}, [autoStart, projectPath]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildColumns(issues, order, (statusId) => {
|
||||
const col = BOARD_COLUMNS.find((c) => c.id === statusId);
|
||||
if (!col) return statusId;
|
||||
return formatMessage({ id: col.titleKey });
|
||||
}),
|
||||
[issues, order, formatMessage]
|
||||
);
|
||||
|
||||
const idsByStatus = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const col of columns) {
|
||||
map[col.id] = col.items.map((i) => i.id);
|
||||
}
|
||||
return map;
|
||||
}, [columns]);
|
||||
|
||||
const handleItemClick = useCallback((issue: Issue) => {
|
||||
setDrawerInitialTab('overview');
|
||||
setSelectedIssue(issue);
|
||||
}, []);
|
||||
|
||||
const handleCloseDrawer = useCallback(() => {
|
||||
setSelectedIssue(null);
|
||||
setOptimisticError(null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (result: DropResult, sourceColumn: string, destColumn: string) => {
|
||||
const issueId = result.draggableId;
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
if (!issue) return;
|
||||
|
||||
setOptimisticError(null);
|
||||
|
||||
const sourceStatus = sourceColumn as IssueBoardStatus;
|
||||
const destStatus = destColumn as IssueBoardStatus;
|
||||
const sourceIds = idsByStatus[sourceStatus] ?? [];
|
||||
const destIds = idsByStatus[destStatus] ?? [];
|
||||
|
||||
// Update local order first (optimistic)
|
||||
setOrder((prev) => {
|
||||
const next = { ...prev };
|
||||
if (sourceStatus === destStatus) {
|
||||
next[sourceStatus] = reorderIds(sourceIds, result.source.index, result.destination!.index);
|
||||
return next;
|
||||
}
|
||||
|
||||
const nextSource = [...sourceIds];
|
||||
nextSource.splice(result.source.index, 1);
|
||||
|
||||
const nextDest = [...destIds];
|
||||
nextDest.splice(result.destination!.index, 0, issueId);
|
||||
|
||||
next[sourceStatus] = nextSource;
|
||||
next[destStatus] = nextDest;
|
||||
return next;
|
||||
});
|
||||
|
||||
// Status update
|
||||
if (sourceStatus !== destStatus) {
|
||||
try {
|
||||
await updateIssue(issueId, { status: destStatus });
|
||||
|
||||
// Auto action: drag to in_progress opens the drawer on terminal tab.
|
||||
if (destStatus === 'in_progress' && sourceStatus !== 'in_progress') {
|
||||
setDrawerInitialTab('terminal');
|
||||
setSelectedIssue({ ...issue, status: destStatus });
|
||||
|
||||
if (autoStart.enabled) {
|
||||
if (!projectPath) {
|
||||
setOptimisticError('Auto-start failed: no project path selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCliSession({
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
tool: autoStart.tool,
|
||||
resumeKey: issueId,
|
||||
});
|
||||
await executeInCliSession(created.session.sessionKey, {
|
||||
tool: autoStart.tool,
|
||||
prompt: buildIssueAutoPrompt({ ...issue, status: destStatus }),
|
||||
mode: autoStart.mode,
|
||||
resumeKey: issueId,
|
||||
resumeStrategy: autoStart.resumeStrategy,
|
||||
});
|
||||
} catch (e) {
|
||||
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setOptimisticError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
},
|
||||
[issues, idsByStatus, updateIssue]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 mx-auto text-destructive/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.error.title' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">{error.message}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-col gap-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoStart.enabled}
|
||||
onCheckedChange={(checked) => setAutoStart((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
<div className="text-sm text-foreground">
|
||||
{formatMessage({ id: 'issues.board.autoStart.label' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={autoStart.tool}
|
||||
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, tool: v as ToolName }))}
|
||||
disabled={!autoStart.enabled}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">claude</SelectItem>
|
||||
<SelectItem value="codex">codex</SelectItem>
|
||||
<SelectItem value="gemini">gemini</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={autoStart.mode}
|
||||
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, mode: v as 'analysis' | 'write' }))}
|
||||
disabled={!autoStart.enabled}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="analysis">analysis</SelectItem>
|
||||
<SelectItem value="write">write</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={autoStart.resumeStrategy}
|
||||
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, resumeStrategy: v as ResumeStrategy }))}
|
||||
disabled={!autoStart.enabled}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nativeResume">nativeResume</SelectItem>
|
||||
<SelectItem value="promptConcat">promptConcat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimisticError && (
|
||||
<div className="text-sm text-destructive">
|
||||
{optimisticError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<KanbanBoard<Issue & KanbanItem>
|
||||
columns={columns}
|
||||
onDragEnd={handleDragEnd}
|
||||
onItemClick={(item) => handleItemClick(item as unknown as Issue)}
|
||||
isLoading={isLoading}
|
||||
emptyColumnMessage={formatMessage({ id: 'issues.emptyState.message' })}
|
||||
className={cn('gap-4', 'grid')}
|
||||
renderItem={(item, provided) => (
|
||||
<IssueCard
|
||||
issue={item as unknown as Issue}
|
||||
compact
|
||||
showActions={false}
|
||||
onClick={(i) => handleItemClick(i)}
|
||||
innerRef={provided.innerRef}
|
||||
draggableProps={provided.draggableProps}
|
||||
dragHandleProps={provided.dragHandleProps}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<IssueDrawer
|
||||
issue={selectedIssue}
|
||||
isOpen={Boolean(selectedIssue)}
|
||||
onClose={handleCloseDrawer}
|
||||
initialTab={drawerInitialTab}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueBoardPanel;
|
||||
@@ -3,23 +3,25 @@
|
||||
// ========================================
|
||||
// Right-side issue detail drawer with Overview/Solutions/History tabs
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash } from 'lucide-react';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash, Terminal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { IssueTerminalTab } from './IssueTerminalTab';
|
||||
|
||||
// ========== Types ==========
|
||||
export interface IssueDrawerProps {
|
||||
issue: Issue | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialTab?: TabValue;
|
||||
}
|
||||
|
||||
type TabValue = 'overview' | 'solutions' | 'history' | 'json';
|
||||
type TabValue = 'overview' | 'solutions' | 'history' | 'terminal' | 'json';
|
||||
|
||||
// ========== Status Configuration ==========
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
@@ -39,20 +41,25 @@ const priorityConfig: Record<string, { label: string; variant: 'default' | 'seco
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
|
||||
export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }: IssueDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('overview');
|
||||
const [activeTab, setActiveTab] = useState<TabValue>(initialTab);
|
||||
|
||||
// Reset to overview when issue changes
|
||||
useState(() => {
|
||||
// Reset to initial tab when opening/switching issues
|
||||
useEffect(() => {
|
||||
if (!isOpen || !issue) return;
|
||||
setActiveTab(initialTab);
|
||||
}, [initialTab, isOpen, issue?.id]);
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
});
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!issue || !isOpen) {
|
||||
return null;
|
||||
@@ -126,6 +133,10 @@ export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.detail.tabs.history' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terminal" className="flex-1">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.detail.tabs.terminal' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
<Hash className="h-4 w-4 mr-2" />
|
||||
JSON
|
||||
@@ -213,6 +224,11 @@ export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Terminal Tab */}
|
||||
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<IssueTerminalTab issueId={issue.id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Dynamic header component for IssueHub
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { AlertCircle, Radar, ListTodo } from 'lucide-react';
|
||||
import { AlertCircle, Radar, ListTodo, LayoutGrid } from 'lucide-react';
|
||||
|
||||
type IssueTab = 'issues' | 'queue' | 'discovery';
|
||||
type IssueTab = 'issues' | 'board' | 'queue' | 'discovery';
|
||||
|
||||
interface IssueHubHeaderProps {
|
||||
currentTab: IssueTab;
|
||||
@@ -22,6 +22,11 @@ export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
|
||||
title: formatMessage({ id: 'issues.title' }),
|
||||
description: formatMessage({ id: 'issues.description' }),
|
||||
},
|
||||
board: {
|
||||
icon: <LayoutGrid className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.board.pageTitle' }),
|
||||
description: formatMessage({ id: 'issues.board.description' }),
|
||||
},
|
||||
queue: {
|
||||
icon: <ListTodo className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.queue.pageTitle' }),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type IssueTab = 'issues' | 'queue' | 'discovery';
|
||||
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery';
|
||||
|
||||
interface IssueHubTabsProps {
|
||||
currentTab: IssueTab;
|
||||
@@ -19,6 +19,7 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
|
||||
|
||||
const tabs: Array<{ value: IssueTab; label: string }> = [
|
||||
{ value: 'issues', label: formatMessage({ id: 'issues.hub.tabs.issues' }) },
|
||||
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
|
||||
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
||||
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
||||
];
|
||||
|
||||
402
ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx
Normal file
402
ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
// ========================================
|
||||
// IssueTerminalTab
|
||||
// ========================================
|
||||
// Embedded xterm.js terminal for PTY-backed CLI sessions.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Plus, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import {
|
||||
closeCliSession,
|
||||
createCliSession,
|
||||
executeInCliSession,
|
||||
fetchCliSessionBuffer,
|
||||
fetchCliSessions,
|
||||
resizeCliSession,
|
||||
sendCliSessionText,
|
||||
type CliSession,
|
||||
} from '@/lib/api';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
|
||||
type ToolName = 'claude' | 'codex' | 'gemini';
|
||||
type ResumeStrategy = 'nativeResume' | 'promptConcat';
|
||||
|
||||
export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const setSessions = useCliSessionStore((s) => s.setSessions);
|
||||
const upsertSession = useCliSessionStore((s) => s.upsertSession);
|
||||
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
||||
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
||||
|
||||
const sessions = useMemo(() => Object.values(sessionsByKey).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), [sessionsByKey]);
|
||||
|
||||
const [selectedSessionKey, setSelectedSessionKey] = useState<string>('');
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [tool, setTool] = useState<ToolName>('claude');
|
||||
const [mode, setMode] = useState<'analysis' | 'write'>('analysis');
|
||||
const [resumeKey, setResumeKey] = useState(issueId);
|
||||
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const lastChunkIndexRef = useRef<number>(0);
|
||||
|
||||
const pendingInputRef = useRef<string>('');
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
|
||||
const flushInput = async () => {
|
||||
const sessionKey = selectedSessionKey;
|
||||
if (!sessionKey) return;
|
||||
const pending = pendingInputRef.current;
|
||||
pendingInputRef.current = '';
|
||||
if (!pending) return;
|
||||
try {
|
||||
await sendCliSessionText(sessionKey, { text: pending, appendNewline: false });
|
||||
} catch (e) {
|
||||
// Ignore transient failures (WS output still shows process state)
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimerRef.current !== null) return;
|
||||
flushTimerRef.current = window.setTimeout(async () => {
|
||||
flushTimerRef.current = null;
|
||||
await flushInput();
|
||||
}, 30);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingSessions(true);
|
||||
setError(null);
|
||||
fetchCliSessions()
|
||||
.then((r) => {
|
||||
setSessions(r.sessions as unknown as CliSession[]);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setIsLoadingSessions(false));
|
||||
}, [setSessions]);
|
||||
|
||||
// Auto-select a session if none selected yet
|
||||
useEffect(() => {
|
||||
if (selectedSessionKey) return;
|
||||
if (sessions.length === 0) return;
|
||||
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
|
||||
}, [sessions, selectedSessionKey]);
|
||||
|
||||
// Init xterm
|
||||
useEffect(() => {
|
||||
if (!terminalHostRef.current) return;
|
||||
if (xtermRef.current) return;
|
||||
|
||||
const term = new XTerm({
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalHostRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Forward keystrokes to backend (batched)
|
||||
term.onData((data) => {
|
||||
if (!selectedSessionKey) return;
|
||||
pendingInputRef.current += data;
|
||||
scheduleFlush();
|
||||
});
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
term.dispose();
|
||||
} finally {
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Attach to selected session: clear terminal and load buffer
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!term || !fitAddon) return;
|
||||
|
||||
lastChunkIndexRef.current = 0;
|
||||
term.reset();
|
||||
term.clear();
|
||||
|
||||
if (!selectedSessionKey) return;
|
||||
clearOutput(selectedSessionKey);
|
||||
|
||||
fetchCliSessionBuffer(selectedSessionKey)
|
||||
.then(({ buffer }) => {
|
||||
setBuffer(selectedSessionKey, buffer || '');
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
})
|
||||
.finally(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
}, [selectedSessionKey, setBuffer, clearOutput]);
|
||||
|
||||
// Stream new output chunks into xterm
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
if (!term) return;
|
||||
if (!selectedSessionKey) return;
|
||||
|
||||
const chunks = outputChunks[selectedSessionKey] ?? [];
|
||||
const start = lastChunkIndexRef.current;
|
||||
if (start >= chunks.length) return;
|
||||
|
||||
for (let i = start; i < chunks.length; i++) {
|
||||
term.write(chunks[i].data);
|
||||
}
|
||||
lastChunkIndexRef.current = chunks.length;
|
||||
}, [outputChunks, selectedSessionKey]);
|
||||
|
||||
// Resize observer -> fit + resize backend
|
||||
useEffect(() => {
|
||||
const host = terminalHostRef.current;
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!host || !term || !fitAddon) return;
|
||||
|
||||
const resize = () => {
|
||||
fitAddon.fit();
|
||||
if (selectedSessionKey) {
|
||||
void (async () => {
|
||||
try {
|
||||
await resizeCliSession(selectedSessionKey, { cols: term.cols, rows: term.rows });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, [selectedSessionKey]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createCliSession({
|
||||
workingDir: projectPath || undefined,
|
||||
preferredShell: 'bash',
|
||||
cols: xtermRef.current?.cols,
|
||||
rows: xtermRef.current?.rows,
|
||||
tool,
|
||||
model: undefined,
|
||||
resumeKey,
|
||||
});
|
||||
upsertSession(created.session as unknown as CliSession);
|
||||
setSelectedSessionKey(created.session.sessionKey);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSession = async () => {
|
||||
if (!selectedSessionKey) return;
|
||||
setIsClosing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await closeCliSession(selectedSessionKey);
|
||||
setSelectedSessionKey('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!selectedSessionKey) return;
|
||||
if (!prompt.trim()) return;
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await executeInCliSession(selectedSessionKey, {
|
||||
tool,
|
||||
prompt: prompt.trim(),
|
||||
mode,
|
||||
resumeKey: resumeKey.trim() || undefined,
|
||||
resumeStrategy,
|
||||
category: 'user',
|
||||
});
|
||||
setPrompt('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshSessions = async () => {
|
||||
setIsLoadingSessions(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetchCliSessions();
|
||||
setSessions(r.sessions as unknown as CliSession[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<Select value={selectedSessionKey} onValueChange={setSelectedSessionKey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.terminal.session.select' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sessions.map((s) => (
|
||||
<SelectItem key={s.sessionKey} value={s.sessionKey}>
|
||||
{(s.tool || 'cli') + ' · ' + s.sessionKey}
|
||||
</SelectItem>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
{formatMessage({ id: 'issues.terminal.session.none' })}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={handleRefreshSessions} disabled={isLoadingSessions}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.terminal.session.refresh' })}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleCreateSession} disabled={isCreating}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.terminal.session.new' })}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCloseSession}
|
||||
disabled={!selectedSessionKey || isClosing}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.terminal.session.close' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.tool' })}</div>
|
||||
<Select value={tool} onValueChange={(v) => setTool(v as ToolName)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">claude</SelectItem>
|
||||
<SelectItem value="codex">codex</SelectItem>
|
||||
<SelectItem value="gemini">gemini</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.mode' })}</div>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as 'analysis' | 'write')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="analysis">analysis</SelectItem>
|
||||
<SelectItem value="write">write</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.resumeKey' })}</div>
|
||||
<Input value={resumeKey} onChange={(e) => setResumeKey(e.target.value)} placeholder={issueId} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
|
||||
</div>
|
||||
<Select value={resumeStrategy} onValueChange={(v) => setResumeStrategy(v as ResumeStrategy)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nativeResume">nativeResume</SelectItem>
|
||||
<SelectItem value="promptConcat">promptConcat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.prompt.label' })}</div>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.terminal.exec.prompt.placeholder' })}
|
||||
className={cn(
|
||||
'w-full min-h-[90px] p-3 bg-background border border-input rounded-md text-sm resize-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleExecute} disabled={!selectedSessionKey || isExecuting || !prompt.trim()}>
|
||||
{formatMessage({ id: 'issues.terminal.exec.run' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border bg-black/90 overflow-hidden">
|
||||
<div ref={terminalHostRef} className="h-[420px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Content panel for Queue tab in IssueHub
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -14,9 +14,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
|
||||
import { QueueCard } from '@/components/issue/queue/QueueCard';
|
||||
import { QueueBoard } from '@/components/issue/queue/QueueBoard';
|
||||
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
|
||||
import { useIssueQueue, useQueueMutations } from '@/hooks';
|
||||
import { useIssueQueue, useQueueHistory, useQueueMutations } from '@/hooks';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Loading Skeleton ==========
|
||||
@@ -73,6 +76,7 @@ export function QueuePanel() {
|
||||
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
|
||||
|
||||
const { data: queueData, isLoading, error } = useIssueQueue();
|
||||
const { data: historyIndex } = useQueueHistory();
|
||||
const {
|
||||
activateQueue,
|
||||
deactivateQueue,
|
||||
@@ -93,6 +97,16 @@ export function QueuePanel() {
|
||||
const conflictCount = queue?.conflicts?.length || 0;
|
||||
const groupCount = Object.keys(queue?.grouped_items || {}).length;
|
||||
const totalItems = taskCount + solutionCount;
|
||||
const activeQueueId = historyIndex?.active_queue_id || null;
|
||||
const activeQueueIds = historyIndex?.active_queue_ids || [];
|
||||
const queueId = queue?.id;
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
|
||||
|
||||
// Keep selector in sync with active queue id
|
||||
useEffect(() => {
|
||||
if (activeQueueId) setSelectedQueueId(activeQueueId);
|
||||
else if (queueId) setSelectedQueueId(queueId);
|
||||
}, [activeQueueId, queueId]);
|
||||
|
||||
const handleActivate = async (queueId: string) => {
|
||||
try {
|
||||
@@ -164,11 +178,62 @@ export function QueuePanel() {
|
||||
return <QueueEmptyState />;
|
||||
}
|
||||
|
||||
// Check if queue is active (has items and no conflicts)
|
||||
const isActive = totalItems > 0 && conflictCount === 0;
|
||||
// Check if queue is active (multi-queue index preferred)
|
||||
const isActive = queueId ? activeQueueIds.includes(queueId) : totalItems > 0 && conflictCount === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Queue History / Active Queue Selector */}
|
||||
{historyIndex && (
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-3 justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.history.title' })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
{formatMessage({ id: 'issues.queue.history.active' })}:{' '}
|
||||
{activeQueueId || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedQueueId} onValueChange={(v) => setSelectedQueueId(v)}>
|
||||
<SelectTrigger className="w-[260px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.queue.history.select' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(historyIndex.queues || []).length === 0 ? (
|
||||
<SelectItem value="" disabled>
|
||||
{formatMessage({ id: 'issues.queue.history.empty' })}
|
||||
</SelectItem>
|
||||
) : (
|
||||
historyIndex.queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.id}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selectedQueueId || isActivating}
|
||||
onClick={() => activateQueue(selectedQueueId)}
|
||||
>
|
||||
{formatMessage({ id: 'issues.queue.history.activate' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDeactivating}
|
||||
onClick={() => deactivateQueue()}
|
||||
>
|
||||
{formatMessage({ id: 'issues.queue.actions.deactivate' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
@@ -226,25 +291,26 @@ export function QueuePanel() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Queue Card */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<QueueCard
|
||||
key="current"
|
||||
queue={queue}
|
||||
isActive={isActive}
|
||||
onActivate={handleActivate}
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onMerge={handleMerge}
|
||||
onSplit={handleSplit}
|
||||
onItemClick={handleItemClick}
|
||||
isActivating={isActivating}
|
||||
isDeactivating={isDeactivating}
|
||||
isDeleting={isDeleting}
|
||||
isMerging={isMerging}
|
||||
isSplitting={isSplitting}
|
||||
/>
|
||||
</div>
|
||||
{/* Queue Card (actions + summary) */}
|
||||
<QueueCard
|
||||
key={queue.id || 'legacy'}
|
||||
queue={queue}
|
||||
isActive={isActive}
|
||||
onActivate={handleActivate}
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onMerge={handleMerge}
|
||||
onSplit={handleSplit}
|
||||
onItemClick={handleItemClick}
|
||||
isActivating={isActivating}
|
||||
isDeactivating={isDeactivating}
|
||||
isDeleting={isDeleting}
|
||||
isMerging={isMerging}
|
||||
isSplitting={isSplitting}
|
||||
/>
|
||||
|
||||
{/* Queue Board (DnD reorder/move) */}
|
||||
<QueueBoard queue={queue} onItemClick={handleItemClick} />
|
||||
|
||||
{/* Solution Detail Drawer */}
|
||||
<SolutionDrawer
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { IssueQueue, QueueItem } from '@/lib/api';
|
||||
|
||||
export interface QueueActionsProps {
|
||||
queue: IssueQueue;
|
||||
queueId?: string;
|
||||
isActive?: boolean;
|
||||
onActivate?: (queueId: string) => void;
|
||||
onDeactivate?: () => void;
|
||||
@@ -50,6 +51,7 @@ export interface QueueActionsProps {
|
||||
|
||||
export function QueueActions({
|
||||
queue,
|
||||
queueId: queueIdProp,
|
||||
isActive = false,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
@@ -69,20 +71,20 @@ export function QueueActions({
|
||||
const [mergeTargetId, setMergeTargetId] = useState('');
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
|
||||
|
||||
// Use "current" as the queue ID for single-queue model
|
||||
// This matches the API pattern where deactivate works on the current queue
|
||||
const queueId = 'current';
|
||||
const queueId = queueIdProp;
|
||||
|
||||
// Get all items from grouped_items for split dialog
|
||||
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!queueId) return;
|
||||
onDelete?.(queueId);
|
||||
setIsDeleteOpen(false);
|
||||
};
|
||||
|
||||
const handleMerge = () => {
|
||||
if (mergeTargetId.trim()) {
|
||||
if (!queueId) return;
|
||||
onMerge?.(queueId, mergeTargetId.trim());
|
||||
setIsMergeOpen(false);
|
||||
setMergeTargetId('');
|
||||
@@ -91,6 +93,7 @@ export function QueueActions({
|
||||
|
||||
const handleSplit = () => {
|
||||
if (selectedItemIds.length > 0 && selectedItemIds.length < allItems.length) {
|
||||
if (!queueId) return;
|
||||
onSplit?.(queueId, selectedItemIds);
|
||||
setIsSplitOpen(false);
|
||||
setSelectedItemIds([]);
|
||||
@@ -128,7 +131,7 @@ export function QueueActions({
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onActivate(queueId)}
|
||||
disabled={isActivating}
|
||||
disabled={isActivating || !queueId}
|
||||
title={formatMessage({ id: 'issues.queue.actions.activate' })}
|
||||
>
|
||||
{isActivating ? (
|
||||
@@ -161,7 +164,7 @@ export function QueueActions({
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsMergeOpen(true)}
|
||||
disabled={isMerging}
|
||||
disabled={isMerging || !queueId}
|
||||
title={formatMessage({ id: 'issues.queue.actions.merge' })}
|
||||
>
|
||||
{isMerging ? (
|
||||
@@ -178,7 +181,7 @@ export function QueueActions({
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsSplitOpen(true)}
|
||||
disabled={isSplitting}
|
||||
disabled={isSplitting || !queueId}
|
||||
title={formatMessage({ id: 'issues.queue.actions.split' })}
|
||||
>
|
||||
{isSplitting ? (
|
||||
@@ -195,7 +198,7 @@ export function QueueActions({
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeleting || !queueId}
|
||||
title={formatMessage({ id: 'issues.queue.actions.delete' })}
|
||||
>
|
||||
{isDeleting ? (
|
||||
|
||||
171
ccw/frontend/src/components/issue/queue/QueueBoard.tsx
Normal file
171
ccw/frontend/src/components/issue/queue/QueueBoard.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
// ========================================
|
||||
// QueueBoard
|
||||
// ========================================
|
||||
// Kanban-style view of queue execution groups with DnD reordering/moving.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useQueueMutations } from '@/hooks';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import type { IssueQueue, QueueItem } from '@/lib/api';
|
||||
|
||||
type QueueBoardItem = QueueItem & KanbanItem;
|
||||
|
||||
function groupSortKey(groupId: string): [number, string] {
|
||||
const n = parseInt(groupId.match(/\d+/)?.[0] || '999');
|
||||
return [Number.isFinite(n) ? n : 999, groupId];
|
||||
}
|
||||
|
||||
function buildColumns(queue: IssueQueue): KanbanColumn<QueueBoardItem>[] {
|
||||
const entries = Object.entries(queue.grouped_items || {});
|
||||
entries.sort(([a], [b]) => {
|
||||
const [an, aid] = groupSortKey(a);
|
||||
const [bn, bid] = groupSortKey(b);
|
||||
if (an !== bn) return an - bn;
|
||||
return aid.localeCompare(bid);
|
||||
});
|
||||
|
||||
return entries.map(([groupId, items]) => {
|
||||
const sorted = [...(items || [])].sort((a, b) => (a.execution_order || 0) - (b.execution_order || 0));
|
||||
const mapped = sorted.map((it) => ({
|
||||
...it,
|
||||
id: it.item_id,
|
||||
title: `${it.issue_id} · ${it.solution_id}`,
|
||||
status: it.status,
|
||||
}));
|
||||
return {
|
||||
id: groupId,
|
||||
title: groupId,
|
||||
items: mapped,
|
||||
icon: <LayoutGrid className="w-4 h-4" />,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyDrag(columns: KanbanColumn<QueueBoardItem>[], result: DropResult): KanbanColumn<QueueBoardItem>[] {
|
||||
if (!result.destination) return columns;
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
const next = columns.map((c) => ({ ...c, items: [...c.items] }));
|
||||
const src = next.find((c) => c.id === source.droppableId);
|
||||
const dst = next.find((c) => c.id === destination.droppableId);
|
||||
if (!src || !dst) return columns;
|
||||
|
||||
const srcIndex = src.items.findIndex((i) => i.id === draggableId);
|
||||
if (srcIndex === -1) return columns;
|
||||
|
||||
const [moved] = src.items.splice(srcIndex, 1);
|
||||
if (!moved) return columns;
|
||||
|
||||
dst.items.splice(destination.index, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function QueueBoard({
|
||||
queue,
|
||||
onItemClick,
|
||||
className,
|
||||
}: {
|
||||
queue: IssueQueue;
|
||||
onItemClick?: (item: QueueItem) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const { reorderQueueGroup, moveQueueItem, isReordering, isMoving } = useQueueMutations();
|
||||
|
||||
const baseColumns = useMemo(() => buildColumns(queue), [queue]);
|
||||
const [columns, setColumns] = useState<KanbanColumn<QueueBoardItem>[]>(baseColumns);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(baseColumns);
|
||||
}, [baseColumns]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (result: DropResult, sourceColumn: string, destColumn: string) => {
|
||||
if (!projectPath) return;
|
||||
if (!result.destination) return;
|
||||
if (sourceColumn === destColumn && result.source.index === result.destination.index) return;
|
||||
|
||||
try {
|
||||
const nextColumns = applyDrag(columns, result);
|
||||
setColumns(nextColumns);
|
||||
|
||||
const itemId = result.draggableId;
|
||||
|
||||
if (sourceColumn === destColumn) {
|
||||
const column = nextColumns.find((c) => c.id === sourceColumn);
|
||||
const nextOrder = (column?.items ?? []).map((i) => i.item_id);
|
||||
await reorderQueueGroup(sourceColumn, nextOrder);
|
||||
} else {
|
||||
await moveQueueItem(itemId, destColumn, result.destination.index);
|
||||
}
|
||||
} catch (e) {
|
||||
// Revert by resetting to server-derived columns
|
||||
setColumns(baseColumns);
|
||||
}
|
||||
},
|
||||
[baseColumns, columns, moveQueueItem, projectPath, reorderQueueGroup]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
{formatMessage({ id: 'issues.queue.stats.executionGroups' })}
|
||||
</Badge>
|
||||
{(isReordering || isMoving) && (
|
||||
<span>
|
||||
{formatMessage({ id: 'common.status.running' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<KanbanBoard<QueueBoardItem>
|
||||
columns={columns}
|
||||
onDragEnd={handleDragEnd}
|
||||
onItemClick={(item) => onItemClick?.(item as unknown as QueueItem)}
|
||||
emptyColumnMessage={formatMessage({ id: 'issues.queue.empty' })}
|
||||
renderItem={(item, provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onClick={() => onItemClick?.(item as unknown as QueueItem)}
|
||||
className={cn(
|
||||
'p-3 bg-card border border-border rounded-lg shadow-sm cursor-pointer',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||
item.status === 'blocked' && 'border-destructive/50 bg-destructive/5',
|
||||
item.status === 'failed' && 'border-destructive/50 bg-destructive/5',
|
||||
item.status === 'executing' && 'border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-mono text-muted-foreground">{item.item_id}</div>
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{item.issue_id} · {item.solution_id}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{formatMessage({ id: `issues.queue.status.${item.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.depends_on?.length ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.solution.overview.dependencies' })}: {item.depends_on.length}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueueBoard;
|
||||
@@ -51,8 +51,7 @@ export function QueueCard({
|
||||
}: QueueCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Use "current" for queue ID display
|
||||
const queueId = 'current';
|
||||
const queueId = queue.id;
|
||||
|
||||
// Calculate item counts
|
||||
const taskCount = queue.tasks?.length || 0;
|
||||
@@ -88,7 +87,7 @@ export function QueueCard({
|
||||
{formatMessage({ id: 'issues.queue.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{queueId.substring(0, 20)}{queueId.length > 20 ? '...' : ''}
|
||||
{(queueId || 'legacy').substring(0, 20)}{(queueId || 'legacy').length > 20 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,6 +101,7 @@ export function QueueCard({
|
||||
|
||||
<QueueActions
|
||||
queue={queue}
|
||||
queueId={queueId}
|
||||
isActive={isActive}
|
||||
onActivate={onActivate}
|
||||
onDeactivate={onDeactivate}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// ========================================
|
||||
// QueueExecuteInSession
|
||||
// ========================================
|
||||
// Minimal “execution plane” for queue items:
|
||||
// pick/create a PTY session and submit a generated prompt to it.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useIssues } from '@/hooks';
|
||||
import {
|
||||
createCliSession,
|
||||
executeInCliSession,
|
||||
fetchCliSessions,
|
||||
type CliSession,
|
||||
type QueueItem,
|
||||
} from '@/lib/api';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
|
||||
type ToolName = 'claude' | 'codex' | 'gemini';
|
||||
type ResumeStrategy = 'nativeResume' | 'promptConcat';
|
||||
|
||||
function buildQueueItemPrompt(item: QueueItem, issue: any | undefined): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Queue Item: ${item.item_id}`);
|
||||
lines.push(`Issue: ${item.issue_id}`);
|
||||
lines.push(`Solution: ${item.solution_id}`);
|
||||
if (item.task_id) lines.push(`Task: ${item.task_id}`);
|
||||
lines.push('');
|
||||
|
||||
if (issue) {
|
||||
if (issue.title) lines.push(`Title: ${issue.title}`);
|
||||
if (issue.context) {
|
||||
lines.push('');
|
||||
lines.push('Context:');
|
||||
lines.push(String(issue.context));
|
||||
}
|
||||
|
||||
const solution = Array.isArray(issue.solutions)
|
||||
? issue.solutions.find((s: any) => s?.id === item.solution_id)
|
||||
: undefined;
|
||||
if (solution) {
|
||||
lines.push('');
|
||||
lines.push('Solution Description:');
|
||||
if (solution.description) lines.push(String(solution.description));
|
||||
if (solution.approach) {
|
||||
lines.push('');
|
||||
lines.push('Approach:');
|
||||
lines.push(String(solution.approach));
|
||||
}
|
||||
|
||||
// Best-effort: if the solution has embedded tasks, include the matched task.
|
||||
const tasks = Array.isArray(solution.tasks) ? solution.tasks : [];
|
||||
const task = item.task_id ? tasks.find((t: any) => t?.id === item.task_id) : undefined;
|
||||
if (task) {
|
||||
lines.push('');
|
||||
lines.push('Task:');
|
||||
if (task.title) lines.push(`- ${task.title}`);
|
||||
if (task.description) lines.push(String(task.description));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Instruction:');
|
||||
lines.push(
|
||||
'Implement the above queue item in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function QueueExecuteInSession({ item, className }: { item: QueueItem; className?: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const { issues } = useIssues();
|
||||
const issue = useMemo(() => issues.find((i) => i.id === item.issue_id) as any, [issues, item.issue_id]);
|
||||
|
||||
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||
const setSessions = useCliSessionStore((s) => s.setSessions);
|
||||
const upsertSession = useCliSessionStore((s) => s.upsertSession);
|
||||
|
||||
const sessions = useMemo(
|
||||
() => Object.values(sessionsByKey).sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
|
||||
[sessionsByKey]
|
||||
);
|
||||
|
||||
const [selectedSessionKey, setSelectedSessionKey] = useState<string>('');
|
||||
const [tool, setTool] = useState<ToolName>('claude');
|
||||
const [mode, setMode] = useState<'analysis' | 'write'>('write');
|
||||
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastExecution, setLastExecution] = useState<{ executionId: string; command: string } | null>(null);
|
||||
|
||||
const refreshSessions = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetchCliSessions();
|
||||
setSessions(r.sessions as unknown as CliSession[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSessions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionKey) return;
|
||||
if (sessions.length === 0) return;
|
||||
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
|
||||
}, [sessions, selectedSessionKey]);
|
||||
|
||||
const ensureSession = async (): Promise<string> => {
|
||||
if (selectedSessionKey) return selectedSessionKey;
|
||||
if (!projectPath) throw new Error('No project path selected');
|
||||
const created = await createCliSession({
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
resumeKey: item.issue_id,
|
||||
});
|
||||
upsertSession(created.session as unknown as CliSession);
|
||||
setSelectedSessionKey(created.session.sessionKey);
|
||||
return created.session.sessionKey;
|
||||
};
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
if (!projectPath) throw new Error('No project path selected');
|
||||
const created = await createCliSession({
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
resumeKey: item.issue_id,
|
||||
});
|
||||
upsertSession(created.session as unknown as CliSession);
|
||||
setSelectedSessionKey(created.session.sessionKey);
|
||||
await refreshSessions();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
setLastExecution(null);
|
||||
try {
|
||||
const sessionKey = await ensureSession();
|
||||
const prompt = buildQueueItemPrompt(item, issue);
|
||||
const result = await executeInCliSession(sessionKey, {
|
||||
tool,
|
||||
prompt,
|
||||
mode,
|
||||
workingDir: projectPath,
|
||||
category: 'user',
|
||||
resumeKey: item.issue_id,
|
||||
resumeStrategy,
|
||||
});
|
||||
setLastExecution({ executionId: result.executionId, command: result.command });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.exec.title' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshSessions}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
{formatMessage({ id: 'issues.terminal.session.refresh' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCreateSession} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.terminal.session.new' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.terminal.session.select' })}
|
||||
</label>
|
||||
<Select value={selectedSessionKey} onValueChange={(v) => setSelectedSessionKey(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.terminal.session.none' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sessions.length === 0 ? (
|
||||
<SelectItem value="" disabled>
|
||||
{formatMessage({ id: 'issues.terminal.session.none' })}
|
||||
</SelectItem>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SelectItem key={s.sessionKey} value={s.sessionKey}>
|
||||
{(s.tool || 'cli') + ' · ' + s.sessionKey}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.terminal.exec.tool' })}
|
||||
</label>
|
||||
<Select value={tool} onValueChange={(v) => setTool(v as ToolName)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">claude</SelectItem>
|
||||
<SelectItem value="codex">codex</SelectItem>
|
||||
<SelectItem value="gemini">gemini</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.terminal.exec.mode' })}
|
||||
</label>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as 'analysis' | 'write')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="analysis">analysis</SelectItem>
|
||||
<SelectItem value="write">write</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
|
||||
</label>
|
||||
<Select value={resumeStrategy} onValueChange={(v) => setResumeStrategy(v as ResumeStrategy)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nativeResume">nativeResume</SelectItem>
|
||||
<SelectItem value="promptConcat">promptConcat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{lastExecution && (
|
||||
<div className="text-xs text-muted-foreground font-mono break-all">
|
||||
{lastExecution.executionId}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={handleExecute} disabled={isExecuting || !projectPath} className="gap-2">
|
||||
{formatMessage({ id: 'issues.terminal.exec.run' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle, Terminal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
|
||||
import { IssueTerminalTab } from '@/components/issue/hub/IssueTerminalTab';
|
||||
import { useIssueQueue } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
@@ -20,7 +22,7 @@ export interface SolutionDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabValue = 'overview' | 'tasks' | 'json';
|
||||
type TabValue = 'overview' | 'tasks' | 'terminal' | 'json';
|
||||
|
||||
// ========== Status Configuration ==========
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
@@ -134,6 +136,10 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.solution.tabs.tasks' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terminal" className="flex-1">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.solution.tabs.terminal' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.solution.tabs.json' })}
|
||||
@@ -170,6 +176,9 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execute in Session */}
|
||||
<QueueExecuteInSession item={item} />
|
||||
|
||||
{/* Dependencies */}
|
||||
{item.depends_on && item.depends_on.length > 0 && (
|
||||
<div>
|
||||
@@ -244,6 +253,11 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Terminal Tab */}
|
||||
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<IssueTerminalTab issueId={issueId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON Tab */}
|
||||
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">
|
||||
|
||||
@@ -69,6 +69,7 @@ export type {
|
||||
export {
|
||||
useIssues,
|
||||
useIssueQueue,
|
||||
useQueueHistory,
|
||||
useCreateIssue,
|
||||
useUpdateIssue,
|
||||
useDeleteIssue,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchIssues,
|
||||
fetchIssueHistory,
|
||||
fetchIssueQueue,
|
||||
fetchQueueHistory,
|
||||
createIssue,
|
||||
updateIssue,
|
||||
deleteIssue,
|
||||
@@ -16,12 +17,15 @@ import {
|
||||
deleteQueue as deleteQueueApi,
|
||||
mergeQueues as mergeQueuesApi,
|
||||
splitQueue as splitQueueApi,
|
||||
reorderQueueGroup as reorderQueueGroupApi,
|
||||
moveQueueItem as moveQueueItemApi,
|
||||
fetchDiscoveries,
|
||||
fetchDiscoveryFindings,
|
||||
exportDiscoveryFindingsAsIssues,
|
||||
type Issue,
|
||||
type IssueQueue,
|
||||
type IssuesResponse,
|
||||
type QueueHistoryIndex,
|
||||
type DiscoverySession,
|
||||
type Finding,
|
||||
} from '../lib/api';
|
||||
@@ -309,14 +313,31 @@ export interface UseQueueMutationsReturn {
|
||||
deleteQueue: (queueId: string) => Promise<void>;
|
||||
mergeQueues: (sourceId: string, targetId: string) => Promise<void>;
|
||||
splitQueue: (sourceQueueId: string, itemIds: string[]) => Promise<void>;
|
||||
reorderQueueGroup: (groupId: string, newOrder: string[]) => Promise<void>;
|
||||
moveQueueItem: (itemId: string, toGroupId: string, toIndex?: number) => Promise<void>;
|
||||
isActivating: boolean;
|
||||
isDeactivating: boolean;
|
||||
isDeleting: boolean;
|
||||
isMerging: boolean;
|
||||
isSplitting: boolean;
|
||||
isReordering: boolean;
|
||||
isMoving: boolean;
|
||||
isMutating: boolean;
|
||||
}
|
||||
|
||||
export function useQueueHistory(options?: { enabled?: boolean; refetchInterval?: number }): UseQueryResult<QueueHistoryIndex> {
|
||||
const { enabled = true, refetchInterval = 0 } = options ?? {};
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
return useQuery({
|
||||
queryKey: workspaceQueryKeys.issueQueueHistory(projectPath),
|
||||
queryFn: () => fetchQueueHistory(projectPath),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: enabled && !!projectPath,
|
||||
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
@@ -325,6 +346,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mutationFn: (queueId: string) => activateQueue(queueId, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -332,6 +354,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mutationFn: () => deactivateQueue(projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -339,6 +362,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -347,12 +371,30 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mergeQueuesApi(sourceId, targetId, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
const splitMutation = useMutation({
|
||||
mutationFn: ({ sourceQueueId, itemIds }: { sourceQueueId: string; itemIds: string[] }) =>
|
||||
splitQueueApi(sourceQueueId, itemIds, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: ({ groupId, newOrder }: { groupId: string; newOrder: string[] }) =>
|
||||
reorderQueueGroupApi(projectPath, { groupId, newOrder }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: ({ itemId, toGroupId, toIndex }: { itemId: string; toGroupId: string; toIndex?: number }) =>
|
||||
moveQueueItemApi(projectPath, { itemId, toGroupId, toIndex }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
},
|
||||
@@ -364,12 +406,23 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
deleteQueue: deleteMutation.mutateAsync,
|
||||
mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }),
|
||||
splitQueue: (sourceQueueId, itemIds) => splitMutation.mutateAsync({ sourceQueueId, itemIds }),
|
||||
reorderQueueGroup: (groupId, newOrder) => reorderMutation.mutateAsync({ groupId, newOrder }).then(() => {}),
|
||||
moveQueueItem: (itemId, toGroupId, toIndex) => moveMutation.mutateAsync({ itemId, toGroupId, toIndex }).then(() => {}),
|
||||
isActivating: activateMutation.isPending,
|
||||
isDeactivating: deactivateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isMerging: mergeMutation.isPending,
|
||||
isSplitting: splitMutation.isPending,
|
||||
isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending || splitMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isMoving: moveMutation.isPending,
|
||||
isMutating:
|
||||
activateMutation.isPending ||
|
||||
deactivateMutation.isPending ||
|
||||
deleteMutation.isPending ||
|
||||
mergeMutation.isPending ||
|
||||
splitMutation.isPending ||
|
||||
reorderMutation.isPending ||
|
||||
moveMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNotificationStore } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import {
|
||||
OrchestratorMessageSchema,
|
||||
type OrchestratorWebSocketMessage,
|
||||
@@ -28,6 +29,7 @@ function getStoreState() {
|
||||
const execution = useExecutionStore.getState();
|
||||
const flow = useFlowStore.getState();
|
||||
const cliStream = useCliStreamStore.getState();
|
||||
const cliSessions = useCliSessionStore.getState();
|
||||
return {
|
||||
// Notification store
|
||||
setWsStatus: notification.setWsStatus,
|
||||
@@ -56,6 +58,11 @@ function getStoreState() {
|
||||
updateNode: flow.updateNode,
|
||||
// CLI stream store
|
||||
addOutput: cliStream.addOutput,
|
||||
|
||||
// CLI session store (PTY-backed terminal)
|
||||
upsertCliSession: cliSessions.upsertSession,
|
||||
removeCliSession: cliSessions.removeSession,
|
||||
appendCliSessionOutput: cliSessions.appendOutput,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +170,31 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== PTY CLI Sessions ==========
|
||||
case 'CLI_SESSION_CREATED': {
|
||||
const session = data.payload?.session;
|
||||
if (session?.sessionKey) {
|
||||
stores.upsertCliSession(session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLI_SESSION_OUTPUT': {
|
||||
const { sessionKey, data: chunk } = data.payload ?? {};
|
||||
if (typeof sessionKey === 'string' && typeof chunk === 'string') {
|
||||
stores.appendCliSessionOutput(sessionKey, chunk);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLI_SESSION_CLOSED': {
|
||||
const { sessionKey } = data.payload ?? {};
|
||||
if (typeof sessionKey === 'string') {
|
||||
stores.removeCliSession(sessionKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLI_OUTPUT': {
|
||||
const { executionId, chunkType, data: outputData, unit } = data.payload;
|
||||
|
||||
|
||||
@@ -702,8 +702,9 @@ export interface QueueItem {
|
||||
}
|
||||
|
||||
export interface IssueQueue {
|
||||
tasks: string[];
|
||||
solutions: string[];
|
||||
id?: string;
|
||||
tasks?: QueueItem[];
|
||||
solutions?: QueueItem[];
|
||||
conflicts: string[];
|
||||
execution_groups: string[];
|
||||
grouped_items: Record<string, QueueItem[]>;
|
||||
@@ -816,12 +817,38 @@ export async function pullIssuesFromGitHub(options: GitHubPullOptions = {}): Pro
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Queue History (Multi-Queue) ==========
|
||||
|
||||
export interface QueueHistoryEntry {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
status?: string;
|
||||
issue_ids?: string[];
|
||||
total_tasks?: number;
|
||||
completed_tasks?: number;
|
||||
total_solutions?: number;
|
||||
completed_solutions?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QueueHistoryIndex {
|
||||
queues: QueueHistoryEntry[];
|
||||
active_queue_id: string | null;
|
||||
active_queue_ids: string[];
|
||||
}
|
||||
|
||||
export async function fetchQueueHistory(projectPath: string): Promise<QueueHistoryIndex> {
|
||||
return fetchApi<QueueHistoryIndex>(`/api/queue/history?path=${encodeURIComponent(projectPath)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a queue
|
||||
*/
|
||||
export async function activateQueue(queueId: string, projectPath: string): Promise<void> {
|
||||
return fetchApi<void>(`/api/queue/${encodeURIComponent(queueId)}/activate?path=${encodeURIComponent(projectPath)}`, {
|
||||
return fetchApi<void>(`/api/queue/activate?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ queueId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -834,6 +861,32 @@ export async function deactivateQueue(projectPath: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder items within a single execution group
|
||||
*/
|
||||
export async function reorderQueueGroup(
|
||||
projectPath: string,
|
||||
input: { groupId: string; newOrder: string[] }
|
||||
): Promise<{ success: boolean; groupId: string; reordered: number }> {
|
||||
return fetchApi<{ success: boolean; groupId: string; reordered: number }>(
|
||||
`/api/queue/reorder?path=${encodeURIComponent(projectPath)}`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item across execution groups (and optionally insert at index)
|
||||
*/
|
||||
export async function moveQueueItem(
|
||||
projectPath: string,
|
||||
input: { itemId: string; toGroupId: string; toIndex?: number }
|
||||
): Promise<{ success: boolean; itemId: string; fromGroupId: string; toGroupId: string }> {
|
||||
return fetchApi<{ success: boolean; itemId: string; fromGroupId: string; toGroupId: string }>(
|
||||
`/api/queue/move?path=${encodeURIComponent(projectPath)}`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a queue
|
||||
*/
|
||||
@@ -849,7 +902,7 @@ export async function deleteQueue(queueId: string, projectPath: string): Promise
|
||||
export async function mergeQueues(sourceId: string, targetId: string, projectPath: string): Promise<void> {
|
||||
return fetchApi<void>(`/api/queue/merge?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceId, targetId }),
|
||||
body: JSON.stringify({ sourceQueueId: sourceId, targetQueueId: targetId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5630,3 +5683,91 @@ export async function fetchTeamStatus(
|
||||
): Promise<{ members: Array<{ member: string; lastSeen: string; lastAction: string; messageCount: number }>; total_messages: number }> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/status`);
|
||||
}
|
||||
|
||||
// ========== CLI Sessions (PTY) API ==========
|
||||
|
||||
export interface CliSession {
|
||||
sessionKey: string;
|
||||
shellKind: string;
|
||||
workingDir: string;
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCliSessionInput {
|
||||
workingDir?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
preferredShell?: 'bash' | 'pwsh';
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
}
|
||||
|
||||
export async function fetchCliSessions(): Promise<{ sessions: CliSession[] }> {
|
||||
return fetchApi<{ sessions: CliSession[] }>('/api/cli-sessions');
|
||||
}
|
||||
|
||||
export async function createCliSession(input: CreateCliSessionInput): Promise<{ success: boolean; session: CliSession }> {
|
||||
return fetchApi<{ success: boolean; session: CliSession }>('/api/cli-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCliSessionBuffer(sessionKey: string): Promise<{ session: CliSession; buffer: string }> {
|
||||
return fetchApi<{ session: CliSession; buffer: string }>(
|
||||
`/api/cli-sessions/${encodeURIComponent(sessionKey)}/buffer`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCliSessionText(
|
||||
sessionKey: string,
|
||||
input: { text: string; appendNewline?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ExecuteInCliSessionInput {
|
||||
tool: string;
|
||||
prompt: string;
|
||||
mode?: 'analysis' | 'write' | 'auto';
|
||||
model?: string;
|
||||
workingDir?: string;
|
||||
category?: 'user' | 'internal' | 'insight';
|
||||
resumeKey?: string;
|
||||
resumeStrategy?: 'nativeResume' | 'promptConcat';
|
||||
}
|
||||
|
||||
export async function executeInCliSession(
|
||||
sessionKey: string,
|
||||
input: ExecuteInCliSessionInput
|
||||
): Promise<{ success: boolean; executionId: string; command: string }> {
|
||||
return fetchApi<{ success: boolean; executionId: string; command: string }>(
|
||||
`/api/cli-sessions/${encodeURIComponent(sessionKey)}/execute`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function resizeCliSession(
|
||||
sessionKey: string,
|
||||
input: { cols: number; rows: number }
|
||||
): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/resize`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeCliSession(sessionKey: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/close`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const workspaceQueryKeys = {
|
||||
issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const,
|
||||
issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const,
|
||||
issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const,
|
||||
issueQueueHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queueHistory'] as const,
|
||||
|
||||
// ========== Discoveries ==========
|
||||
discoveries: (projectPath: string) => ['workspace', projectPath, 'discoveries'] as const,
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"overview": "Overview",
|
||||
"solutions": "Solutions",
|
||||
"history": "History",
|
||||
"terminal": "Terminal",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
@@ -112,10 +113,40 @@
|
||||
"empty": "No history yet"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"session": {
|
||||
"select": "Select session",
|
||||
"none": "No sessions",
|
||||
"refresh": "Refresh",
|
||||
"new": "New Session",
|
||||
"close": "Close"
|
||||
},
|
||||
"exec": {
|
||||
"tool": "Tool",
|
||||
"mode": "Mode",
|
||||
"resumeKey": "resumeKey",
|
||||
"resumeStrategy": "resumeStrategy",
|
||||
"prompt": {
|
||||
"label": "Prompt",
|
||||
"placeholder": "Type a prompt to execute in this session..."
|
||||
},
|
||||
"run": "Execute"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"title": "Queue",
|
||||
"pageTitle": "Issue Queue",
|
||||
"description": "Manage issue execution queue with execution groups",
|
||||
"history": {
|
||||
"title": "Queue History",
|
||||
"active": "Active",
|
||||
"select": "Select queue",
|
||||
"activate": "Activate",
|
||||
"empty": "No queues"
|
||||
},
|
||||
"exec": {
|
||||
"title": "Execute in Session"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"ready": "Ready",
|
||||
@@ -192,6 +223,7 @@
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"tasks": "Tasks",
|
||||
"terminal": "Terminal",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
@@ -313,8 +345,16 @@
|
||||
"description": "Unified management for issues, queues, and discoveries",
|
||||
"tabs": {
|
||||
"issues": "Issues",
|
||||
"board": "Board",
|
||||
"queue": "Queue",
|
||||
"discovery": "Discovery"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"pageTitle": "Issue Board",
|
||||
"description": "Visualize and manage issues in a kanban board",
|
||||
"autoStart": {
|
||||
"label": "Auto-run when moved to In Progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,12 @@
|
||||
"condition": "Condition",
|
||||
"conditionPlaceholder": "e.g., {{prev.success}} === true",
|
||||
"artifacts": "Artifacts",
|
||||
"delivery": "Delivery",
|
||||
"targetSessionKey": "Target Session",
|
||||
"targetSessionKeyPlaceholder": "e.g., cli-session-... (from Issue Terminal tab)",
|
||||
"resumeKey": "resumeKey",
|
||||
"resumeKeyPlaceholder": "e.g., issue-123 or any stable key",
|
||||
"resumeStrategy": "resumeStrategy",
|
||||
"available": "Available:",
|
||||
"variables": "Variables:",
|
||||
"artifactsLabel": "Artifacts:",
|
||||
@@ -282,6 +288,11 @@
|
||||
"modeWrite": "Write (modify files)",
|
||||
"modeMainprocess": "Main Process (blocking)",
|
||||
"modeAsync": "Async (non-blocking)"
|
||||
,
|
||||
"deliveryNewExecution": "New execution",
|
||||
"deliverySendToSession": "Send to session",
|
||||
"resumeStrategyNative": "nativeResume",
|
||||
"resumeStrategyPromptConcat": "promptConcat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"overview": "概览",
|
||||
"solutions": "解决方案",
|
||||
"history": "历史",
|
||||
"terminal": "终端",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
@@ -112,10 +113,40 @@
|
||||
"empty": "暂无历史记录"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"session": {
|
||||
"select": "选择会话",
|
||||
"none": "暂无会话",
|
||||
"refresh": "刷新",
|
||||
"new": "新建会话",
|
||||
"close": "关闭"
|
||||
},
|
||||
"exec": {
|
||||
"tool": "工具",
|
||||
"mode": "模式",
|
||||
"resumeKey": "resumeKey",
|
||||
"resumeStrategy": "resumeStrategy",
|
||||
"prompt": {
|
||||
"label": "提示词",
|
||||
"placeholder": "输入要在该会话中执行的提示词..."
|
||||
},
|
||||
"run": "执行"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"title": "队列",
|
||||
"pageTitle": "问题队列",
|
||||
"description": "管理问题执行队列和执行组",
|
||||
"history": {
|
||||
"title": "队列历史",
|
||||
"active": "当前",
|
||||
"select": "选择队列",
|
||||
"activate": "激活",
|
||||
"empty": "暂无队列"
|
||||
},
|
||||
"exec": {
|
||||
"title": "在会话中执行"
|
||||
},
|
||||
"status": {
|
||||
"pending": "待处理",
|
||||
"ready": "就绪",
|
||||
@@ -192,6 +223,7 @@
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"tasks": "任务",
|
||||
"terminal": "终端",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
@@ -313,8 +345,16 @@
|
||||
"description": "统一管理问题、队列和发现",
|
||||
"tabs": {
|
||||
"issues": "问题列表",
|
||||
"board": "看板",
|
||||
"queue": "执行队列",
|
||||
"discovery": "问题发现"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"pageTitle": "问题看板",
|
||||
"description": "以看板方式可视化管理问题",
|
||||
"autoStart": {
|
||||
"label": "拖到进行中自动执行"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,12 @@
|
||||
"condition": "条件",
|
||||
"conditionPlaceholder": "例如: {{prev.success}} === true",
|
||||
"artifacts": "产物",
|
||||
"delivery": "投递方式",
|
||||
"targetSessionKey": "目标会话",
|
||||
"targetSessionKeyPlaceholder": "例如:cli-session-...(从 Issue 终端页复制)",
|
||||
"resumeKey": "resumeKey",
|
||||
"resumeKeyPlaceholder": "例如:issue-123 或任意稳定 key",
|
||||
"resumeStrategy": "resumeStrategy",
|
||||
"available": "可用:",
|
||||
"variables": "变量:",
|
||||
"artifactsLabel": "产物:",
|
||||
@@ -281,7 +287,11 @@
|
||||
"modeAnalysis": "分析 (只读)",
|
||||
"modeWrite": "写入 (修改文件)",
|
||||
"modeMainprocess": "主进程 (阻塞)",
|
||||
"modeAsync": "异步 (非阻塞)"
|
||||
"modeAsync": "异步 (非阻塞)",
|
||||
"deliveryNewExecution": "新执行",
|
||||
"deliverySendToSession": "发送到会话",
|
||||
"resumeStrategyNative": "nativeResume",
|
||||
"resumeStrategyPromptConcat": "promptConcat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import App from './App.tsx'
|
||||
import './index.css'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import 'react-resizable/css/styles.css'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { initMessages, getInitialLocale, getMessages, type Locale } from './lib/i18n'
|
||||
import { logWebVitals } from './lib/webVitals'
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
||||
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
||||
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
||||
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
|
||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -161,6 +162,7 @@ export function IssueHubPage() {
|
||||
const renderActionButtons = () => {
|
||||
switch (currentTab) {
|
||||
case 'issues':
|
||||
case 'board':
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
|
||||
@@ -212,6 +214,7 @@ export function IssueHubPage() {
|
||||
|
||||
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
|
||||
{currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
|
||||
{currentTab === 'board' && <IssueBoardPanel />}
|
||||
{currentTab === 'queue' && <QueuePanel />}
|
||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||
|
||||
|
||||
@@ -1122,6 +1122,84 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
||||
onChange={(artifacts) => onChange({ artifacts })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CLI Session Routing (tmux-like) */}
|
||||
{!isSlashCommandMode && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.delivery' })}
|
||||
</label>
|
||||
<select
|
||||
value={(data.delivery as string) || 'newExecution'}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as 'newExecution' | 'sendToSession';
|
||||
const updates: Partial<PromptTemplateNodeData> = { delivery: next };
|
||||
if (next !== 'sendToSession') {
|
||||
updates.targetSessionKey = undefined;
|
||||
updates.resumeKey = undefined;
|
||||
updates.resumeStrategy = undefined;
|
||||
}
|
||||
onChange(updates);
|
||||
}}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="newExecution">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.options.deliveryNewExecution' })}
|
||||
</option>
|
||||
<option value="sendToSession">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.options.deliverySendToSession' })}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{((data.delivery as string) || 'newExecution') === 'sendToSession' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.targetSessionKey' })}
|
||||
</label>
|
||||
<Input
|
||||
value={(data.targetSessionKey as string) || ''}
|
||||
onChange={(e) => onChange({ targetSessionKey: e.target.value || undefined })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.targetSessionKeyPlaceholder' })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.resumeKey' })}
|
||||
</label>
|
||||
<Input
|
||||
value={(data.resumeKey as string) || ''}
|
||||
onChange={(e) => onChange({ resumeKey: e.target.value || undefined })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.resumeKeyPlaceholder' })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.resumeStrategy' })}
|
||||
</label>
|
||||
<select
|
||||
value={(data.resumeStrategy as string) || 'nativeResume'}
|
||||
onChange={(e) => onChange({ resumeStrategy: e.target.value as any })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="nativeResume">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.options.resumeStrategyNative' })}
|
||||
</option>
|
||||
<option value="promptConcat">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.options.resumeStrategyPromptConcat' })}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
132
ccw/frontend/src/stores/cliSessionStore.ts
Normal file
132
ccw/frontend/src/stores/cliSessionStore.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// ========================================
|
||||
// CLI Session Store (PTY-backed terminals)
|
||||
// ========================================
|
||||
// Zustand store for managing PTY session metadata and output chunks.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
export interface CliSessionMeta {
|
||||
sessionKey: string;
|
||||
shellKind: string;
|
||||
workingDir: string;
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CliSessionOutputChunk {
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CliSessionState {
|
||||
sessions: Record<string, CliSessionMeta>;
|
||||
outputChunks: Record<string, CliSessionOutputChunk[]>;
|
||||
outputBytes: Record<string, number>;
|
||||
|
||||
setSessions: (sessions: CliSessionMeta[]) => void;
|
||||
upsertSession: (session: CliSessionMeta) => void;
|
||||
removeSession: (sessionKey: string) => void;
|
||||
|
||||
setBuffer: (sessionKey: string, buffer: string) => void;
|
||||
appendOutput: (sessionKey: string, data: string, timestamp?: number) => void;
|
||||
clearOutput: (sessionKey: string) => void;
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_BYTES_PER_SESSION = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
function utf8ByteLength(value: string): number {
|
||||
// Browser-safe alternative to Buffer.byteLength
|
||||
return utf8Encoder.encode(value).length;
|
||||
}
|
||||
|
||||
export const useCliSessionStore = create<CliSessionState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
sessions: {},
|
||||
outputChunks: {},
|
||||
outputBytes: {},
|
||||
|
||||
setSessions: (sessions) =>
|
||||
set((state) => {
|
||||
const nextSessions: Record<string, CliSessionMeta> = {};
|
||||
for (const session of sessions) {
|
||||
nextSessions[session.sessionKey] = session;
|
||||
}
|
||||
|
||||
const keepKeys = new Set(Object.keys(nextSessions));
|
||||
const nextChunks = { ...state.outputChunks };
|
||||
const nextBytes = { ...state.outputBytes };
|
||||
for (const key of Object.keys(nextChunks)) {
|
||||
if (!keepKeys.has(key)) delete nextChunks[key];
|
||||
}
|
||||
for (const key of Object.keys(nextBytes)) {
|
||||
if (!keepKeys.has(key)) delete nextBytes[key];
|
||||
}
|
||||
|
||||
return { sessions: nextSessions, outputChunks: nextChunks, outputBytes: nextBytes };
|
||||
}),
|
||||
|
||||
upsertSession: (session) =>
|
||||
set((state) => ({
|
||||
sessions: { ...state.sessions, [session.sessionKey]: session },
|
||||
})),
|
||||
|
||||
removeSession: (sessionKey) =>
|
||||
set((state) => {
|
||||
const nextSessions = { ...state.sessions };
|
||||
const nextChunks = { ...state.outputChunks };
|
||||
const nextBytes = { ...state.outputBytes };
|
||||
delete nextSessions[sessionKey];
|
||||
delete nextChunks[sessionKey];
|
||||
delete nextBytes[sessionKey];
|
||||
return { sessions: nextSessions, outputChunks: nextChunks, outputBytes: nextBytes };
|
||||
}),
|
||||
|
||||
setBuffer: (sessionKey, buffer) =>
|
||||
set((state) => ({
|
||||
outputChunks: {
|
||||
...state.outputChunks,
|
||||
[sessionKey]: buffer ? [{ data: buffer, timestamp: Date.now() }] : [],
|
||||
},
|
||||
outputBytes: {
|
||||
...state.outputBytes,
|
||||
[sessionKey]: buffer ? utf8ByteLength(buffer) : 0,
|
||||
},
|
||||
})),
|
||||
|
||||
appendOutput: (sessionKey, data, timestamp = Date.now()) => {
|
||||
if (!data) return;
|
||||
const chunkBytes = utf8ByteLength(data);
|
||||
const { outputChunks, outputBytes } = get();
|
||||
const existingChunks = outputChunks[sessionKey] ?? [];
|
||||
const existingBytes = outputBytes[sessionKey] ?? 0;
|
||||
|
||||
const nextChunks = [...existingChunks, { data, timestamp }];
|
||||
let nextBytes = existingBytes + chunkBytes;
|
||||
|
||||
// Ring-buffer by bytes
|
||||
while (nextBytes > MAX_OUTPUT_BYTES_PER_SESSION && nextChunks.length > 0) {
|
||||
const removed = nextChunks.shift();
|
||||
if (removed) nextBytes -= utf8ByteLength(removed.data);
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
outputChunks: { ...state.outputChunks, [sessionKey]: nextChunks },
|
||||
outputBytes: { ...state.outputBytes, [sessionKey]: Math.max(0, nextBytes) },
|
||||
}));
|
||||
},
|
||||
|
||||
clearOutput: (sessionKey) =>
|
||||
set((state) => ({
|
||||
outputChunks: { ...state.outputChunks, [sessionKey]: [] },
|
||||
outputBytes: { ...state.outputBytes, [sessionKey]: 0 },
|
||||
})),
|
||||
}),
|
||||
{ name: 'cliSessionStore' }
|
||||
)
|
||||
);
|
||||
@@ -89,6 +89,28 @@ export interface PromptTemplateNodeData {
|
||||
*/
|
||||
mode?: ExecutionMode;
|
||||
|
||||
/**
|
||||
* Delivery target for CLI-mode execution.
|
||||
* - newExecution: spawn a fresh CLI execution (default)
|
||||
* - sendToSession: route to a PTY session (tmux-like send)
|
||||
*/
|
||||
delivery?: 'newExecution' | 'sendToSession';
|
||||
|
||||
/**
|
||||
* When delivery=sendToSession, route execution to this PTY session key.
|
||||
*/
|
||||
targetSessionKey?: string;
|
||||
|
||||
/**
|
||||
* Optional logical resume key for chaining executions.
|
||||
*/
|
||||
resumeKey?: string;
|
||||
|
||||
/**
|
||||
* Optional resume mapping strategy.
|
||||
*/
|
||||
resumeStrategy?: 'nativeResume' | 'promptConcat';
|
||||
|
||||
/**
|
||||
* References to outputs from previous steps
|
||||
* Use the outputName values from earlier nodes
|
||||
|
||||
153
ccw/src/core/routes/cli-sessions-routes.ts
Normal file
153
ccw/src/core/routes/cli-sessions-routes.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CLI Sessions (PTY) Routes Module
|
||||
* Independent from existing /api/cli/* execution endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/cli-sessions
|
||||
* - POST /api/cli-sessions
|
||||
* - GET /api/cli-sessions/:sessionKey/buffer
|
||||
* - POST /api/cli-sessions/:sessionKey/send
|
||||
* - POST /api/cli-sessions/:sessionKey/execute
|
||||
* - POST /api/cli-sessions/:sessionKey/resize
|
||||
* - POST /api/cli-sessions/:sessionKey/close
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import { getCliSessionManager } from '../services/cli-session-manager.js';
|
||||
|
||||
export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, handlePostRequest, initialPath } = ctx;
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
|
||||
// GET /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sessions: manager.listSessions() }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const {
|
||||
workingDir,
|
||||
cols,
|
||||
rows,
|
||||
preferredShell,
|
||||
tool,
|
||||
model,
|
||||
resumeKey
|
||||
} = (body || {}) as any;
|
||||
|
||||
const session = manager.createSession({
|
||||
workingDir: workingDir || initialPath,
|
||||
cols: typeof cols === 'number' ? cols : undefined,
|
||||
rows: typeof rows === 'number' ? rows : undefined,
|
||||
preferredShell: preferredShell === 'pwsh' ? 'pwsh' : 'bash',
|
||||
tool,
|
||||
model,
|
||||
resumeKey
|
||||
});
|
||||
|
||||
return { success: true, session };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/cli-sessions/:sessionKey/buffer
|
||||
const bufferMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/buffer$/);
|
||||
if (bufferMatch && req.method === 'GET') {
|
||||
const sessionKey = decodeURIComponent(bufferMatch[1]);
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session not found' }));
|
||||
return true;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ session, buffer: manager.getBuffer(sessionKey) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/send
|
||||
const sendMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/send$/);
|
||||
if (sendMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(sendMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { text, appendNewline } = (body || {}) as any;
|
||||
if (typeof text !== 'string') {
|
||||
return { error: 'text is required', status: 400 };
|
||||
}
|
||||
manager.sendText(sessionKey, text, appendNewline !== false);
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/execute
|
||||
const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/);
|
||||
if (executeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(executeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const {
|
||||
tool,
|
||||
prompt,
|
||||
mode,
|
||||
model,
|
||||
workingDir,
|
||||
category,
|
||||
resumeKey,
|
||||
resumeStrategy
|
||||
} = (body || {}) as any;
|
||||
|
||||
if (!tool || typeof tool !== 'string') {
|
||||
return { error: 'tool is required', status: 400 };
|
||||
}
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return { error: 'prompt is required', status: 400 };
|
||||
}
|
||||
|
||||
const result = manager.execute(sessionKey, {
|
||||
tool,
|
||||
prompt,
|
||||
mode,
|
||||
model,
|
||||
workingDir,
|
||||
category,
|
||||
resumeKey,
|
||||
resumeStrategy: resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
|
||||
});
|
||||
|
||||
return { success: true, ...result };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/resize
|
||||
const resizeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/resize$/);
|
||||
if (resizeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(resizeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { cols, rows } = (body || {}) as any;
|
||||
if (typeof cols !== 'number' || typeof rows !== 'number') {
|
||||
return { error: 'cols and rows are required', status: 400 };
|
||||
}
|
||||
manager.resize(sessionKey, cols, rows);
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/close
|
||||
const closeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/close$/);
|
||||
if (closeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(closeMatch[1]);
|
||||
manager.close(sessionKey);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// GET /api/queue/:id or /api/issues/queue/:id - Get specific queue by ID
|
||||
const queueDetailMatch = normalizedPath?.match(/^\/api\/queue\/([^/]+)$/);
|
||||
const reservedQueuePaths = ['history', 'reorder', 'switch', 'deactivate', 'merge', 'activate'];
|
||||
const reservedQueuePaths = ['history', 'reorder', 'move', 'switch', 'deactivate', 'merge', 'activate'];
|
||||
if (queueDetailMatch && req.method === 'GET' && !reservedQueuePaths.includes(queueDetailMatch[1])) {
|
||||
const queueId = queueDetailMatch[1];
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
@@ -592,6 +592,89 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/move - Move an item to a different execution_group (and optionally insert at index)
|
||||
if (normalizedPath === '/api/queue/move' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { itemId, toGroupId, toIndex } = body;
|
||||
if (!itemId || !toGroupId) {
|
||||
return { error: 'itemId and toGroupId required' };
|
||||
}
|
||||
|
||||
const queue = readQueue(issuesDir);
|
||||
const items = getQueueItems(queue);
|
||||
const isSolutionBased = isSolutionBasedQueue(queue);
|
||||
|
||||
const itemIndex = items.findIndex((i: any) => i.item_id === itemId);
|
||||
if (itemIndex === -1) return { error: `Item ${itemId} not found` };
|
||||
|
||||
const moved = { ...items[itemIndex] };
|
||||
const fromGroupId = moved.execution_group || 'ungrouped';
|
||||
|
||||
// Build per-group ordered lists based on current execution_order
|
||||
const groupToIds = new Map<string, string[]>();
|
||||
const sorted = [...items].sort((a: any, b: any) => (a.execution_order || 0) - (b.execution_order || 0));
|
||||
for (const it of sorted) {
|
||||
const gid = it.execution_group || 'ungrouped';
|
||||
if (!groupToIds.has(gid)) groupToIds.set(gid, []);
|
||||
groupToIds.get(gid)!.push(it.item_id);
|
||||
}
|
||||
|
||||
// Remove from old group
|
||||
const fromList = groupToIds.get(fromGroupId) || [];
|
||||
groupToIds.set(fromGroupId, fromList.filter((id) => id !== itemId));
|
||||
|
||||
// Insert into target group
|
||||
const targetList = groupToIds.get(toGroupId) || [];
|
||||
const insertAt = typeof toIndex === 'number' ? Math.max(0, Math.min(targetList.length, toIndex)) : targetList.length;
|
||||
const nextTarget = [...targetList];
|
||||
nextTarget.splice(insertAt, 0, itemId);
|
||||
groupToIds.set(toGroupId, nextTarget);
|
||||
|
||||
moved.execution_group = toGroupId;
|
||||
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_id, i]));
|
||||
itemMap.set(itemId, moved);
|
||||
|
||||
const groupIds = Array.from(groupToIds.keys());
|
||||
groupIds.sort((a, b) => {
|
||||
const aGroup = parseInt(a.match(/\\d+/)?.[0] || '999');
|
||||
const bGroup = parseInt(b.match(/\\d+/)?.[0] || '999');
|
||||
if (aGroup !== bGroup) return aGroup - bGroup;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const nextItems: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const gid of groupIds) {
|
||||
const ids = groupToIds.get(gid) || [];
|
||||
for (const id of ids) {
|
||||
const it = itemMap.get(id);
|
||||
if (!it) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
nextItems.push(it);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: append any missing items
|
||||
for (const it of items) {
|
||||
if (!seen.has(it.item_id)) nextItems.push(it);
|
||||
}
|
||||
|
||||
nextItems.forEach((it, idx) => { it.execution_order = idx + 1; });
|
||||
|
||||
if (isSolutionBased) {
|
||||
queue.solutions = nextItems;
|
||||
} else {
|
||||
queue.tasks = nextItems;
|
||||
}
|
||||
writeQueue(issuesDir, queue);
|
||||
|
||||
return { success: true, itemId, fromGroupId, toGroupId };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/queue/:queueId/item/:itemId or /api/issues/queue/:queueId/item/:itemId
|
||||
const queueItemDeleteMatch = normalizedPath?.match(/^\/api\/queue\/([^/]+)\/item\/([^/]+)$/);
|
||||
if (queueItemDeleteMatch && req.method === 'DELETE') {
|
||||
|
||||
@@ -104,6 +104,28 @@ export interface PromptTemplateNodeData {
|
||||
*/
|
||||
mode?: ExecutionMode;
|
||||
|
||||
/**
|
||||
* Delivery target for CLI-mode execution.
|
||||
* - newExecution: spawn a fresh CLI execution (default)
|
||||
* - sendToSession: route to a PTY session (tmux-like send)
|
||||
*/
|
||||
delivery?: 'newExecution' | 'sendToSession';
|
||||
|
||||
/**
|
||||
* When delivery=sendToSession, route execution to this PTY session key.
|
||||
*/
|
||||
targetSessionKey?: string;
|
||||
|
||||
/**
|
||||
* Optional logical resume key for chaining executions.
|
||||
*/
|
||||
resumeKey?: string;
|
||||
|
||||
/**
|
||||
* Optional resume mapping strategy.
|
||||
*/
|
||||
resumeStrategy?: 'nativeResume' | 'promptConcat';
|
||||
|
||||
/**
|
||||
* References to outputs from previous steps
|
||||
* Use the outputName values from earlier nodes
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p
|
||||
import { handleStatusRoutes } from './routes/status-routes.js';
|
||||
import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js';
|
||||
import { handleCliSettingsRoutes } from './routes/cli-settings-routes.js';
|
||||
import { handleCliSessionsRoutes } from './routes/cli-sessions-routes.js';
|
||||
import { handleProviderRoutes } from './routes/provider-routes.js';
|
||||
import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
|
||||
@@ -591,6 +592,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleDashboardRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CLI sessions (PTY) routes (/api/cli-sessions/*) - independent from /api/cli/*
|
||||
if (pathname.startsWith('/api/cli-sessions')) {
|
||||
if (await handleCliSessionsRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CLI routes (/api/cli/*)
|
||||
if (pathname.startsWith('/api/cli/')) {
|
||||
// CLI Settings routes first (more specific path /api/cli/settings/*)
|
||||
|
||||
110
ccw/src/core/services/cli-session-command-builder.ts
Normal file
110
ccw/src/core/services/cli-session-command-builder.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import path from 'path';
|
||||
|
||||
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh';
|
||||
|
||||
export type CliSessionResumeStrategy = 'nativeResume' | 'promptConcat';
|
||||
|
||||
export interface CliSessionExecuteCommandInput {
|
||||
projectRoot: string;
|
||||
shellKind: CliSessionShellKind;
|
||||
tool: string;
|
||||
prompt: string;
|
||||
mode?: 'analysis' | 'write' | 'auto';
|
||||
model?: string;
|
||||
workingDir?: string;
|
||||
category?: 'user' | 'internal' | 'insight';
|
||||
resumeStrategy?: CliSessionResumeStrategy;
|
||||
prevExecutionId?: string;
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export interface CliSessionExecuteCommandOutput {
|
||||
command: string;
|
||||
}
|
||||
|
||||
function toPosixPath(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function toWslPath(winPath: string): string {
|
||||
const normalized = winPath.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
|
||||
if (!driveMatch) return normalized;
|
||||
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
||||
}
|
||||
|
||||
function escapeArg(value: string): string {
|
||||
// Minimal quoting that works in pwsh + bash.
|
||||
// We intentionally avoid escaping with platform-specific rules; values are expected to be simple (paths/tool/model).
|
||||
if (!value) return '""';
|
||||
if (/[\s"]/g.test(value)) {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildCliSessionExecuteCommand(input: CliSessionExecuteCommandInput): CliSessionExecuteCommandOutput {
|
||||
const {
|
||||
projectRoot,
|
||||
shellKind,
|
||||
tool,
|
||||
prompt,
|
||||
mode = 'analysis',
|
||||
model,
|
||||
workingDir,
|
||||
category = 'user',
|
||||
resumeStrategy = 'nativeResume',
|
||||
prevExecutionId,
|
||||
executionId
|
||||
} = input;
|
||||
|
||||
const nodeExe = shellKind === 'wsl-bash' ? 'node.exe' : 'node';
|
||||
|
||||
const ccwScriptWin = path.join(projectRoot, 'ccw', 'bin', 'ccw.js');
|
||||
const ccwScriptPosix = toPosixPath(ccwScriptWin);
|
||||
const ccwScriptWsl = toWslPath(ccwScriptPosix);
|
||||
|
||||
// In WSL we prefer running the Windows Node (`node.exe`) for compatibility
|
||||
// (no dependency on Node being installed inside the Linux distro). However,
|
||||
// Windows executables do not reliably understand `/mnt/*` paths, so we convert
|
||||
// to Windows paths at runtime via `wslpath -w`.
|
||||
const wslPreambleParts: string[] = [];
|
||||
if (shellKind === 'wsl-bash') {
|
||||
wslPreambleParts.push(`CCW_WIN=$(wslpath -w ${escapeArg(ccwScriptWsl)})`);
|
||||
if (workingDir) {
|
||||
const wdWsl = toWslPath(toPosixPath(workingDir));
|
||||
wslPreambleParts.push(`WD_WIN=$(wslpath -w ${escapeArg(wdWsl)})`);
|
||||
}
|
||||
}
|
||||
const wslPreamble = wslPreambleParts.length > 0 ? `${wslPreambleParts.join('; ')}; ` : '';
|
||||
|
||||
const cdArg =
|
||||
workingDir
|
||||
? shellKind === 'wsl-bash'
|
||||
? ' --cd "$WD_WIN"'
|
||||
: ` --cd ${escapeArg(toPosixPath(workingDir))}`
|
||||
: '';
|
||||
const modelArg = model ? ` --model ${escapeArg(model)}` : '';
|
||||
const resumeArg = prevExecutionId ? ` --resume ${escapeArg(prevExecutionId)}` : '';
|
||||
const noNativeArg = resumeStrategy === 'promptConcat' ? ' --no-native' : '';
|
||||
|
||||
// Pipe prompt through stdin so multi-line works without shell-dependent quoting.
|
||||
// Base64 avoids escaping issues; decode is performed by node itself.
|
||||
const promptB64 = Buffer.from(prompt, 'utf8').toString('base64');
|
||||
const decodeCmd = `${nodeExe} -e "process.stdout.write(Buffer.from('${promptB64}','base64'))"`;
|
||||
|
||||
const ccwTarget = shellKind === 'wsl-bash' ? '"$CCW_WIN"' : escapeArg(ccwScriptPosix);
|
||||
const ccwCmd =
|
||||
`${nodeExe} ${ccwTarget} cli` +
|
||||
` --tool ${escapeArg(tool)}` +
|
||||
` --mode ${escapeArg(mode)}` +
|
||||
`${modelArg}` +
|
||||
`${cdArg}` +
|
||||
` --category ${escapeArg(category)}` +
|
||||
` --stream` +
|
||||
` --id ${escapeArg(executionId)}` +
|
||||
`${resumeArg}` +
|
||||
`${noNativeArg}`;
|
||||
|
||||
return { command: `${wslPreamble}${decodeCmd} | ${ccwCmd}` };
|
||||
}
|
||||
380
ccw/src/core/services/cli-session-manager.ts
Normal file
380
ccw/src/core/services/cli-session-manager.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { existsSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as nodePty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import { broadcastToClients } from '../websocket.js';
|
||||
import {
|
||||
buildCliSessionExecuteCommand,
|
||||
type CliSessionShellKind,
|
||||
type CliSessionResumeStrategy
|
||||
} from './cli-session-command-builder.js';
|
||||
import { getCliSessionPolicy } from './cli-session-policy.js';
|
||||
|
||||
export interface CliSession {
|
||||
sessionKey: string;
|
||||
shellKind: CliSessionShellKind;
|
||||
workingDir: string;
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCliSessionOptions {
|
||||
workingDir: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
preferredShell?: 'bash' | 'pwsh';
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
}
|
||||
|
||||
export interface ExecuteInCliSessionOptions {
|
||||
tool: string;
|
||||
prompt: string;
|
||||
mode?: 'analysis' | 'write' | 'auto';
|
||||
model?: string;
|
||||
workingDir?: string;
|
||||
category?: 'user' | 'internal' | 'insight';
|
||||
resumeKey?: string;
|
||||
resumeStrategy?: CliSessionResumeStrategy;
|
||||
}
|
||||
|
||||
export interface CliSessionOutputEvent {
|
||||
sessionKey: string;
|
||||
data: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CliSessionInternal extends CliSession {
|
||||
pty: nodePty.IPty;
|
||||
buffer: string[];
|
||||
bufferBytes: number;
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createSessionKey(): string {
|
||||
const suffix = randomBytes(4).toString('hex');
|
||||
return `cli-session-${Date.now()}-${suffix}`;
|
||||
}
|
||||
|
||||
function normalizeWorkingDir(workingDir: string): string {
|
||||
return path.resolve(workingDir);
|
||||
}
|
||||
|
||||
function findGitBashExe(): string | null {
|
||||
const candidates = [
|
||||
'C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe',
|
||||
'C:\\\\Program Files\\\\Git\\\\usr\\\\bin\\\\bash.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Git\\\\bin\\\\bash.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Git\\\\usr\\\\bin\\\\bash.exe'
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
try {
|
||||
const where = spawnSync('where', ['bash'], { encoding: 'utf8', windowsHide: true });
|
||||
if (where.status === 0) {
|
||||
const lines = (where.stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
const gitBash = lines.find(l => /\\Git\\.*\\bash\.exe$/i.test(l));
|
||||
return gitBash || (lines[0] || null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWslAvailable(): boolean {
|
||||
try {
|
||||
const probe = spawnSync('wsl.exe', ['-e', 'bash', '-lc', 'echo ok'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 1500
|
||||
});
|
||||
return probe.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickShell(preferred: 'bash' | 'pwsh'): { shellKind: CliSessionShellKind; file: string; args: string[] } {
|
||||
if (os.platform() === 'win32') {
|
||||
if (preferred === 'bash') {
|
||||
if (isWslAvailable()) {
|
||||
return { shellKind: 'wsl-bash', file: 'wsl.exe', args: ['-e', 'bash', '-l', '-i'] };
|
||||
}
|
||||
const gitBash = findGitBashExe();
|
||||
if (gitBash) {
|
||||
return { shellKind: 'git-bash', file: gitBash, args: ['-l', '-i'] };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: PowerShell (pwsh preferred, windows powershell as final)
|
||||
const pwsh = spawnSync('where', ['pwsh'], { encoding: 'utf8', windowsHide: true });
|
||||
if (pwsh.status === 0) {
|
||||
return { shellKind: 'pwsh', file: 'pwsh', args: ['-NoLogo'] };
|
||||
}
|
||||
return { shellKind: 'pwsh', file: 'powershell', args: ['-NoLogo'] };
|
||||
}
|
||||
|
||||
// Non-Windows: keep it simple (bash-first)
|
||||
if (preferred === 'pwsh') {
|
||||
return { shellKind: 'pwsh', file: 'pwsh', args: ['-NoLogo'] };
|
||||
}
|
||||
return { shellKind: 'git-bash', file: 'bash', args: ['-l', '-i'] };
|
||||
}
|
||||
|
||||
function toWslPath(winPath: string): string {
|
||||
const normalized = winPath.replace(/\\/g, '/');
|
||||
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
|
||||
if (!driveMatch) return normalized;
|
||||
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
||||
}
|
||||
|
||||
export class CliSessionManager {
|
||||
private sessions = new Map<string, CliSessionInternal>();
|
||||
private resumeKeyLastExecution = new Map<string, string>();
|
||||
private projectRoot: string;
|
||||
private emitter = new EventEmitter();
|
||||
private maxBufferBytes: number;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.maxBufferBytes = getCliSessionPolicy().maxBufferBytes;
|
||||
}
|
||||
|
||||
listSessions(): CliSession[] {
|
||||
return Array.from(this.sessions.values()).map(({ pty: _pty, buffer: _buffer, bufferBytes: _bytes, ...rest }) => rest);
|
||||
}
|
||||
|
||||
getSession(sessionKey: string): CliSession | null {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return null;
|
||||
const { pty: _pty, buffer: _buffer, bufferBytes: _bytes, ...rest } = session;
|
||||
return rest;
|
||||
}
|
||||
|
||||
getBuffer(sessionKey: string): string {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return '';
|
||||
return session.buffer.join('');
|
||||
}
|
||||
|
||||
createSession(options: CreateCliSessionOptions): CliSession {
|
||||
const workingDir = normalizeWorkingDir(options.workingDir);
|
||||
const preferredShell = options.preferredShell ?? 'bash';
|
||||
const { shellKind, file, args } = pickShell(preferredShell);
|
||||
|
||||
const sessionKey = createSessionKey();
|
||||
const createdAt = nowIso();
|
||||
|
||||
const pty = nodePty.spawn(file, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: options.cols ?? 120,
|
||||
rows: options.rows ?? 30,
|
||||
cwd: workingDir,
|
||||
env: process.env as Record<string, string>
|
||||
});
|
||||
|
||||
const session: CliSessionInternal = {
|
||||
sessionKey,
|
||||
shellKind,
|
||||
workingDir,
|
||||
tool: options.tool,
|
||||
model: options.model,
|
||||
resumeKey: options.resumeKey,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
pty,
|
||||
buffer: [],
|
||||
bufferBytes: 0,
|
||||
lastActivityAt: Date.now(),
|
||||
};
|
||||
|
||||
pty.onData((data) => {
|
||||
this.appendToBuffer(sessionKey, data);
|
||||
const now = Date.now();
|
||||
const s = this.sessions.get(sessionKey);
|
||||
if (s) {
|
||||
s.updatedAt = nowIso();
|
||||
s.lastActivityAt = now;
|
||||
}
|
||||
|
||||
this.emitter.emit('output', {
|
||||
sessionKey,
|
||||
data,
|
||||
timestamp: nowIso(),
|
||||
} satisfies CliSessionOutputEvent);
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_OUTPUT',
|
||||
payload: {
|
||||
sessionKey,
|
||||
data,
|
||||
timestamp: nowIso()
|
||||
} satisfies CliSessionOutputEvent
|
||||
});
|
||||
});
|
||||
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
this.sessions.delete(sessionKey);
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_CLOSED',
|
||||
payload: {
|
||||
sessionKey,
|
||||
exitCode,
|
||||
signal,
|
||||
timestamp: nowIso()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.sessions.set(sessionKey, session);
|
||||
|
||||
// WSL often ignores Windows cwd; best-effort cd to mounted path.
|
||||
if (shellKind === 'wsl-bash') {
|
||||
const wslCwd = toWslPath(workingDir.replace(/\\/g, '/'));
|
||||
this.sendText(sessionKey, `cd ${wslCwd}`, true);
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_CREATED',
|
||||
payload: { session: this.getSession(sessionKey), timestamp: nowIso() }
|
||||
});
|
||||
|
||||
return this.getSession(sessionKey)!;
|
||||
}
|
||||
|
||||
sendText(sessionKey: string, text: string, appendNewline: boolean): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
session.pty.write(text);
|
||||
if (appendNewline) {
|
||||
session.pty.write('\r');
|
||||
}
|
||||
}
|
||||
|
||||
resize(sessionKey: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
session.pty.resize(cols, rows);
|
||||
}
|
||||
|
||||
close(sessionKey: string): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return;
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
try {
|
||||
session.pty.kill();
|
||||
} finally {
|
||||
this.sessions.delete(sessionKey);
|
||||
broadcastToClients({ type: 'CLI_SESSION_CLOSED', payload: { sessionKey, timestamp: nowIso() } });
|
||||
}
|
||||
}
|
||||
|
||||
execute(sessionKey: string, options: ExecuteInCliSessionOptions): { executionId: string; command: string } {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
|
||||
const resumeKey = options.resumeKey ?? session.resumeKey;
|
||||
const resumeMapKey = resumeKey ? `${options.tool}:${resumeKey}` : null;
|
||||
const prevExecutionId = resumeMapKey ? this.resumeKeyLastExecution.get(resumeMapKey) : undefined;
|
||||
|
||||
const executionId = resumeKey
|
||||
? `${resumeKey}-${Date.now()}`
|
||||
: `exec-${Date.now()}-${randomBytes(3).toString('hex')}`;
|
||||
|
||||
const { command } = buildCliSessionExecuteCommand({
|
||||
projectRoot: this.projectRoot,
|
||||
shellKind: session.shellKind,
|
||||
tool: options.tool,
|
||||
prompt: options.prompt,
|
||||
mode: options.mode,
|
||||
model: options.model,
|
||||
workingDir: options.workingDir ?? session.workingDir,
|
||||
category: options.category,
|
||||
resumeStrategy: options.resumeStrategy,
|
||||
prevExecutionId,
|
||||
executionId
|
||||
});
|
||||
|
||||
// Best-effort: preemptively update mapping so subsequent queue items can chain.
|
||||
if (resumeMapKey) {
|
||||
this.resumeKeyLastExecution.set(resumeMapKey, executionId);
|
||||
}
|
||||
|
||||
this.sendText(sessionKey, command, true);
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_EXECUTE',
|
||||
payload: { sessionKey, executionId, command, timestamp: nowIso() }
|
||||
});
|
||||
|
||||
return { executionId, command };
|
||||
}
|
||||
|
||||
private appendToBuffer(sessionKey: string, chunk: string): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return;
|
||||
|
||||
session.buffer.push(chunk);
|
||||
session.bufferBytes += Buffer.byteLength(chunk, 'utf8');
|
||||
|
||||
while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 0) {
|
||||
const removed = session.buffer.shift();
|
||||
if (removed) session.bufferBytes -= Buffer.byteLength(removed, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
onOutput(listener: (event: CliSessionOutputEvent) => void): () => void {
|
||||
const handler = (event: CliSessionOutputEvent) => listener(event);
|
||||
this.emitter.on('output', handler);
|
||||
return () => this.emitter.off('output', handler);
|
||||
}
|
||||
|
||||
closeIdleSessions(idleTimeoutMs: number): number {
|
||||
if (idleTimeoutMs <= 0) return 0;
|
||||
const now = Date.now();
|
||||
let closed = 0;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (now - s.lastActivityAt >= idleTimeoutMs) {
|
||||
this.close(s.sessionKey);
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
|
||||
const managersByRoot = new Map<string, CliSessionManager>();
|
||||
|
||||
export function getCliSessionManager(projectRoot: string = process.cwd()): CliSessionManager {
|
||||
const resolved = path.resolve(projectRoot);
|
||||
const existing = managersByRoot.get(resolved);
|
||||
if (existing) return existing;
|
||||
const created = new CliSessionManager(resolved);
|
||||
managersByRoot.set(resolved, created);
|
||||
return created;
|
||||
}
|
||||
55
ccw/src/core/services/cli-session-policy.ts
Normal file
55
ccw/src/core/services/cli-session-policy.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import os from 'os';
|
||||
|
||||
export interface CliSessionPolicy {
|
||||
allowedTools: string[];
|
||||
maxSessions: number;
|
||||
idleTimeoutMs: number;
|
||||
allowWorkingDirOutsideProject: boolean;
|
||||
maxBufferBytes: number;
|
||||
rateLimit: {
|
||||
createPerMinute: number;
|
||||
executePerMinute: number;
|
||||
sendBytesPerMinute: number;
|
||||
resizePerMinute: number;
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntEnv(name: string, fallback: number): number {
|
||||
const raw = (process.env[name] ?? '').trim();
|
||||
if (!raw) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function parseBoolEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = (process.env[name] ?? '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y') return true;
|
||||
if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'n') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getCliSessionPolicy(): CliSessionPolicy {
|
||||
const defaultAllowedTools = ['claude', 'codex', 'gemini', 'qwen', 'opencode'];
|
||||
const allowedToolsRaw = (process.env.CCW_CLI_SESSIONS_ALLOWED_TOOLS ?? '').trim();
|
||||
const allowedTools = allowedToolsRaw
|
||||
? allowedToolsRaw.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: defaultAllowedTools;
|
||||
|
||||
const maxSessionsDefault = os.platform() === 'win32' ? 6 : 8;
|
||||
|
||||
return {
|
||||
allowedTools,
|
||||
maxSessions: parseIntEnv('CCW_CLI_SESSIONS_MAX', maxSessionsDefault),
|
||||
idleTimeoutMs: parseIntEnv('CCW_CLI_SESSIONS_IDLE_TIMEOUT_MS', 30 * 60_000),
|
||||
allowWorkingDirOutsideProject: parseBoolEnv('CCW_CLI_SESSIONS_ALLOW_OUTSIDE_PROJECT', false),
|
||||
maxBufferBytes: parseIntEnv('CCW_CLI_SESSIONS_MAX_BUFFER_BYTES', 2 * 1024 * 1024),
|
||||
rateLimit: {
|
||||
createPerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_CREATE_PER_MIN', 12),
|
||||
executePerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_EXECUTE_PER_MIN', 60),
|
||||
sendBytesPerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_SEND_BYTES_PER_MIN', 256 * 1024),
|
||||
resizePerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_RESIZE_PER_MIN', 120),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { broadcastToClients } from '../websocket.js';
|
||||
import { executeCliTool } from '../../tools/cli-executor-core.js';
|
||||
import { getCliSessionManager } from './cli-session-manager.js';
|
||||
import type {
|
||||
Flow,
|
||||
FlowNode,
|
||||
@@ -244,6 +245,44 @@ export class NodeRunner {
|
||||
const mode = this.determineCliMode(data.mode);
|
||||
|
||||
try {
|
||||
// Optional: route execution to a PTY session (tmux-like send)
|
||||
if (data.delivery === 'sendToSession') {
|
||||
const targetSessionKey = data.targetSessionKey;
|
||||
if (!targetSessionKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'delivery=sendToSession requires targetSessionKey'
|
||||
};
|
||||
}
|
||||
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
const routed = manager.execute(targetSessionKey, {
|
||||
tool,
|
||||
prompt: instruction,
|
||||
mode,
|
||||
workingDir: this.context.workingDir,
|
||||
resumeKey: data.resumeKey,
|
||||
resumeStrategy: data.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
|
||||
});
|
||||
|
||||
const outputKey = data.outputName || `${node.id}_output`;
|
||||
this.context.variables[outputKey] = {
|
||||
delivery: 'sendToSession',
|
||||
sessionKey: targetSessionKey,
|
||||
executionId: routed.executionId,
|
||||
command: routed.command
|
||||
};
|
||||
this.context.variables[`${node.id}_executionId`] = routed.executionId;
|
||||
this.context.variables[`${node.id}_command`] = routed.command;
|
||||
this.context.variables[`${node.id}_success`] = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: routed.command,
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Execute via CLI tool
|
||||
const result = await executeCliTool({
|
||||
tool,
|
||||
|
||||
49
ccw/src/core/services/rate-limiter.ts
Normal file
49
ccw/src/core/services/rate-limiter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
interface BucketState {
|
||||
tokens: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple fixed-window token bucket (in-memory).
|
||||
* Good enough for local dashboard usage; not suitable for multi-process deployments.
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private buckets = new Map<string, BucketState>();
|
||||
private limit: number;
|
||||
private windowMs: number;
|
||||
|
||||
constructor(opts: { limit: number; windowMs: number }) {
|
||||
this.limit = Math.max(0, opts.limit);
|
||||
this.windowMs = Math.max(1, opts.windowMs);
|
||||
}
|
||||
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const safeCost = Math.max(0, Math.floor(cost));
|
||||
const existing = this.buckets.get(key);
|
||||
|
||||
if (!existing || now >= existing.resetAt) {
|
||||
const resetAt = now + this.windowMs;
|
||||
const nextTokens = this.limit - safeCost;
|
||||
const ok = nextTokens >= 0;
|
||||
const tokens = ok ? nextTokens : this.limit;
|
||||
this.buckets.set(key, { tokens, resetAt });
|
||||
return { ok, remaining: Math.max(0, ok ? tokens : 0), resetAt };
|
||||
}
|
||||
|
||||
const nextTokens = existing.tokens - safeCost;
|
||||
if (nextTokens < 0) {
|
||||
return { ok: false, remaining: Math.max(0, existing.tokens), resetAt: existing.resetAt };
|
||||
}
|
||||
|
||||
existing.tokens = nextTokens;
|
||||
return { ok: true, remaining: nextTokens, resetAt: existing.resetAt };
|
||||
}
|
||||
}
|
||||
|
||||
62
ccw/tests/cli-session-command-builder.test.js
Normal file
62
ccw/tests/cli-session-command-builder.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Unit tests for PTY session execute command builder
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const builderUrl = new URL('../dist/core/services/cli-session-command-builder.js', import.meta.url).href;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mod;
|
||||
|
||||
describe('buildCliSessionExecuteCommand', async () => {
|
||||
mod = await import(builderUrl);
|
||||
|
||||
it('builds a node-piped command with resume + promptConcat', () => {
|
||||
const { command } = mod.buildCliSessionExecuteCommand({
|
||||
projectRoot: 'D:\\\\Claude_dms3',
|
||||
shellKind: 'pwsh',
|
||||
tool: 'codex',
|
||||
prompt: 'line1\nline2',
|
||||
mode: 'write',
|
||||
workingDir: 'D:\\\\Claude_dms3',
|
||||
resumeStrategy: 'promptConcat',
|
||||
prevExecutionId: 'prev-123',
|
||||
executionId: 'rk-1'
|
||||
});
|
||||
|
||||
assert.match(command, /^node -e "process\.stdout\.write\(Buffer\.from\('/);
|
||||
assert.match(command, /\| node /);
|
||||
assert.match(command, / cli\b/);
|
||||
assert.match(command, / --tool codex\b/);
|
||||
assert.match(command, / --mode write\b/);
|
||||
assert.match(command, / --stream\b/);
|
||||
assert.match(command, / --id rk-1\b/);
|
||||
assert.match(command, / --resume prev-123\b/);
|
||||
assert.match(command, / --no-native\b/);
|
||||
});
|
||||
|
||||
it('uses wslpath to pass Windows paths to node.exe in wsl-bash', () => {
|
||||
const { command } = mod.buildCliSessionExecuteCommand({
|
||||
projectRoot: 'D:\\\\Claude_dms3',
|
||||
shellKind: 'wsl-bash',
|
||||
tool: 'claude',
|
||||
prompt: 'hello',
|
||||
mode: 'analysis',
|
||||
workingDir: 'D:\\\\Claude_dms3',
|
||||
resumeStrategy: 'nativeResume',
|
||||
executionId: 'exec-1'
|
||||
});
|
||||
|
||||
assert.match(command, /^CCW_WIN=\$\(\s*wslpath -w /);
|
||||
assert.match(command, /WD_WIN=\$\(\s*wslpath -w /);
|
||||
assert.match(command, /node\.exe -e /);
|
||||
assert.match(command, /\| node\.exe "\$CCW_WIN" cli/);
|
||||
assert.match(command, / --cd "\$WD_WIN"/);
|
||||
assert.match(command, / --tool claude\b/);
|
||||
assert.match(command, / --mode analysis\b/);
|
||||
assert.match(command, / --id exec-1\b/);
|
||||
assert.ok(!command.includes('--no-native'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user