diff --git a/.planning/.active_plan b/.planning/.active_plan index 5f7cd2a..acae273 100644 --- a/.planning/.active_plan +++ b/.planning/.active_plan @@ -1 +1 @@ -file-preview-encoding +2026-08-11-help diff --git a/.planning/2026-08-11-ccweb-list-children-scope/findings.md b/.planning/2026-08-11-ccweb-list-children-scope/findings.md new file mode 100644 index 0000000..db094a6 --- /dev/null +++ b/.planning/2026-08-11-ccweb-list-children-scope/findings.md @@ -0,0 +1,73 @@ +# 发现与决策:ccweb_list_conversations children scope + +## 需求 + +- 给 `ccweb_list_conversations` 增加 `scope: "children"`。 +- 同步更新该 MCP 的提示词/描述。 +- 完成实现、回归覆盖和验证。 + +## 已知代码事实 + +- `listConversationSummaries(args, sourceSessionId)` 当前遍历 `SESSIONS_DIR` 下全部 JSON,只按 agent/status 过滤,最后排序并应用 limit。 +- MCP 创建的子会话在自身 JSON 中持久化 `createdFrom.kind = "mcp"` 和 `createdFrom.sourceSessionId`。 +- `loadSessionMetaFromFile` 当前只暴露 `createdFromKind`,没有暴露来源会话 ID;实现 children 过滤需扩展元数据或读取完整会话。 +- `ccweb_list_conversations` 有两处 schema/描述:`lib/ccweb-mcp-server.js` 与 `server.js` 的 Codex App 动态工具兼容定义。 +- README 的 MCP 表格需要同步新语义。 +- 当前工作树已有其他任务修改 `server.js`、`lib/ccweb-mcp-server.js`、`scripts/regression.js`,补丁必须基于当前内容最小追加。 +- 本地 `git diff -G` 未发现既有未提交改动触碰 `ccweb_list_conversations`、`listConversationSummaries`、`loadSessionMetaFromFile` 或 `createdFromKind` 相关行;目标区域可做局部补丁。 +- 当前回归先在 `scripts/regression.js` 约 6021 行测试全量列表,MCP 创建及 `createdFrom` 断言位于约 6139 行后,适合在创建完成后追加 children scope 场景。 +- `createdFrom` 已包含在大文件头尾 preview 的顶层字段集合中,因此只需从现有对象提取 `sourceSessionId`,无需为 children scope 全量读取大 JSON。 +- `loadSessionMetaFromFile` 的完整解析和 preview 两条分支都应新增内部字段 `createdFromSourceSessionId`;该字段只用于过滤,不必暴露到 MCP 返回摘要。 +- children 过滤应紧跟 meta 读取执行,早于运行态、等待状态计算,以减少无关会话开销。 +- stdio MCP 与 Codex App 兼容工具的现有描述文字不同;两者都需明确“默认全部、children 为当前来源直接 MCP 子对话”,并添加同一 enum schema。 +- 两处 schema 均设置 `additionalProperties: false`,新增 `scope` 后不会影响既有 agent/status/limit 参数。 +- 回归中可在首个 `mcpCreate` 完成并验证 `createdFrom` 后调用列表:此时当前来源只有一个直接 MCP 子会话,适合断言精确集合。 +- 省略 scope 与显式 all 的兼容性可比较同参数调用返回的会话 ID 数组;children 应包含新建子会话且排除来源会话。 +- README 当前仅有一行轻量元数据描述,可直接补充 `scope=children` 的行为与默认 all。 +- 现有回归没有 branch 会话夹具;为验证 `kind === 'mcp'` 条件,需要在回归中创建一个来自同一来源的 branch,或构造等价持久会话夹具。 +- 项目只有 `npm run regression` 这一完整回归入口;失败测试与最终验证均需用 `timeout 60s` 包裹。 +- `handleNewSession` 创建 branch 后会把测试 WebSocket 切换到新会话;为避免干扰后续事件断言,children scope 回归应直接在临时 sessions 目录写入最小 branch/孙级夹具。 +- `buildSessionInfoPayload` 虽会返回 `createdFromKind`,但 children MCP 过滤不应依赖当前 WebSocket 查看状态。 +- 会话元数据完整解析阈值默认 512 KiB、preview 头尾各 128 KiB;已有 `session-preview-metadata` 定向回归以更小阈值强制走 preview 分支。 +- 该定向回归已覆盖 preview 中的 `createdFrom.kind`,可扩展来源 ID 或通过内部 MCP 验证 children 过滤,避免只测试小文件路径。 +- `runSessionPreviewMetadataRegression` 已配置独立内部 MCP token,可将 tail 夹具改为 `kind: mcp + sourceSessionId`,随后调用 `ccweb_list_conversations(scope=children)` 精确断言;这是最小且快速的失败回归入口。 +- preview 定向回归无需真实父会话文件:列表过滤只使用自动传入的来源 ID 与子会话持久元数据比对。 +- 主回归已直接导入 `lib/ccweb-mcp-server.js` 的 `TOOLS`,可对 stdio/shared MCP 的 scope enum 与描述做结构化断言。 +- Codex App 当前主路径走线程级 MCP;`codexAppCommunicationDynamicTools` 是兼容定义,仍需同步文字与 schema,可用已有 server 源码审计断言或行为回归覆盖。 +- 回归已有 `assertCcwebDisplayImageContract` 这类静态契约函数和 target 分派模式;可新增列表 scope 契约函数,并在 `session-preview-metadata` 定向目标及完整回归中复用。 +- 完整回归后续没有对全局会话数量做精确断言,仅按 ID 查找;增加 branch 与孙级临时会话夹具不会破坏后续场景。 +- 新增测试语法检查通过;实现前定向回归稳定失败在 stdio/shared MCP 缺少 scope enum,证明失败测试有效。 +- 实现后 `server.js`、`lib/ccweb-mcp-server.js`、`scripts/regression.js` 语法检查全部通过,preview 定向回归通过。 +- 服务端将任何非 `children` 值归一为 `all`;children 在运行态/等待态计算、排序和 limit 前完成过滤。 +- 最终语法检查、session-preview-metadata 定向回归、完整 `npm run regression` 与 `git diff --check` 全部通过。 +- 最终局部差异确认核心改动仅落在会话元数据、列表过滤、两处 MCP 契约、README 与回归断言;工作树中的任务看板等大段 diff 为既有改动,未被覆盖。 +- 差异审查发现 preview 测试不应把原有 `ccweb_prompt_user` 夹具改写为 `mcp`;将改为新增独立大文件 MCP 子会话夹具,以保留旧覆盖。 +- 已完成上述修正,定向回归、完整回归和 diff check 再次通过;没有剩余代码风险或阻塞。 +- 无需重启服务即可完成代码交付;本轮未执行服务重启。 +- 最终文件行号已核验,临时 CSV 已不存在,`git diff --check` 仍通过。 +- 重启前运行态检查发现除当前对话外还有 2 个 running 对话;按项目运维规则暂缓 `pm2 restart ccweb --update-env`。 + +## 技术决策 + +| 决策 | 理由 | +|------|------| +| 支持 `scope: "all" | "children"` | schema 显式、可验证;默认 all 向后兼容 | +| children 匹配 `createdFrom.kind === "mcp" && createdFrom.sourceSessionId === sourceSessionId` | 精确表达直接持久子对话,不混入分支对话 | +| 非法 scope 回退 all | 正常 MCP 由 enum 约束,服务内部调用仍保持兼容与可预测 | +| 不依赖 LLM 上下文或 pending reply 状态 | 子会话持久元数据才是可重启恢复的权威来源 | +| 暂不新增返回字段 | 用户只要求过滤能力,遵循 YAGNI;现有摘要字段足以继续投递消息 | + +## 风险 + +- 大会话文件可能走 preview 解析路径,需要确保 preview 元数据能读取 `createdFrom.sourceSessionId`。 +- limit 必须在 children 过滤后应用。 +- 回归测试已有大量并行任务新增内容,插入断言时避免更改无关逻辑。 +- 需分别断言省略 scope、scope=all、scope=children 和非法 scope。 +- 本轮 `codebase-memory-mcp` transport 已关闭;按降级策略使用本地检索,不再重复失败调用。 + +## 资源 + +- `server.js`:列表实现、会话元数据解析、Codex App 工具 schema。 +- `lib/ccweb-mcp-server.js`:stdio MCP 工具 schema 与提示词。 +- `scripts/regression.js`:MCP 创建及列表回归。 +- `README.md`:MCP 能力说明。 diff --git a/.planning/2026-08-11-ccweb-list-children-scope/progress.md b/.planning/2026-08-11-ccweb-list-children-scope/progress.md new file mode 100644 index 0000000..925dcec --- /dev/null +++ b/.planning/2026-08-11-ccweb-list-children-scope/progress.md @@ -0,0 +1,116 @@ +# 进度日志:ccweb_list_conversations children scope + +## 会话:2026-08-11 + +### 阶段 1:核对现状与重叠改动 + +- **状态:** complete +- **开始时间:** 2026-08-11 22:56 +08:00 +- 已完成动作: + - 读取 `planning-with-files` 与 `todo-list-csv` 技能说明。 + - 运行 session catchup,未发现需恢复的未同步上下文。 + - 检查 Git 工作树,确认三个目标文件存在其他任务未提交改动。 + - 建立本任务独立规划目录和六步计划。 + - 独立计划审查通过;采纳兼容性、非法参数、直接子会话判定和定向验证建议。 + - 使用本地检索确认目标代码行及回归入口;相关区域没有既有未提交 diff,仍按当前文件内容局部修改。 + - 核对大小会话元数据解析路径,确认可从现有 `createdFrom` preview 直接提取来源 ID。 + +### 阶段 2:编写失败回归断言 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:10 +08:00 +- 计划动作: + - 扩展 session preview 定向回归,先证明 scope 尚未生效。 + - 扩展主回归覆盖兼容 all、直接 children、branch/孙级排除与非法 scope。 +- 完成动作: + - 新增 stdio/shared MCP 与 Codex App 兼容工具提示词/schema 契约断言。 + - 新增大文件 preview children 过滤定向断言。 + - 新增小文件默认/all/非法 scope、branch 与孙级排除断言。 + +### 阶段 3:实现列表过滤与 MCP 契约 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:13 +08:00 +- 计划动作: + - 扩展会话元数据来源 ID。 + - 在排序和 limit 前执行 children 过滤。 + - 同步两处工具描述与 schema。 +- 完成动作: + - 完整解析和 preview 元数据均提取 `createdFromSourceSessionId`。 + - `scope=children` 只匹配当前来源的直接 MCP 子会话。 + - stdio/shared MCP 与 Codex App 兼容 schema 均暴露 `all/children`,提示词同步说明语义。 + +### 阶段 4:更新 README 与规划记录 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:16 +08:00 +- 计划动作: + - 更新 MCP 表格和设计边界说明。 + - 核对规划记录与实现一致。 +- 完成动作: + - README 表格记录默认 `scope=all` 和 `scope=children`。 + - README 设计边界明确排除 branch 与更深层后代。 + +### 阶段 5:运行定向及完整回归验证 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:17 +08:00 +- 计划动作: + - 重跑定向回归、语法检查和 diff check。 + - 在 60 秒上限内运行完整回归。 +- 完成动作: + - 三个 JavaScript 文件语法检查通过。 + - session preview 定向回归通过。 + - 完整回归在硬超时内通过,输出 `Regression checks passed.`。 + - `git diff --check` 通过。 + +### 阶段 6:差异审查与交付 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:19 +08:00 +- 计划动作: + - 审查目标文件最终差异与行号。 + - 确认未覆盖其他任务改动。 + - 完成 CSV 状态并清理临时清单。 +- 审查发现:需要保留 preview 回归原有 `ccweb_prompt_user` kind 覆盖,另增 MCP child 夹具;修正后重跑验证。 +- 完成动作: + - 保留原有 preview kind 夹具,新增独立的大文件 MCP 子会话夹具。 + - 修正后语法、定向回归、完整回归和 diff check 再次全部通过。 + - 审查局部 diff,确认未覆盖其他对话的未提交改动。 + - 将临时 CSV 全部推进为 DONE,准备按技能规则清理。 + - TODO CSV 状态工具确认 `6/6 DONE` 后已删除;最终存在性检查与 diff check 通过。 + - 检查运行会话:另有 2 个对话处于 running,按规则未重启 cc-web,避免中断其他任务。 +- 创建/修改文件: + - `.planning/2026-08-11-ccweb-list-children-scope/task_plan.md` + - `.planning/2026-08-11-ccweb-list-children-scope/findings.md` + - `.planning/2026-08-11-ccweb-list-children-scope/progress.md` + - `ccweb会话子范围 TO DO list.csv` + +## 测试结果 + +| 测试 | 输入 | 预期 | 实际 | 状态 | +|------|------|------|------|------| +| 回归脚本语法 | `node --check scripts/regression.js` | 通过 | 通过 | ✓ | +| children scope 失败回归 | `node scripts/regression.js --target session-preview-metadata` | 实现前失败 | 缺少 scope enum,退出 1 | ✓ | +| 三文件语法检查 | `node --check` | 全部通过 | 全部通过 | ✓ | +| children scope 定向回归 | `node scripts/regression.js --target session-preview-metadata` | 通过 | 通过 | ✓ | +| 完整回归 | `npm run regression` | 60 秒内通过 | `Regression checks passed.` | ✓ | +| 差异空白检查 | `git diff --check` | 通过 | 通过 | ✓ | +| 修正后完整回归 | `npm run regression` | 60 秒内通过 | `Regression checks passed.` | ✓ | + +## 错误日志 + +| 时间 | 错误 | 尝试 | 解决方式 | +|------|------|------|----------| +| 2026-08-11 22:56 +08:00 | 项目内 todo-list-csv 技能路径不存在 | 1 | 改用会话公布的 `/home/hdzx/.codex/skills/todo-list-csv/` | +| 2026-08-11 23:00 +08:00 | codebase-memory-mcp `Transport closed` | 3 | 停止重试,降级到本地 `rg/sed/git diff` | + +## 五问恢复检查 + +| 问题 | 回答 | +|------|------| +| 当前在哪? | 阶段 1,核对实现与重叠改动 | +| 接下来去哪? | 失败回归、实现、文档、验证、交付 | +| 目标是什么? | 增加兼容的 children scope 并更新提示词 | +| 学到了什么? | 见 findings.md | +| 做了什么? | 见上方阶段日志 | diff --git a/.planning/2026-08-11-ccweb-list-children-scope/task_plan.md b/.planning/2026-08-11-ccweb-list-children-scope/task_plan.md new file mode 100644 index 0000000..f4c0584 --- /dev/null +++ b/.planning/2026-08-11-ccweb-list-children-scope/task_plan.md @@ -0,0 +1,80 @@ +# 任务计划:ccweb_list_conversations children scope + +## 目标 + +为 `ccweb_list_conversations` 增加兼容默认行为的 `scope: "all" | "children"`,让来源对话能查询自己通过 MCP 创建的持久子对话,并同步提示词、schema、回归测试和文档。 + +## 当前阶段 + +已完成 + +## 阶段 + +### 阶段 1:核对现有接口、提示词、回归覆盖与工作树重叠 + +- [x] 核对服务端列表实现、两处工具 schema、README 和回归入口 +- [x] 审查工作树中与本任务重叠的既有改动并保留其语义 +- **状态:** complete + +### 阶段 2:为 children scope 编写失败回归断言 + +- [x] 新增省略 `scope` 与显式 `all` 返回完全一致的兼容断言 +- [x] 新增 `children` 只匹配 `kind === 'mcp'` 且来源 ID 为当前调用上下文的直接子会话断言 +- [x] 新增非法 `scope` 保守回退 `all` 的服务端断言 +- [x] 先运行定向回归,确认新断言在实现前失败 +- **状态:** complete + +### 阶段 3:实现列表过滤与双端 MCP schema/描述 + +- [x] 按 `createdFrom.kind === 'mcp'` 与来源会话 ID 过滤 +- [x] 更新 stdio MCP 与 Codex App 工具描述、参数 schema +- **状态:** complete + +### 阶段 4:更新 README 文档与规划记录 + +- [x] 记录 `scope` 默认值、children 语义和适用边界 +- [x] 同步发现、决策与进度 +- **状态:** complete + +### 阶段 5:运行定向及完整回归验证 + +- [x] 运行语法检查,并定向覆盖列表实现、stdio MCP schema 与 Codex App 工具描述 +- [x] 在 60 秒超时约束下运行完整回归 +- **状态:** complete + +### 阶段 6:审查差异、清理临时清单并完成交付 + +- [x] 检查差异只追加本任务语义且未覆盖用户改动 +- [x] 将 CSV 全部推进为 DONE 后清理 +- [x] 保留 `.planning/2026-08-11-ccweb-list-children-scope/` 作为长期任务记录 +- **状态:** complete + +## 关键问题 + +1. `children` 是否仅包含 `createdFrom.kind === 'mcp'` 的直接子对话? +2. 默认省略 `scope` 时是否完全保持当前全量列表行为? +3. 过滤应在 limit 之前还是之后执行? +4. 返回结构是否需要新增父子字段,还是只改变列表集合? + +## 决策 + +| 决策 | 理由 | +|------|------| +| `scope` 默认 `all` | 保持现有调用完全兼容 | +| `children` 仅返回当前来源直接通过 MCP 创建的会话 | 与用户提出的“我曾创建的子对话”语义一致,排除 branch 与孙级会话 | +| 非法 `scope` 在服务实现中回退 `all` | schema enum 会拦截正常 MCP 调用;内部兼容入口保持保守且不破坏旧行为 | +| 先过滤、再排序、再应用 limit | 避免全局热门会话占满 limit 后漏掉子对话 | +| 不在父会话维护 children 数组 | 现有子会话 `createdFrom` 已是权威数据,避免双写一致性问题 | + +## 遇到的错误 + +| 错误 | 尝试 | 解决方式 | +|------|------|----------| +| 项目内 `/home/cc-web/.codex/skills/todo-list-csv/SKILL.md` 不存在 | 1 | 使用会话公布的有效路径 `/home/hdzx/.codex/skills/todo-list-csv/SKILL.md` | +| `codebase-memory-mcp` 连续返回 `Transport closed` | 3 | 停止重复调用,基于此前已确认入口降级为本地 `rg/sed/git diff` | + +## 约束 + +- 保留工作树中其他对话已存在的 `server.js`、`lib/ccweb-mcp-server.js`、`scripts/regression.js` 改动。 +- 测试命令最大超时 60 秒。 +- 不重启服务;若后续确有必要,先按项目规则检查其他运行中对话。 diff --git a/.planning/2026-08-11-failed-insert-card-actions/findings.md b/.planning/2026-08-11-failed-insert-card-actions/findings.md new file mode 100644 index 0000000..5036067 --- /dev/null +++ b/.planning/2026-08-11-failed-insert-card-actions/findings.md @@ -0,0 +1,103 @@ +# 失败插入卡片重试与删除:发现记录 + +## 用户需求 + +- 用户希望“插入失败”的卡片增加“重试”和“删除”按钮。 +- 截图中的失败项位于用户消息卡片内,包含图片预览、文件名、尺寸/状态信息和红色“插入失败”状态。 + +## 视觉观察 + +- 当前失败状态只有红点和“插入失败”文本,没有恢复或清理入口。 +- 操作应靠近失败状态区域,保持卡片宽度和当前主题视觉,不应抢占消息正文。 + +## 工作区约束 + +- 当前分支为 `main`,已有另一任务留下的未提交改动。 +- 已确认 `.trellis/.current-task` 指向 `08-11-task-board`,本任务不会覆盖该指针。 +- 与本任务可能重叠的已修改文件包括 `public/app.js`、`public/index.html`、`scripts/regression.js` 和 `server.js`;后续必须做局部差异审查。 + +## 待确认的代码事实 + +- 失败状态的数据结构、稳定标识和持久化位置。 +- 原始插入请求的入口、请求参数及重试时可复用的数据。 +- 删除应落在草稿状态、消息附件状态还是服务端持久化层。 +- 现有测试中与运行时图片插入相关的覆盖位置。 + +## 初步代码定位 + +- `codebase-memory-mcp` 项目 `home-cc-web` 索引状态为 `ready`(5737 节点、12312 边)。 +- “插入失败”不是普通上传失败文案,而是 Codex App 运行中消息插入(steer)状态:前端入口命中 `codexAppSteerStatusLabel` 与相邻的 `setCodexAppSteerStatusElement`。 +- 服务端入口命中 `handleCodexAppSteerMessage`:`turn/steer` 成功发送 `inserted`,失败发送 `failed`,且消息会先以 `persistedUserMessage` 写入会话。 +- 下一步需要确认状态事件携带的稳定标识、前端如何找到对应用户消息,以及重试是否可以复用现有 `handleCodexAppSteerMessage` 消息形状。 + +## 已确认的数据与渲染契约 + +- 前端 `updateCodexAppSteerMessage(clientMessageId, status, message)` 使用 `userMessageIndex` 或消息元素的 `data-message-id` 精确定位卡片,稳定标识已经存在,无需按文件名匹配。 +- `setCodexAppSteerStatusElement` 目前只切换 `pending / inserted / failed` 三种 CSS class,并把状态区域写成纯文本;操作按钮应由这里或紧邻的单一渲染函数统一维护,避免重复绑定。 +- 服务端回归 `runCodexAppRuntimeImageSteerRegression` 已覆盖 `clientMessageId`、失败状态和成功插入;失败的“附件不可用”消息明确不会持久化,说明截图里的失败卡片至少有一类是客户端临时项。 +- 现有成功分支会持久化一条带附件的用户消息,并最终发送 `inserted`;重试实现必须避免把失败项或成功项重复写入会话。 + +## 调用链与主要风险 + +- `handleServerMessage` 仅在 `codex_app_steer_status` 分支调用 `updateCodexAppSteerMessage`;当前状态事件已是最小可扩展协议面。 +- `handleCodexAppSteerMessage` 在真正调用 `turn/steer` **之前** 把用户消息写入会话。若 `turn/steer` 异步失败,该持久化消息当前不会回滚,因此“重试”若直接重发原消息会造成重复持久化。 +- 失败分为两类:附件解析/空消息等同步前置失败(未持久化)与准备/`turn/steer` 异步失败(可能已持久化)。删除和重试必须能区分或统一修正这两类,否则刷新后的行为会不一致。 +- 下一步要确认本地消息对象是否保存 `clientMessageId`、服务端是否已有单条消息删除/替换协议,以及附件引用在失败后是否仍可用。 + +## 客户端入口补充 + +- `sendMessage` 为运行中 Codex App 插入创建本地 `messageId`,用该值注册 `userMessageIndex`,并作为 WebSocket 消息的 `clientMessageId` 发送;因此失败卡片已有天然的单项操作键。 +- 当前前后端没有通用的“删除单条消息”协议;仅有排队消息、提示卡等其他局部删除实现,不能直接用于持久化的 steer 用户消息。 +- 需要在服务端把 `clientMessageId` 与持久化消息关联起来,或在 steer 失败时回滚该消息,才能让删除和重试同时满足“不重复”和“刷新一致”。 + +## 可行实现方向 + +- 本地卡片由 `sendMessage` 创建,包含完整 `text` 与克隆后的 `attachments`;扩展 `userMessageIndex` 即可保存重试载荷,不需要从 DOM 反解析。 +- `createMsgElement` 是失败状态和附件预览的共同装配点,`setCodexAppSteerStatusElement` 是状态区域的唯一更新点,适合统一创建/移除按钮并维护 `disabled` / `aria-busy`。 +- 推荐服务端在真实 `turn/steer` 最终失败时按对象引用回滚刚持久化的用户消息。这样所有失败卡片都保持客户端临时态,删除无需新增持久化删除协议,重试也不会制造历史重复项。 +- 重试仍需校验当前 Codex App turn 是否在运行;若运行已结束,应给出明确失败状态,不能让卡片永久停在 `pending`。 + +## 本地源码校验 + +- 当前样式集中在 `public/style.css` 的 `.codex-steer-*` 区域,失败卡片已有红色边框/背景和 11px 状态行;新操作可在同一区域追加,避免影响其他消息卡片。 +- 项目没有 DOM 测试依赖,仅有 `node scripts/regression.js`;前端交互测试需要使用轻量假 DOM/纯函数抽取,或在现有回归中增加协议和静态契约断言。 +- 本任务开始时的重叠文件基线:`public/app.js` 已有 +155 行、`server.js` +532/-7 行、`scripts/regression.js` +73/-11 行、`public/index.html` +14 行,均来自既有“任务看板”工作,后续差异必须按目标函数/样式区段核对。 +- `public/style.css` 当前没有未提交改动,是本任务新增样式的低冲突落点。 + +## 测试与冲突边界 + +- 现有 `runCodexAppStaleRunningRegression` 已有一个稳定的 `expectedTurnId` 不匹配失败夹具,适合新增“失败后持久化消息已回滚”的服务端断言。 +- 现有成功恢复分支已断言用户消息只持久化一次,可继续保护“重试/恢复不重复”。 +- 既有未提交 diff 的 hunk 不覆盖 `setCodexAppSteerStatusElement`、`sendMessage` 或 `handleCodexAppSteerMessage` 主体;可以对这些区段做局部补丁,但最终仍需逐 hunk 复核。 +- 仓库已有未跟踪的任务看板 MiniDOM 单测脚本,但它属于另一任务;本功能不能修改或依赖该未提交测试文件。若需要 DOM 单测,应建立独立、最小的测试入口。 + +## 失败夹具与规范结论 + +- mock app-server 的 mismatch 夹具会保留一个不同的活动 turn,因此每次 `turn/steer` 都稳定返回 `expectedTurnId does not match active turn`;可用于重复验证失败与回滚,但不能作为成功重试夹具。 +- 前端 Trellis 规范除“静态资源版本握手”外大部分仍是模板占位,没有额外组件框架约束;本任务应遵循仓库现有原生 DOM + CSS 组织方式。 +- 回归脚本已有按 target 执行的分支,可为本功能提供小范围测试入口,避免每次执行完整回归超过 60 秒。 + +## 资源 + +- 用户截图:`sessions/_attachments/8da11ed4-5989-4d30-a12a-f27522138799.png` +- Trellis 任务:`.trellis/tasks/08-11-failed-insert-card-actions/` + +## 最终实现状态 + +- 前端新增 `codexAppSteerRecords`,保存失败项的原文本、附件、会话、模式和 agent;重试沿用同一 `clientMessageId`,并通过 `status + inFlight` 阻止重复发送。 +- failed 状态统一渲染“重试”“删除”;pending/inserted 移除操作,卡片使用 `aria-busy`,状态行使用 `role=status` 与 `aria-live=polite`。 +- 本地运行中插入不再提前增加 `currentSessionMessageCount`,只有收到 `inserted` 才提交消息索引;失败/删除不会制造普通路径的索引漂移。 +- 服务端为临时 steer 用户消息写入稳定 `id`,真正的 `turn/steer` 最终失败时先按 id 从最新会话回滚,再发 failed 状态;stale recovery 成功路径保持一次持久化。 +- 前端 MiniDOM 单测覆盖失败操作、双击保护、载荷复用、再次失败恢复、删除单项、断线/跨会话/turn 结束保护与成功索引提交。 + +## 验证结果 + +- `node scripts/failed-insert-card-unit.js`:通过。 +- `node scripts/regression.js --target runtime-image-send`:通过。 +- `node --check public/app.js`、`server.js`、`scripts/regression.js`:通过。 +- `node scripts/regression.js`:因首次定向命令参数错误而实际执行,完整回归通过(约 46 秒)。 + +## 独立审查修正 + +- 检查代理发现 `options.emitUserMessage` 的程序化 `session_message` 虽会显示失败操作,但缺少原始重试载荷;现已由服务端附加临时 steer 元数据,前端据此建立同样的重试记录。 +- 主线程进一步将这类 `session_message` 在结果未定时保持为临时项:不提前写入消息缓存、不提前占用 `currentSessionMessageCount`,收到 `inserted` 后才提交,避免失败删除后的缓存幽灵和索引漂移。 diff --git a/.planning/2026-08-11-failed-insert-card-actions/progress.md b/.planning/2026-08-11-failed-insert-card-actions/progress.md new file mode 100644 index 0000000..4f07a37 --- /dev/null +++ b/.planning/2026-08-11-failed-insert-card-actions/progress.md @@ -0,0 +1,63 @@ +# 失败插入卡片重试与删除:进度日志 + +## 2026-08-11 + +### 初始化与定位 + +- **状态:** `complete` +- 已扫描可用技能并完整读取 `planning-with-files`、`todo-list-csv` 主说明。 +- 已运行 planning session catchup,未发现需要恢复的未同步上下文。 +- 已读取 Trellis 会话上下文,确认开发者为 `shiyue`,现有活动任务为“独立任务看板”。 +- 已创建独立 Trellis 任务 `08-11-failed-insert-card-actions`,但未切换全局当前任务指针。 +- 已记录截图中的失败卡片结构和现有工作区重叠风险。 +- 独立计划审查已通过;已吸收稳定标识、忙碌状态和定向测试优先等建议。 +- 已创建 Trellis PRD,并为实施/检查配置前端规范上下文。 +- 已确认 codebase-memory 索引可用,并初步定位到 Codex App `turn/steer` 的前后端状态链路。 +- 已完成调用链、失败持久化、临时消息索引和测试夹具分析,并写入 Trellis 研究文档。 + +### 交互契约与回归测试 + +- **状态:** `complete` +- 已明确失败项使用 `clientMessageId` 定位、失败持久化回滚、临时消息延迟提交索引、重试并发保护和客户端删除语义。 +- 下一步先补回归测试,再写最小实现。 + +### 实现、样式与定向验证 + +- **状态:** `complete` +- 已实现失败卡片的重试、删除、防重复点击、可访问状态和消息索引延迟提交。 +- 已实现服务端最终 steer 失败的精确持久化回滚及错误关联 id。 +- 已新增独立 MiniDOM 前端单测并扩展 runtime image mismatch 回滚断言。 + +### 差异审查与完整回归 + +- **状态:** `complete` +- 已完成主线程首轮目标函数审查;下一步由独立检查代理按 PRD、研究契约和现有脏工作区边界复核。 +- 独立检查已修复程序化 `session_message` 缺少重试载荷的问题;主线程补齐其临时缓存/索引语义,并扩展成功提交断言。 +- 修正后前端单测、`runtime-image-send` 定向集成和 `node --check public/app.js` 再次通过。 +- `git diff --check` 通过;目标修改保持在既定函数、CSS 区段和测试范围,未回退任务看板或消息回传的并行改动。 + +### 清理与交付 + +- **状态:** `complete` +- 已检查运行对话:除当前对话外仍有“设计任务看板状态机制”和“统一 ccweb 消息回传接口”处于 `running`,按运维规则暂不重启 `ccweb`。 +- 规范沉淀判断:本次结论属于 Codex App steer 的任务级协议细节,已完整记录在 Trellis 研究文档;没有形成需要写入通用前端规范的新约定,因此不修改 `.trellis/spec/`。 +- 已将 Trellis 任务元数据标记为完成,并按 `todo-list-csv` 工作流删除本任务临时 CSV;持久化计划与研究文档保留用于审计。 + +## 测试结果 + +| 测试 | 预期 | 实际 | 状态 | +|---|---|---|---| +| 前端失败卡片单测 | 重试/删除/并发/无障碍/边界保护通过 | 全部通过 | ✓ | +| Runtime image send 定向集成 | 成功、stale recovery、mismatch 回滚通过 | 全部通过 | ✓ | +| 完整回归 | 项目全部回归通过 | 全部通过(误执行但有效) | ✓ | +| JavaScript 语法检查 | 三个目标脚本语法有效 | 全部通过 | ✓ | + +## 错误日志 + +| 时间 | 错误 | 尝试 | 处理 | +|---|---|---:|---| +| 2026-08-11 23:00 +08:00 | 新建 Trellis 任务后读取到缺失的 `prd.md` | 1 | 该脚本只生成任务元数据与上下文模板,已按提示显式创建 PRD | +| 2026-08-11 23:20 +08:00 | `codebase-memory-mcp` transport closed | 1 | 已保留先前 MCP 命中结果,剩余小范围源码用本地检索降级校验 | +| 2026-08-11 23:30 +08:00 | 前端单测跨 realm 深比较失败 | 1 | 将沙箱载荷规范化为普通 JSON 对象后再断言,并清理重复的 `inFlight` 赋值 | +| 2026-08-11 23:35 +08:00 | 定向回归漏写 `--target` | 1 | 命令实际跑成完整回归且通过;已确认正确 CLI,避免再次执行完整套件 | +| 2026-08-11 23:38 +08:00 | 计划同步补丁使用了不存在的标题锚点 | 1 | 补丁未生效;读取实际文件后以现有区段重新应用 | diff --git a/.planning/2026-08-11-failed-insert-card-actions/task_plan.md b/.planning/2026-08-11-failed-insert-card-actions/task_plan.md new file mode 100644 index 0000000..c9101db --- /dev/null +++ b/.planning/2026-08-11-failed-insert-card-actions/task_plan.md @@ -0,0 +1,55 @@ +# 失败插入卡片重试与删除:任务计划 + +## 目标 + +为图片/附件“插入失败”卡片增加可用的“重试”和“删除”操作:重试沿用原附件并正确更新卡片状态,删除只移除对应失败项;同时保证重复点击、再次失败、会话恢复和现有发送流程不被破坏。 + +## 范围 + +- 失败卡片的前端渲染、事件处理和状态同步。 +- 必要的后端/API 复用或补充。 +- 定向回归测试、样式和无障碍状态。 +- 不重构无关消息链路,不覆盖当前工作区中的“独立任务看板”改动。 + +## 阶段 + +1. **初始化任务上下文并定位失败附件链路** — `complete` +2. **明确重试与删除交互契约并补充回归测试** — `complete` +3. **实现失败卡片重试与删除行为** — `complete` +4. **完善操作按钮样式与无障碍状态** — `complete` +5. **运行定向测试并修复问题** — `complete` +6. **执行差异审查与完整回归** — `complete` +7. **清理临时跟踪文件并交付** — `complete` + +## 验收条件 + +- 仅失败卡片展示“重试”和“删除”。 +- 点击“重试”后复用原附件,进入明确的处理中状态;成功后不再显示失败操作,失败后恢复可重试状态并保留可理解的错误提示。 +- 重试过程中禁用重复操作,避免并发插入或重复提交。 +- 点击“删除”只移除目标失败卡片,不影响消息文本、其他附件或其他消息。 +- 刷新/恢复会话后的失败卡片仍可执行两项操作,或按现有持久化边界保持一致行为。 +- 定向测试与项目相关回归通过;无新增控制台错误。 + +## 关键决策 + +| 决策 | 原因 | +|---|---| +| 使用独立 `.planning` 与 Trellis 任务目录,不切换 `.trellis/.current-task` | 当前工作区另有“独立任务看板”任务正在运行,避免跨会话状态覆盖 | +| 先追踪现有插入请求和失败状态,再决定是否新增 API | 优先复用既有链路,减少协议与持久化风险 | +| 以失败项标识作为重试/删除目标,不按文件名匹配 | 同名附件可能同时存在,必须精确操作单项 | + +## 风险与约束 + +- `public/app.js`、`server.js` 等文件已有未提交改动;补丁必须保持局部并在最终差异中区分本任务内容。 +- 删除属于界面内失败项清理,不删除用户磁盘上的原始文件或服务器无关资源。 +- 所有自动化测试单次运行控制在 60 秒内。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| Trellis 新任务默认未生成 `prd.md` | 1 | 按工作流要求显式创建 PRD,并保留已生成的上下文清单与任务元数据 | +| `codebase-memory-mcp` 补查 `send` / `clearUserMessageIndex` 时 transport closed | 1 | 核心链路已通过 MCP 确认;剩余局部实现按约定降级为 `rg/sed` 校验,不重复相同失败调用 | +| 前端单测跨 `vm` 上下文对象被 `deepStrictEqual` 判为原型不一致 | 1 | 对实际发送载荷先做 JSON 规范化,再比较纯数据结构;功能载荷本身完全一致 | +| 回归命令漏写 `--target`,误执行完整回归 | 1 | 完整回归已通过;查明 CLI 只解析 `--target` 或环境变量,后续仅补跑未覆盖的专项目标 | +| 计划同步补丁引用了研究文档中的错误标题 | 1 | 补丁未生效;读取实际计划文件后改用现有“资源”区段作为追加锚点 | diff --git a/.planning/2026-08-11-help/findings.md b/.planning/2026-08-11-help/findings.md new file mode 100644 index 0000000..f26d82d --- /dev/null +++ b/.planning/2026-08-11-help/findings.md @@ -0,0 +1,42 @@ +# Findings & Decisions + +## Requirements +- 新增独立任务看板,不影响现有聊天、会话列表、MCP 和 Codex App 行为。 +- 普通对话默认不进入任务池;提供“加入任务看板”开关。 +- 支持系统固定状态与用户自定义状态。 +- Agent 可通过 MCP 主动上报;未上报时由 cc-web 生命周期 Hook 自动分类。 +- 归档由用户或服务端策略触发,不允许 Agent 隐式归档。 +- 三个实现对话完成后,必须由第四个集成对话统一接线和测试。 +- 集成测试通过且无其他 running 对话后才允许重启 cc-web。 + +## Research Findings +- 当前会话列表只有运行态 isRunning,没有任务状态或归档字段。 +- cc-web 已能观察 turn started/completed、工具调用、用户输入等待和失败事件,可实现自身 Hook。 +- Codex Goal 只适用于 Codex App,不能作为跨 Agent 看板的唯一状态源。 +- 多个对话共享同一工作区,中心文件必须避免并发修改。 +- 项目必需命令是 `node --check` 与 `npm run regression`;不存在通用 lint/typecheck 脚本。 +- Trellis 任务已创建为 `.trellis/tasks/08-11-task-board` 并设为 current。 +- 共享数据、WebSocket、MCP、Hook 与文件所有权契约已写入该任务的 `info.md`。 +- 当前来源对话 ID 为 `56bb82c6-4fa3-4cb8-bb84-6969e6547d3a`,重启门禁只能排除该 ID。 + +## Technical Decisions +| Decision | Rationale | +|----------|-----------| +| 系统状态使用稳定 ID,自定义状态映射到 baseStatus | 标签可编辑,同时保留自动化语义 | +| taskTracking.enabled 控制是否进入任务池 | 关闭时不分类、不展示,也不等于归档 | +| MCP、Hook、UI 共用一个任务状态服务 | 防止三套状态逻辑漂移 | +| Hook 作为 MCP 缺失时的兜底,用户操作优先级最高 | 保证可控并降低误判影响 | +| MCP 对话负责共享 TaskBoardService,Hook 对话只实现事件归约器 | 确保并行可实现且第四个对话可机械集成 | + +## Issues Encountered +| Issue | Resolution | +|-------|------------| +| 规划初始化目录名称不理想 | 继续复用,不影响功能代码与最终集成 | +| 首次计划审查未通过 | 按四项阻断意见补齐契约、Trellis、测试和重启门禁后复审 | + +## Resources +- 参考图:sessions/_attachments/42432a7d-778d-4dca-8156-d5bd651d242d.png +- 项目约束:AGENTS.md、.trellis/workflow.md +- 前端实现对话:b94ec6fe-5438-48a3-9733-4028bfd9198f +- MCP 实现对话:972a6903-4860-4558-9ab0-4e124bffa363 +- Hook 实现对话:27651660-a64d-4b01-8606-500468e9c599 diff --git a/.planning/2026-08-11-help/progress.md b/.planning/2026-08-11-help/progress.md new file mode 100644 index 0000000..da340f3 --- /dev/null +++ b/.planning/2026-08-11-help/progress.md @@ -0,0 +1,61 @@ +# Progress Log + +## Session: 2026-08-11 + +### Current Status +- **Phase:** 4 - 集成返工:删除伪优先级并合并控制栏 +- **Started:** 2026-08-11 + +### Actions Taken +- 已确认采用四个 cc-web 对话:前端、MCP、Hook、最终集成。 +- 已读取 planning-with-files、todo-list-csv、frontend-skill。 +- 已确定并行实现阶段不修改共享中心文件。 +- 已创建并启动 Trellis 任务 `08-11-task-board`。 +- 已写入 PRD、技术设计和并行集成契约。 +- 首次计划审查发现 4 个阻断问题,均已修正,等待复审。 +- 已通过 `ccweb_list_conversations` 记录当前来源对话 ID:`56bb82c6-4fa3-4cb8-bb84-6969e6547d3a`。 +- 计划第三次复审已通过,无阻断问题。 +- 已并行创建前端对话 `b94ec6fe-5438-48a3-9733-4028bfd9198f`,reply request `6f4c2836-f473-4cfe-97fa-fd3f58d356c4`。 +- 已并行创建 MCP 对话 `972a6903-4860-4558-9ab0-4e124bffa363`,reply request `c4c3b580-0678-4152-995c-9f6dcf7eebad`。 +- 已并行创建 Hook 对话 `27651660-a64d-4b01-8606-500468e9c599`,reply request `0997a46b-fe03-42cf-af1f-235a61aa5851`。 +- 三个 reply 均处于 waiting;首次 30 秒轮询尚无完成项。 +- `config/cross-conversation-replies.json` 因 requestReply 运行态自动更新,视为 cc-web 运行态记录,不由本任务手工修改。 +- 第二次轮询三路仍为 running;中心文件与功能白名单文件尚未出现写入,暂无冲突。 +- 第三次轮询三路仍为 running,子会话尚未持久化 assistant 终态消息,继续等待而不打断。 +- 三个 `codexapp-state.json` 持续刷新,说明 turn 仍有活动;会话 JSON 仍只含初始用户消息,尚未到终态持久化阶段。 +- 中断恢复后再次检查:三路 `turnStatus` 均为 `running`、`error=null`,没有等待用户输入;前端分支已开始落盘 `public/task-board.js`,其余分支仍在实现中。 +- 14:09 检查:前端与 Hook 已分别落盘 `public/task-board.js`、`lib/task-board-lifecycle.js`,仍在继续补齐模块和测试;MCP 分支仍运行且无错误,尚未返回终态。 +- 前端首轮在仅落盘 `public/task-board.js` 时异常提前结束:缺少 CSS、单测和最终说明,未计为通过。已向同一前端对话发送续做消息并成功恢复运行,没有新建替代对话。 +- 前端续做已补齐 `public/task-board.css`,正在编写契约单测;Hook 的模块与单测均已落盘并在自检;MCP 分支进入原子保存、回滚和状态错误码设计,三路仍无运行错误。 +- 一次 `ccweb_list_pending_replies` 监控调用在 60 秒后超时,已降级为读取本地会话运行态;实现本身无错误。前端三文件与 MCP 的 service/mcp 两文件均已落盘,MCP 单测尚待补齐。 +- MCP 分支已返回,且严格只新增 `lib/task-board-service.js`、`lib/task-board-mcp.js`、`scripts/task-board-service-unit.js`。主对话复验 3 个 `node --check` 和独立单测全部通过,MCP 分支验收通过。 +- 前端续做轮次已完整返回,严格只新增 `public/task-board.js`、`public/task-board.css`、`scripts/task-board-frontend-unit.js`。主对话复验语法和独立 MiniDOM 契约单测均通过,前端分支验收通过。 +- Hook 两个白名单文件的语法、独立单测,以及真实 service+lifecycle 跨模块冒烟均通过;但子对话仍处于运行态且未返回终态。尝试发送“停止修改并直接汇报”被 cc-web 以 `target_running` 拒绝,未打断其活动 turn。 +- Hook 收尾阶段持续高 token 运行且文件仍有变化;确认原生 `abort` 只中断目标 turn 后,定向结束该 Hook turn。会话与文件均保留,三路 pending reply 全部转为 ready。 +- 对 Hook 最终文件重新执行语法、独立单测和真实 service+lifecycle 跨模块冒烟,全部通过;三个独立实现分支正式验收完成,进入第四个集成对话阶段。 +- 已创建第四个集成对话 `c053ad60-c5d7-4480-a3ef-a118fa81b13d`,reply request `af555b18-177f-4d41-b2da-bb18e211e848`。中央接线文件仅由该对话独占,明确禁止重启生产服务和创建额外工作对话。 +- 集成对话已完成规划/契约及三个模块的第一轮阅读,继续核对独立单测与现有接线点;当前 `lastError=null`,尚未修改中央文件。 +- 集成对话已通过 codebase-memory 函数级定位并用 `rg` 交叉核验会话 IO、WebSocket 分发、Codex App 生命周期、MCP tools/list/call 及前端导航入口;仍处于只读设计阶段,中央文件保持未改。 +- 集成对话首轮在 `contextCompaction` 时空结束,未修改中央文件;已向同一对话续发精简指令并恢复运行,没有创建替代对话。续做轮次正在重载必要模块上下文,`lastError=null`。 +- 集成续做轮次完成服务端与生命周期入口核对后,已开始用 `FileChange` 修改 `server.js`;这是中央文件首次解冻写入,当前仍未启动或重启任何服务。 +- 集成对话已修改 `server.js`、`lib/ccweb-mcp-server.js`、`public/index.html`、`public/app.js`,完成生产服务实例、WS/MCP/生命周期桥接及前端面板/开关初步接入;主对话复验三份中央 JS 的 `node --check` 均通过,生产服务仍未重启。 +- 集成 turn 在文件稳定后出现无活动,已定向中断;cc-web 随后自动重新接管同一 Codex 线程并以新运行态继续,未新建对话。续跑已进入 `scripts/regression.js`,现有中央补丁全部保留。 +- 集成对话完成全部接线与专项修正:前端返工移除伪“新建任务”和刷新按钮、改为继承现有主题变量、将任务跟踪开关收敛为输入区上方 28px 透明单行;集成层仅删除 `public/app.js` 中失效的 `createSession` 适配器。 +- 语法检查、前端/服务与 MCP/生命周期独立单测、隔离集成测试、`timeout 60s npm run regression` 与 `git diff --check` 全部通过,完整回归输出 `Regression checks passed.`。 +- Firefox 153.0.3 已完成 washi/carbon、1440×900/500px 四组真实浏览器验收:主题色随主题切换,伪按钮数量为 0,跟踪开关可开关,状态管理、归档/恢复与窄屏横向滚动正常。 +- 浏览器与隔离服务已自然关闭,无端口或 WebDriver 残留;四个工作对话均已结束,准备执行生产重启门禁。 +- 用户提供真实页面截图后确认首轮视觉验收结论错误:任务跟踪控件虽为 28px,却在输入区上方独占一行;同时“会话优先级”没有任何生产写入入口,仅由测试夹具构造。已撤销 Phase 4/5 完成结论并中止生产重启。 +- 已退回原前端、状态服务和集成对话:前端删除 priority 全部界面/协议,服务删除无来源字段与过滤,集成负责把 `#task-tracking-control` 移入 `.chat-controls` 并重新执行真实浏览器验收。最终结论由主对话直接核对,不再以子对话口头报告代替验收。 + +### Test Results +| Test | Expected | Actual | Status | +|------|----------|--------|--------| +| 工作区基线 | 识别已有改动并保护用户内容 | 初始化前 tracked worktree clean | PASS | +| 集成语法与独立单测 | 中央 JS 语法及三个模块测试通过 | 全部退出码 0 | PASS | +| 隔离集成与完整回归 | 集成链路和既有功能无回归 | `Task board integration checks passed.`;`Regression checks passed.` | PASS | +| 真实浏览器验收 | 亮暗主题、桌面窄屏与核心交互通过 | Firefox 四组视口/主题证据通过 | PASS | + +### Errors +| Error | Resolution | +|-------|------------| +| init-session.sh 不支持 --help,误建规划目录 | 复用并改写为正式计划,未删除文件 | diff --git a/.planning/2026-08-11-help/task_plan.md b/.planning/2026-08-11-help/task_plan.md new file mode 100644 index 0000000..4306b94 --- /dev/null +++ b/.planning/2026-08-11-help/task_plan.md @@ -0,0 +1,72 @@ +# Task Plan: 独立任务看板功能 + +## Goal +在不破坏现有会话功能的前提下,实现可选加入任务池的任务看板、状态 MCP 与 cc-web 生命周期 Hook,并完成集成回归、视觉验收和安全重启。 + +## Current Phase +Phase 4 + +## Phases + +### Phase 1: 完成规划与基线检查 +- [x] 固化功能边界、共享接口和互斥文件范围 +- [x] 建立并审查执行计划与 TODO CSV +- [x] 创建并启动 Trellis 任务,完成 PRD、技术设计与上下文配置 +- [x] 记录现有工作区与测试基线 +- **Status:** complete + +### Phase 2: 并行完成三个独立实现对话 +- [x] 前端对话仅实现 `public/task-board.*` 与独立单测 +- [x] MCP 对话负责 `lib/task-board-service.js`、MCP 适配模块与独立单测 +- [x] Hook 对话仅实现生命周期模块与独立单测 +- **Status:** complete + +### Phase 3: 汇总并审查三路产物 +- [x] 等待三个对话全部完成并收集回复 +- [x] 检查文件冲突、接口契约与独立性 +- **Status:** complete + +### Phase 4: 创建集成对话并完成接线 +- [x] 创建第四个集成对话 +- [ ] 接入现有 server、MCP、WebSocket 与页面入口 +- [x] 保持普通对话默认不进入任务池 +- [ ] 删除无数据来源的会话优先级,并将跟踪开关并入现有会话控制栏 +- **Status:** in_progress + +### Phase 5: 运行集成回归与视觉验收 +- [ ] 执行 `node --check`、三个独立单测和 `timeout 60s npm run regression` +- [ ] 使用隔离测试服务和真实浏览器验证看板交互与响应式 +- [ ] 核对现有核心功能无回归 +- [ ] 由主对话对照真实页面亲自复核控制栏单行布局与功能来源 +- **Status:** pending + +### Phase 6: 审核重启条件并重启服务 +- [ ] 调用 `ccweb_list_conversations(status=running)`,仅排除来源 ID `56bb82c6-4fa3-4cb8-bb84-6969e6547d3a` +- [ ] 确认四个工作对话均 idle,且 pending replies 无 waiting/delivering +- [ ] 满足条件后执行 pm2 restart ccweb --update-env +- [ ] 完成服务健康检查 +- **Status:** pending + +### Phase 7: 清理临时跟踪并交付 +- [ ] 完成最终变更审查与测试摘要 +- [ ] 同步并清理 TODO CSV +- [ ] 交付功能、风险和验证结果 +- **Status:** pending + +## Decisions Made +| Decision | Rationale | +|----------|-----------| +| 三个实现对话只新增或修改各自边界内文件,中心文件由第四个集成对话统一修改 | 避免共享工作区并发写冲突 | +| 使用 cc-web 自身生命周期事件,不自动注入用户 Codex Hook | 保持功能独立并避免污染项目配置 | +| 任务跟踪默认关闭,从看板创建的会话默认开启 | 普通简单对话不进入任务池 | +| 归档采用软归档,与任务状态分离 | 可恢复、可审计,不影响原会话历史 | +| MCP 对话拥有共享 TaskBoardService;Hook 通过依赖注入调用它 | 消除两个后端对话重复实现状态模型的风险 | +| 共享契约固定在 `.trellis/tasks/08-11-task-board/info.md` | 三个并行对话与第四个集成对话有同一事实来源 | + +## Errors Encountered +| Error | Resolution | +|-------|------------| +| init-session.sh 将 --help 当成任务名并创建 2026-08-11-help | 保留该目录并改写为本任务正式规划,避免未经确认删除或移动文件 | +| 首次计划审查发现并行边界、共享服务、Trellis 和重启门禁不够具体 | 已补 PRD、技术契约、明确文件所有权、验证命令和会话检查入口 | +| Hook 子对话收尾阶段长时间高 token 运行且不返回终态 | 独立测试通过后使用现有 WebSocket `abort` 仅中断该目标 turn;其最终文件随后重新验收通过 | +| 首轮视觉验收只检查跟踪控件自身高度,未检查它是否占用独立行;同时未追查会话优先级的数据生产入口 | 判定首轮验收无效并中止重启;重新打开集成阶段,删除伪优先级并要求跟踪控件与既有会话控制同一行 | diff --git a/.planning/2026-08-11-unify-ccweb-message-reply/findings.md b/.planning/2026-08-11-unify-ccweb-message-reply/findings.md new file mode 100644 index 0000000..af05639 --- /dev/null +++ b/.planning/2026-08-11-unify-ccweb-message-reply/findings.md @@ -0,0 +1,91 @@ +# 发现与决策:统一 ccweb 消息回传接口 + +## 需求 + +- 将 `ccweb_send_message` 与 `ccweb_request_reply` 合并为一个公开工具。 +- 使用显式参数选择单向投递或自动回传。 +- 优化工具描述、目标对话提示和来源续跑提示。 +- 保留旧调用的兼容能力,但不让新模型继续在两个公开工具间选错。 +- 本次需要实际落地并通过回归。 + +## 已确认事实 + +- `requestCrossConversationReply()` 当前只是 + `sendCrossConversationMessage(..., { expectReply: true })` 的薄包装。 +- 正式 MCP 定义位于 `lib/ccweb-mcp-server.js`,Codex App 备用定义位于 + `server.js`,两处描述已出现漂移。 +- `ccweb_create_conversation` 已经使用 `requestReply` 参数,现有公开 API 风格不一致。 +- `buildCrossConversationRuntimeText()` 当前只标注来源标题和 ID,不携带是否自动回传。 +- `buildCrossConversationReplyAutoRunText()` 当前只包含目标标题和返回正文,缺少 + `requestId`、原始请求和完整性检查指令。 +- 真实会话中曾对明确要求“完成后汇报/最终验收”的任务误用 + `ccweb_send_message`;同一会话此前使用过 `ccweb_request_reply`,说明问题不是工具 + 不可发现,而是选择语义不够强。 +- 真实会话还出现过目标返回“完成定位、准备修改”后仍继续工作的情况;提示词可降低 + 概率,但“turn 完成是否等于任务完成”仍是协议级风险。 + +## 并发与工作树基线 + +- 当前另有两个非本对话的 `running` 对话:任务看板、失败插入卡片。 +- `server.js`、`lib/ccweb-mcp-server.js`、`scripts/regression.js` 已有未提交改动,均与本任务重叠。 +- `.planning/.active_plan` 指向 `2026-08-11-help`,`.trellis/.current-task` 指向 + `.trellis/tasks/08-11-task-board`;不得覆盖。 +- 本任务使用 `.planning/2026-08-11-unify-ccweb-message-reply/` 保存独立上下文。 +- 已创建 Trellis 任务 `.trellis/tasks/08-11-unify-ccweb-message-reply/`,但不会执行 + `task.py start`:全局 `.trellis/.current-task` 正被另一个运行中对话使用。后续实施与 + 审查子代理必须显式读取本任务路径,不能依赖全局 current-task。 + +## 技术决策 + +| 决策 | 理由 | +|---|---| +| `replyMode` 必填、无默认值 | 避免模型省略参数后静默失去回传 | +| 值为 `one_way`、`return_and_continue` | 名称直接表达真实副作用,不暗示同步等待 | +| 公开列表移除旧工具,内部 dispatcher 保留别名 | 兼顾新调用清晰度与存量线程兼容 | +| 共享工具定义为单一来源 | 防止 MCP 与备用 dynamic tools 文案/Schema 漂移 | +| 回传模式目标提示禁止手工回复 | 防止同一结果重复投递或形成环路 | +| 来源续跑先判断完整性 | 防止把“已返回”误判成“已完成” | +| 直接复用 `CCWEB_MCP_TOOLS` 生成 Codex App 备用定义 | `server.js` 已导入正式工具清单,无需新增第三个契约模块 | + +## 最新代码基线 + +- `server.js` 顶部已导入 `{ TOOLS: CCWEB_MCP_TOOLS }`,但 + `codexAppCommunicationDynamicTools()` 仍手写重复定义。 +- 最小单一来源方案:正式定义继续留在 `lib/ccweb-mcp-server.js`;该模块同时导出 + reply mode 常量;`server.js` 从 `CCWEB_MCP_TOOLS` 按通信工具白名单筛选并添加 + `namespace: 'ccweb'`。 +- 此方案同时统一所有既有通信工具描述,且不会把 task-board 工具意外加入备用列表。 +- 公开 TOOLS 不能兼作唯一调用 allowlist;需要单独的 hidden legacy name 集合,避免 + 已缓存 `ccweb_request_reply` 的旧线程在到达 dispatcher 前失败。 +- `scripts/regression.js` 现有 `assertCcwebListConversationsScopeContract()` 会从 + `server.js` 的手写 dynamic tool 源码中截取 `ccweb_list_conversations` schema;改成 + 单一来源后该断言也必须改为调用 `codexAppCommunicationTools()` 并比较正式定义, + 否则正确的去重实现会被旧静态断言误报。 +- 当前任务看板对话不是陈旧运行标记:其 `codexapp-state.json` 持续更新,正在做隔离 + 浏览器验收并已发现新增列 `description` 契约缺陷。它后续可能修改 `server.js` 与 + `scripts/regression.js`,本任务必须继续等待其生产改动闭环。 + +## 待验证 + +- 现有测试是否直接断言公开工具数量或旧工具名称。 +- Codex App 备用 dynamic tool 路径是否仍有实际回归覆盖。 +- 旧线程调用已不在工具列表中的旧名称时,内部 MCP dispatcher 是否仍可接收。 +- 原始请求内容加入 pending 状态是否涉及持久化格式兼容。 +- 正式 MCP 的 `tools/call` 是否在公开 TOOLS 之外保留隐藏旧名(必须保留)。 + +## Trellis 规范结论 + +- 仓库只声明 backend/frontend 两层;本任务属于 backend + 跨层协议,不需要加载前端组件规范。 +- backend error handling 基本为空,现有 `mcpToolError` 行为应作为事实规范保留。 +- backend quality 的已填内容仅针对部分 JSON 预览,与本任务无直接约束。 +- `cross-layer-thinking-guide.md` 要求显式定义工具 Schema → dispatcher → pending 状态 → + 目标运行提示 → 来源续跑提示的完整数据流,并在入口集中校验。 +- `code-reuse-thinking-guide.md` 明确要求相同常量/定义单一来源;支持让 Codex App + 备用工具列表直接派生自正式 MCP 定义。 + +## 资源 + +- `server.js`:跨对话发送、回传、运行提示、dynamic tool 定义。 +- `lib/ccweb-mcp-server.js`:正式 MCP 工具清单与输入 Schema。 +- `scripts/regression.js`:跨对话 MCP 端到端回归。 +- `README.md`:公开工具说明。 diff --git a/.planning/2026-08-11-unify-ccweb-message-reply/progress.md b/.planning/2026-08-11-unify-ccweb-message-reply/progress.md new file mode 100644 index 0000000..f74b174 --- /dev/null +++ b/.planning/2026-08-11-unify-ccweb-message-reply/progress.md @@ -0,0 +1,104 @@ +# 进度日志:统一 ccweb 消息回传接口 + +## 2026-08-11 + +### 阶段 1:隔离规划与并发基线 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:34 CST +- 已执行: + - 读取 `planning-with-files`、`todo-list-csv` 与 Trellis 工作流。 + - 运行 planning session catchup。 + - 检查工作树、全局 planning/Trellis 指针和运行中对话。 + - 确认目标文件存在并发重叠,决定不覆盖全局活动指针。 + - 建立 8 步 `update_plan`。 + - 创建独立 Trellis 任务 `08-11-unify-ccweb-message-reply`,保留现有全局 current-task。 + - 写入 PRD、技术设计、研究基线和 implement/check 上下文;Trellis validate 通过。 + - 并行派发计划审查、影响面研究、测试研究和并发基线研究。 + - 计划审查已通过;采纳“不重启、旧 pending 兼容、双公开列表测试”建议。 + - 确认 `server.js` 已导入正式 `CCWEB_MCP_TOOLS`,决定直接复用而非新增契约文件。 + - 并发审计确认核心安全范围:只改跨对话提示/发送/回传、dynamic tools、工具定义、 + README 和对应回归;保留 task-board、children scope 与 failed-insert 改动。 + +### 阶段 2:契约与失败测试 + +- **状态:** complete +- **开始时间:** 2026-08-11 23:45 CST +- 已执行: + - 固化 `replyMode`、公开工具、旧调用兼容和单一来源方向。 + - 影响面与测试研究已汇总,Trellis context 再次校验通过。 + - 测试审计确认正式 MCP tools/call 需要隐藏旧名 allowlist,已补入 PRD 与测试设计。 + - 契约固化完成,新增 `scripts/ccweb-message-reply-unit.js` 静态契约测试。 + - `node --check scripts/ccweb-message-reply-unit.js` 通过。 + - `node scripts/ccweb-message-reply-unit.js` 以退出码 1 按预期红灯;首个失败为 + 公开 `TOOLS` 仍暴露 `ccweb_request_reply`。 + - 复核四个核心文件 SHA-256,均与隔离基线一致。 +- 已创建/修改: + - `.planning/2026-08-11-unify-ccweb-message-reply/task_plan.md` + - `.planning/2026-08-11-unify-ccweb-message-reply/findings.md` + - `.planning/2026-08-11-unify-ccweb-message-reply/progress.md` + - `.trellis/tasks/08-11-unify-ccweb-message-reply/`(由 Trellis 脚本创建) + +## 测试结果 + +| 测试 | 预期 | 实际 | 状态 | +|---|---|---|---| +| 尚未执行 | 先完成失败测试设计 | 待执行 | pending | +| `node --check scripts/ccweb-message-reply-unit.js` | 语法通过 | 退出码 0 | passed | +| `node scripts/ccweb-message-reply-unit.js` | 当前实现红灯 | 退出码 1,旧工具仍公开 | expected-fail | +| `node --check lib/ccweb-mcp-server.js` | 语法通过 | 退出码 0 | passed | +| `node --check server.js` | 语法通过 | 退出码 0 | passed | +| `node scripts/ccweb-message-reply-unit.js`(实现后) | 契约转绿 | 退出码 0 | passed | +| `node scripts/regression.js --target session-preview-metadata` | 共享定义定向回归通过 | 退出码 0 | passed | +| `npm run regression`(第 1 次) | 暴露兼容提示缺口 | 缺少“子对话”措辞,退出码 1 | fixed | +| `npm run regression`(第 2 次) | 暴露提示强度缺口 | 缺少连续“自动回传”措辞,退出码 1 | fixed | +| `npm run regression`(第 3 次) | 完整回归通过 | 退出码 0,约 49 秒 | passed | +| `git diff --check` | 无空白错误 | 退出码 0 | passed | + +### 阶段 3:实现与文档 + +- **状态:** complete +- **开始时间:** 2026-08-12 00:00 CST +- 当前约束:任务看板对话仍在运行且与四个核心文件重叠;进入生产修改前继续等待其退出, + 避免覆盖并发改动。 +- 并发收敛后已完成: + - 正式 MCP 只公开带必填 `replyMode` 的 `ccweb_send_message`。 + - Codex App fallback 从正式定义派生,旧名只保留隐藏调用兼容。 + - 发送运行时、pending `originalRequest`、两类目标提示和来源续跑提示已实现。 + - README 与新接口一致。 + - 专项契约测试已转绿。 + +### 阶段 4:验证与交付 + +- **状态:** complete +- **开始时间:** 2026-08-12 00:26 CST +- **完成时间:** 2026-08-12 00:34 CST +- 已完成: + - `node --check lib/ccweb-mcp-server.js` 与 `node --check server.js` 均通过。 + - 专项契约测试与 `session-preview-metadata` 定向回归均通过。 + - 完整 `npm run regression` 在 60 秒门禁内通过,耗时约 49 秒。 + - `git diff --check` 通过。 + - 独立 Trellis 验收通过,无阻塞、重大或中等问题,且未修改文件。 + - 确认未覆盖任务看板、children scope、failed-insert-card 与移动端按钮既有改动。 + - 按约束未重启生产 `ccweb` 服务,未修改全局 planning/Trellis 指针。 + +## 错误日志 + +| 时间 | 问题 | 次数 | 处理 | +|---|---|---:|---| +| 2026-08-11 23:34 CST | 目标文件存在并发未提交修改 | 1 | 记录基线,先研究后精确合并 | +| 2026-08-11 23:39 CST | full-history fork 不能同时覆盖 agent_type | 1 | 改用无历史 fork 并显式传入路径和目标 | +| 2026-08-11 23:43 CST | 跨多文件补丁上下文不精确 | 1 | 拆成小型精确补丁完成同步 | +| 2026-08-11 23:47 CST | 第二次跨多文件补丁仍因上下文漂移失败 | 2 | 后续坚持单文件或稳定锚点补丁 | +| 2026-08-12 00:27 CST | 完整回归发现来源续跑缺少旧“子对话”标识 | 1 | 恢复兼容措辞后专项通过 | +| 2026-08-12 00:28 CST | 完整回归发现目标提示“自动把结果回传”不够直接 | 1 | 收紧为连续短语“自动回传结果” | + +## 5 问重启检查 + +| 问题 | 回答 | +|---|---| +| 当前在哪? | 任务已完成并进入交付状态 | +| 接下来去哪? | 无剩余实施步骤 | +| 目标是什么? | 统一公开发送工具并可靠区分是否自动回传 | +| 已了解什么? | 见 `findings.md` | +| 已完成什么? | 隔离规划、契约、失败测试、生产实现、文档、完整回归与独立验收均已闭环 | diff --git a/.planning/2026-08-11-unify-ccweb-message-reply/task_plan.md b/.planning/2026-08-11-unify-ccweb-message-reply/task_plan.md new file mode 100644 index 0000000..b29dc82 --- /dev/null +++ b/.planning/2026-08-11-unify-ccweb-message-reply/task_plan.md @@ -0,0 +1,93 @@ +# 任务计划:统一 ccweb 消息回传接口 + +## 目标 + +将公开跨对话发送能力统一为一个 `ccweb_send_message`,通过必填 `replyMode` +显式选择单向投递或自动回传;旧 `ccweb_request_reply` 仅保留内部兼容,并补齐 +目标提示、来源续跑上下文、文档与回归覆盖。 + +## 当前阶段 + +已完成:专项与完整回归、独立审查及交付清理均已闭环 + +## 可验收步骤 + +1. [x] 建立隔离规划并审查并发改动基线(DONE) +2. [x] 固化统一消息接口契约与兼容策略(DONE) +3. [x] 补充统一接口与提示语义的失败回归测试(DONE) +4. [x] 实现单一公开工具与共享工具定义(DONE) +5. [x] 实现模式化目标提示与来源续跑上下文(DONE) +6. [x] 更新文档并保留旧工具内部兼容(DONE) +7. [x] 运行专项与完整回归并修复问题(DONE) +8. [x] 复核变更并清理临时规划清单(DONE) + +## 阶段 + +### 阶段 1:隔离规划与并发基线 + +- [x] 记录现有脏工作树与重叠文件 +- [x] 确认并发对话边界,不覆盖全局活动计划 +- [x] 完成计划文档审查 +- **状态:** complete + +### 阶段 2:契约与失败测试 + +- [x] 定义必填 `replyMode`、返回结构与兼容行为 +- [x] 先补覆盖正式 MCP/备用工具列表、参数校验、内部旧别名和提示语义的失败测试 +- **状态:** complete + +### 阶段 3:实现与文档 + +- [x] 统一正式 MCP 与 Codex App 备用定义 +- [x] 接入模式化目标提示和带关联信息的来源续跑提示 +- [x] pending reply 保存原始请求,并兼容旧状态缺失该字段 +- [x] 保留旧工具内部路由兼容但不再公开 +- [x] 更新 README +- **状态:** complete + +### 阶段 4:验证与交付 + +- [x] 运行语法、专项测试和完整回归 +- [x] 审查仅本任务引入的差异,确认未覆盖并发改动 +- [x] 清理 TODO CSV 并闭环计划 +- **状态:** complete + +## 关键问题 + +1. 如何在不破坏现有持久对话的前提下停止公开 `ccweb_request_reply`? +2. `replyMode` 如何强制模型在每次调用时明确选择,而不是依赖危险默认值? +3. 如何让目标对话知道是否自动回传,并防止重复手工回传? +4. 来源自动续跑时需要携带哪些关联信息,才能判断返回是否完整? +5. 如何复用单一工具定义,避免正式 MCP 与备用定义再次漂移? + +## 已作决定 + +| 决定 | 理由 | +|---|---| +| 公开接口只保留 `ccweb_send_message` | 真实会话已出现两个相似工具选错的情况 | +| `replyMode` 为必填枚举且无默认值 | 强制调用者显式判断是否需要结果 | +| 初始值采用 `one_way` / `return_and_continue` | 对应当前真实能力,避免虚假的同步等待语义 | +| 旧 `ccweb_request_reply` 仅内部兼容 | 保护已经加载旧工具定义的存量线程 | +| 目标提示按模式生成 | 自动回传模式必须禁止手工重复回传 | +| 来源续跑提示携带 requestId、原请求与完整性检查规则 | 降低多请求混淆和把中间结果当完成的风险 | +| 不修改 `.planning/.active_plan` 与 `.trellis/.current-task` | 它们正被其他运行中对话使用,覆盖会造成并发污染 | + +## 错误与风险记录 + +| 错误或风险 | 次数 | 处理 | +|---|---:|---| +| 工作树已有大量他人改动,且目标文件重叠 | 1 | 先记录基线,等待/协调重叠对话,采用精确补丁 | +| 全局 planning/Trellis 活动指针属于其他任务 | 1 | 使用独立规划目录和显式任务路径,不覆盖指针 | +| 使用 full-history fork 同时指定 explorer 角色被拒绝 | 1 | 改为 `fork_turns: none` 并显式提供完整研究上下文 | +| 一次跨多文件补丁因上下文不精确失败 | 1 | 拆成小型精确补丁,不重复原调用 | + +## 完成标准 + +- 新工具清单不再公开 `ccweb_request_reply`。 +- `ccweb_send_message.replyMode` 必填,并覆盖两种模式。 +- 旧工具名仍可经内部路由执行自动回传。 +- 目标运行提示明确模式,自动回传模式禁止手工重复回传。 +- 来源续跑提示包含关联信息和完整性判断要求。 +- 正式 MCP、Codex App 备用定义与 README 一致。 +- 相关专项测试和完整回归通过,`git diff --check` 通过。 +- 不执行生产 `ccweb` 服务重启。 diff --git a/.trellis/tasks/08-11-failed-insert-card-actions/check.jsonl b/.trellis/tasks/08-11-failed-insert-card-actions/check.jsonl new file mode 100644 index 0000000..cf6f208 --- /dev/null +++ b/.trellis/tasks/08-11-failed-insert-card-actions/check.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/frontend/index.md","reason":"执行前端质量检查清单"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"检查渲染结构、事件归属和可维护性"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查回归覆盖、错误处理和无障碍状态"} +{"file":".trellis/spec/frontend/state-management.md","reason":"检查状态流转和并发保护"} +{"file":".trellis/spec/frontend/type-safety.md","reason":"检查附件状态数据的边界处理"} +{"file":".trellis/tasks/08-11-failed-insert-card-actions/research/failed-insert-chain.md","reason":"逐项核对重试、删除、回滚和回归验收契约"} diff --git a/.trellis/tasks/08-11-failed-insert-card-actions/implement.jsonl b/.trellis/tasks/08-11-failed-insert-card-actions/implement.jsonl new file mode 100644 index 0000000..b431605 --- /dev/null +++ b/.trellis/tasks/08-11-failed-insert-card-actions/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/frontend/index.md","reason":"执行前端预开发清单与完成后质量检查"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"保持失败卡片渲染和事件边界清晰"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"遵循项目测试、错误处理和代码质量要求"} +{"file":".trellis/spec/frontend/state-management.md","reason":"正确管理失败、重试中、成功和删除状态"} +{"file":".trellis/spec/frontend/type-safety.md","reason":"校验附件状态字段和事件数据边界"} +{"file":".trellis/tasks/08-11-failed-insert-card-actions/research/failed-insert-chain.md","reason":"提供已验证的调用链、状态契约、持久化风险和测试范围"} diff --git a/.trellis/tasks/08-11-failed-insert-card-actions/prd.md b/.trellis/tasks/08-11-failed-insert-card-actions/prd.md new file mode 100644 index 0000000..be7a6a4 --- /dev/null +++ b/.trellis/tasks/08-11-failed-insert-card-actions/prd.md @@ -0,0 +1,53 @@ +# 失败插入卡片重试与删除 + +## 背景 + +图片或附件插入失败后,消息内卡片目前只显示“插入失败”,用户既不能在原位重试,也不能清理该失败项,只能重新组织消息或容忍残留卡片。 + +## 目标 + +为失败插入卡片提供“重试”和“删除”两个就地操作,使失败可恢复、残留可清理,同时保持原消息文本和其他附件不变。 + +## 用户故事 + +- 作为发送图片/附件的用户,我可以点击“重试”,沿用原失败项再次执行插入,不必重新选择文件。 +- 作为发送图片/附件的用户,我可以点击“删除”,只移除目标失败项,不影响同一消息中的文本和其他附件。 +- 作为键盘或辅助技术用户,我可以识别按钮用途和重试中的忙碌状态。 + +## 功能要求 + +1. 仅状态为失败的插入卡片展示“重试”和“删除”操作。 +2. 重试必须以失败项的稳定标识为目标,复用现有插入链路及原始附件数据。 +3. 重试开始后卡片进入处理中状态;在请求完成前禁用重复重试和删除,并暴露可访问的忙碌状态。 +4. 重试成功后,卡片按现有成功状态渲染,不再展示失败操作。 +5. 重试再次失败后,卡片恢复失败操作,并显示既有或更明确的失败原因。 +6. 删除只移除目标失败项;同名附件、其他附件、消息文本及其他消息不得受影响。 +7. 操作必须遵循现有状态持久化边界:若失败项已持久化,刷新后仍能正确重试/删除;若现有设计只保存于客户端,则不得伪造跨刷新保证。 +8. 事件处理必须防止重复绑定和并发提交。 + +## 视觉与交互 + +- 两个操作放在失败状态附近,文本分别为“重试”“删除”。 +- “重试”为主要恢复操作,“删除”为克制的危险操作;不引入与当前卡片风格冲突的大面积按钮。 +- 重试期间显示明确的处理中反馈,并设置 `disabled` 与 `aria-busy`(或等价可访问语义)。 +- 按钮应支持键盘聚焦与激活,并具有明确的可访问名称。 + +## 非目标 + +- 不重构整个消息或附件系统。 +- 不删除用户本地原始文件,也不清理与目标失败项无关的服务器资源。 +- 不新增独立的附件管理面板。 +- 不改变成功卡片和进行中卡片的既有操作语义。 + +## 验收标准 + +- 失败卡片可见两个操作,其他状态不可见。 +- 连续快速点击重试只产生一次有效插入请求。 +- 成功、再次失败、删除三个分支均有自动化测试。 +- 删除一项后,同消息中的文字和其他附件保持不变。 +- 定向测试、相关回归和语法/静态检查通过,单次测试最长 60 秒。 +- 最终差异不覆盖工作区中已有的“独立任务看板”改动。 + +## 实施隔离 + +`.trellis/.current-task` 当前属于另一个活动会话。本任务不切换该全局指针;实施与检查代理必须显式读取本目录下的 `prd.md`、上下文清单和研究材料。 diff --git a/.trellis/tasks/08-11-failed-insert-card-actions/research/failed-insert-chain.md b/.trellis/tasks/08-11-failed-insert-card-actions/research/failed-insert-chain.md new file mode 100644 index 0000000..f645c8a --- /dev/null +++ b/.trellis/tasks/08-11-failed-insert-card-actions/research/failed-insert-chain.md @@ -0,0 +1,74 @@ +# 失败插入卡片链路与实现契约 + +## 现状链路 + +```text +sendMessage + → 创建本地用户卡片 + clientMessageId + → WebSocket type=message + → handleMessage + → 活动 Codex App turn 时进入 handleCodexAppSteerMessage + → codex_app_steer_status(pending / inserted / failed) + → handleServerMessage + → updateCodexAppSteerMessage + → setCodexAppSteerStatusElement +``` + +关键位置: + +- `public/app.js`:`sendMessage`、`setCodexAppSteerStatusElement`、`updateCodexAppSteerMessage`、`createMsgElement`、`clearUserMessageIndex`。 +- `public/style.css`:`.codex-steer-*` 状态样式。 +- `server.js`:`handleMessage`、`handleCodexAppSteerMessage`。 +- `scripts/regression.js`:`runCodexAppRuntimeImageSteerRegression`、`runCodexAppStaleRunningRegression`。 + +## 已确认事实 + +1. 本地失败卡片以 `clientMessageId` / `data-message-id` 稳定定位,不应按文件名或 DOM 顺序匹配。 +2. 当前 `userMessageIndex` 只保存内容与时间;重试还需要保存附件、会话、模式和 agent。 +3. 当前状态渲染只有文本,没有操作容器、忙碌语义和并发保护。 +4. 当前服务端在调用 `turn/steer` 前持久化用户消息,最终失败时不回滚,会留下模型未接收的幽灵消息。 +5. 同步前置失败不会持久化;两类失败必须统一为“失败卡片仅存在于当前客户端”的语义。 +6. 本地临时卡片当前提前增加 `currentSessionMessageCount`,失败后会造成消息索引漂移。 + +## 推荐实现 + +### 前端 + +- 新增独立的运行中插入记录 Map,以 `clientMessageId` 保存:元素、文本、附件克隆、sessionId、mode、agent、pending/committed 状态。 +- `clearUserMessageIndex` 同时清理该 Map,防止切换会话后重试旧请求。 +- `setCodexAppSteerStatusElement` 统一维护: + - `role=status`、`aria-live=polite`; + - 卡片 `aria-busy`; + - 失败态的“重试”“删除”按钮; + - 非失败态移除操作容器; + - 按钮使用 `type=button`、明确 `aria-label`,并阻止重复绑定。 +- `retryCodexAppSteerMessage` 只在记录存在、仍属于当前会话、当前为 Codex App 运行中且 WebSocket 可用时发送;先切到 pending 并禁用操作,发送失败则恢复 failed。 +- `deleteCodexAppSteerMessage` 只允许删除 failed 临时项,移除 DOM、两个索引记录并刷新轮廓/滚动条。 +- 临时插入不提前递增 `currentSessionMessageCount`;收到 `inserted` 后才标记 session message 并递增,失败/删除不改变持久化索引。 + +### 服务端 + +- 给待持久化的 steer 用户消息写入稳定 `id`(优先使用 `clientMessageId`)。 +- 最终 `turn/steer` 失败时,在发送 failed 状态前,从最新会话中按该 id 精确移除本次用户消息并保存。 +- stale no-active-turn 自动恢复成功分支不得回滚;同步前置失败和 ready 超时本就没有持久化项。 +- 异步错误消息补带 `clientMessageId`,便于前端和回归关联。 + +## 测试契约 + +- 前端独立单测或可执行契约测试: + - failed 创建两个按钮,pending/inserted 不展示; + - 快速双击重试只发送一次,载荷复用原文本与附件; + - retry 时 `aria-busy=true` 且操作禁用/移除; + - failed 恢复操作; + - delete 只移除目标项及对应索引; + - turn 已结束或 WebSocket 断开时不发送并保留可恢复失败态。 +- 服务端定向回归: + - 现有同步附件失败仍不持久化; + - mismatch 异步失败后同名用户消息为 0; + - stale recovery / 成功 steer 用户消息仍恰好为 1。 +- 静态契约:按钮文本、CSS 作用域和 status handler 保持存在。 + +## 已知限制 + +- 附件已经从服务端过期时,单纯重试仍会失败;按钮主要恢复临时 turn/server 错误。再次失败后必须恢复可操作状态和明确错误提示。 +- 失败项按现有持久化边界保持客户端临时态,刷新后会消失;不新增任意历史消息删除协议。 diff --git a/.trellis/tasks/08-11-failed-insert-card-actions/task.json b/.trellis/tasks/08-11-failed-insert-card-actions/task.json new file mode 100644 index 0000000..4aaac08 --- /dev/null +++ b/.trellis/tasks/08-11-failed-insert-card-actions/task.json @@ -0,0 +1,32 @@ +{ + "id": "failed-insert-card-actions", + "name": "failed-insert-card-actions", + "title": "失败插入卡片重试与删除", + "description": "为 Codex App 插入失败卡片增加重试与删除,并修复失败消息持久化回滚。", + "status": "completed", + "dev_type": "bugfix", + "scope": "frontend,server,regression", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-11", + "completedAt": "2026-08-11", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "public/app.js", + "public/style.css", + "server.js", + "scripts/regression.js", + "scripts/failed-insert-card-unit.js" + ], + "notes": "未切换 .trellis/.current-task;ccweb 重启因其他运行中对话暂缓。", + "meta": {} +} diff --git a/.trellis/tasks/08-11-task-board/check.jsonl b/.trellis/tasks/08-11-task-board/check.jsonl new file mode 100644 index 0000000..89ef9f1 --- /dev/null +++ b/.trellis/tasks/08-11-task-board/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查后端实现的兼容性、错误边界和可维护性"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查任务看板视图的可用性和回归风险"} +{"file":".trellis/tasks/08-11-task-board/research/integration-contract.md","reason":"验证跨模块契约、优先级与文件边界没有漂移"} diff --git a/.trellis/tasks/08-11-task-board/implement.jsonl b/.trellis/tasks/08-11-task-board/implement.jsonl new file mode 100644 index 0000000..6dc22b2 --- /dev/null +++ b/.trellis/tasks/08-11-task-board/implement.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/index.md","reason":"遵循项目后端模块、错误处理和质量约束入口"} +{"file":".trellis/spec/frontend/index.md","reason":"遵循项目现有前端结构与交互约束入口"} +{"file":".trellis/tasks/08-11-task-board/research/integration-contract.md","reason":"三个并行实现必须共享同一数据、状态、错误码和文件边界契约"} diff --git a/.trellis/tasks/08-11-task-board/info.md b/.trellis/tasks/08-11-task-board/info.md new file mode 100644 index 0000000..f2640c5 --- /dev/null +++ b/.trellis/tasks/08-11-task-board/info.md @@ -0,0 +1,195 @@ +# 技术设计与并行边界 + +## 共享模型 + +会话 JSON 新增可选字段: + +```json +{ + "taskTracking": { + "enabled": true, + "statusId": "in_progress", + "baseStatus": "in_progress", + "source": "user", + "reason": "", + "summary": "", + "progress": null, + "reportingStatus": "reported", + "version": 1, + "enabledAt": "ISO-8601", + "disabledAt": null, + "statusUpdatedAt": "ISO-8601", + "completedAt": null, + "archivedAt": null, + "archivedBy": null + } +} +``` + +状态定义: + +```json +{ + "id": "waiting_deploy", + "label": "等待部署", + "baseStatus": "waiting_user", + "description": "实现完成,等待部署环境或发布窗口", + "color": "#f59e0b", + "order": 40, + "system": false, + "enabled": true +} +``` + +## 服务契约 + +MCP 对话负责产出 `lib/task-board-service.js`,导出: + +```text +createTaskBoardService(deps) + .getStatusDefinitions() + .upsertStatusDefinition(input, actor) + .removeStatusDefinition(id, migrateTo, actor) + .listTasks(filters) + .setTracking(sessionId, enabled, actor) + .updateStatus(sessionId, update, actor) + .setArchived(sessionId, archived, actor) + .recordLifecycleEvent(sessionId, event) +``` + +服务通过依赖注入读取/保存会话和状态配置,不直接 import `server.js`。所有修改使用版本号和统一校验。 + +## WebSocket 契约 + +客户端请求: + +- `task_board_query` +- `task_tracking_set` +- `task_status_set` +- `task_status_definition_upsert` +- `task_status_definition_remove` +- `task_archive_set` + +服务端响应/广播: + +- `task_board_result` +- `task_board_event` +- `task_tracking_result` +- `task_status_result` +- `task_status_definitions_result` +- `task_archive_result` + +所有请求携带 `requestId`;所有写响应返回规范化任务、状态定义或稳定错误码。 + +## MCP 契约 + +### ccweb_task_status_list + +无业务参数;来源会话从 MCP 上下文解析。返回 `trackingEnabled`、当前状态和状态定义。 + +### ccweb_task_update + +```json +{ + "statusId": "in_progress", + "reason": "可选,最长 2000 字符", + "summary": "可选,最长 4000 字符", + "progress": 0 +} +``` + +`progress` 范围 0-100。跟踪关闭返回 `task_tracking_disabled`,未知状态返回 `task_status_unknown`。 + +## 生命周期契约 + +Hook 模块只接收事实事件,不自行读写文件: + +```json +{ + "type": "turn_completed", + "turnId": "可选", + "occurredAt": "ISO-8601", + "outcome": "completed", + "pendingUserInput": false, + "hadTaskStatusUpdate": false, + "error": null +} +``` + +规则: + +- 未开启跟踪:忽略。 +- 用户消息:若归档则取消归档;状态进入 `in_progress`。 +- turn started:非终态进入 `in_progress`。 +- structured user input:进入 `waiting_user`。 +- turn failed:进入 `blocked` 并记录错误。 +- turn completed 且本轮无 MCP:保持状态,设置 `reportingStatus=missing`。 +- 本轮已有 MCP:不得被 Hook 覆盖。 + +## 并行文件所有权 + +### 前端对话(只允许修改) + +- `public/task-board.js` +- `public/task-board.css` +- `scripts/task-board-frontend-unit.js` + +不得修改:`public/index.html`、`public/app.js`、`public/style.css`、`server.js`、`scripts/regression.js`。 + +### MCP/状态服务对话(只允许修改) + +- `lib/task-board-service.js` +- `lib/task-board-mcp.js` +- `scripts/task-board-service-unit.js` + +不得修改:`server.js`、`lib/ccweb-mcp-server.js`、`scripts/regression.js`、任何 `public/*` 现有文件。 + +### Hook 对话(只允许修改) + +- `lib/task-board-lifecycle.js` +- `scripts/task-board-lifecycle-unit.js` + +不得修改:`server.js`、`lib/codex-app-runtime.js`、`scripts/regression.js`、任何 `public/*` 文件。 + +### 集成对话(前三路完成后独占) + +可以修改中心文件并调整前三路模块: + +- `server.js` +- `lib/ccweb-mcp-server.js` +- `lib/codex-app-runtime.js`(仅必要时) +- `public/index.html` +- `public/app.js` +- `public/style.css`(优先不改,使用独立 CSS) +- `scripts/regression.js` +- 前三路新模块 + +## 视觉方向 + +- 视觉论点:延续 cc-web 的安静暗色操作界面,以克制的青色主强调和少量状态色构建密集、清晰的横向看板。 +- 内容计划:顶层视图入口与工具条;横向状态工作区;归档视图;状态编辑面板。 +- 交互论点:视图切换轻淡入、卡片悬停与状态变化使用短促位移/颜色过渡、窄屏保持流畅横向滚动。 + +## 验证命令 + +```bash +node --check lib/task-board-service.js +node --check lib/task-board-mcp.js +node --check lib/task-board-lifecycle.js +node --check public/task-board.js +node scripts/task-board-service-unit.js +node scripts/task-board-lifecycle-unit.js +node scripts/task-board-frontend-unit.js +node --check server.js +timeout 60s npm run regression +``` + +视觉验收使用隔离端口和临时配置目录启动测试服务,不提前重启 pm2 生产服务。 + +## 重启门禁 + +1. 调用 `ccweb_list_conversations(status="running")`。 +2. 当前来源对话 ID 固定记录为 `56bb82c6-4fa3-4cb8-bb84-6969e6547d3a`;标题“设计任务看板状态机制”仅作人工交叉核验。 +3. 三个实现对话和集成对话必须全部为 idle,且 `ccweb_list_pending_replies` 无 waiting/delivering。 +4. 任何其他 conversation 状态为 running 都暂停重启并报告。 +5. 满足条件后执行 `pm2 restart ccweb --update-env`,随后检查 pm2 状态和 HTTP/WebSocket 健康。 diff --git a/.trellis/tasks/08-11-task-board/prd.md b/.trellis/tasks/08-11-task-board/prd.md new file mode 100644 index 0000000..0b820cb --- /dev/null +++ b/.trellis/tasks/08-11-task-board/prd.md @@ -0,0 +1,79 @@ +# 独立任务看板 PRD + +## 目标 + +为 cc-web 增加一个与现有聊天功能解耦的任务看板。只有显式开启“加入任务看板”的会话才参与状态跟踪;Agent 可通过 MCP 主动上报,cc-web 生命周期 Hook 在缺少上报时提供保守兜底。完成状态与软归档分离。 + +## 用户场景 + +1. 用户从普通聊天页开启“加入任务看板”,该会话立即出现在看板。 +2. 用户从任务看板创建会话,新会话默认开启任务跟踪。 +3. 用户查看系统固定状态列,并新增、编辑、排序或停用自定义状态。 +4. Agent 调用状态 MCP 上报处理中、等待用户、阻碍、完成或自定义状态。 +5. Agent 没有上报时,cc-web 根据 turn 生命周期和结构化事件更新基础状态或标记“未上报”。 +6. 用户手动归档已完成任务;以后可取消归档。归档不删除会话。 + +## 功能要求 + +### 任务跟踪开关 + +- 普通新对话默认 `taskTracking.enabled=false`。 +- 从任务看板创建的会话默认 `true`。 +- 关闭开关后不进入看板、不运行自动分类;保留已有状态用于审计。 +- Agent 不能通过状态 MCP 偷偷开启、关闭或归档任务。 + +### 状态 + +- 系统固定状态 ID:`unassigned`、`in_progress`、`waiting_user`、`blocked`、`completed`。 +- 系统 ID 和语义不可删除;展示名称、颜色、顺序允许配置。 +- 自定义状态必须映射到一个系统 `baseStatus`,并包含名称、说明、颜色、顺序和启用状态。 +- 删除仍被任务引用的自定义状态前必须指定迁移目标。 +- 状态优先级:用户操作 > MCP 明确上报 > 生命周期 Hook > 默认状态。 + +### MCP + +- `ccweb_task_status_list`:读取当前会话是否启用跟踪及可用状态定义。 +- `ccweb_task_update`:当前来源会话上报 `statusId`、`reason`、`summary`、`progress`。 +- 跟踪关闭时 `ccweb_task_update` 返回 `task_tracking_disabled`,不得自动加入看板。 +- 不提供 Agent 归档 MCP。 + +### 生命周期 Hook + +- 使用 cc-web 内部事件,不修改用户 `.codex/hooks.json`。 +- 处理 `turn_started`、`user_input_requested`、`turn_completed`、`turn_failed`、`user_message_received`。 +- `turn_started` 可进入处理中;结构化用户输入进入等待用户;失败进入阻碍。 +- `turn_completed` 不等于任务完成。若本轮没有 MCP 上报,保留业务状态并标记 `reportingStatus=missing`。 +- 已归档会话收到新用户消息时自动取消归档并进入处理中。 + +### 看板 + +- 提供独立任务看板视图,不替换现有聊天页与会话列表。 +- 支持搜索、会话/Agent 筛选、优先级筛选、横向状态列、归档视图和状态管理面板。 +- 卡片点击打开原会话;运行态和任务态分开显示。 +- 支持桌面横向看板和窄屏横向滚动,不破坏现有响应式布局。 + +### 归档 + +- 归档使用 `archivedAt`/`archivedBy` 软标记,不移动或删除会话文件。 +- 用户可手动归档/取消归档。 +- 自动归档策略保留扩展点,本期不默认启用定时归档。 + +## 非目标 + +- 不修改 Codex Hook 配置。 +- 不把所有历史对话自动加入任务池。 +- 不从自由文本强行推断任务完成。 +- 不删除、移动或迁移现有会话文件。 +- 不在三个并行实现对话中修改中心接线文件。 + +## 验收标准 + +1. 现有普通会话默认不出现在任务看板。 +2. 开关启用后会话可进入看板,关闭后立即退出但状态保留。 +3. 系统状态存在,自定义状态可创建、编辑、排序、停用并安全迁移。 +4. 两个 MCP 工具可发现、可调用,并遵守来源会话和开关边界。 +5. cc-web 生命周期事件正确驱动基础状态,且不会把 turn 完成误判为任务完成。 +6. 归档不删除会话,重新发消息可恢复。 +7. 前端视图与现有 cc-web 风格一致,桌面和窄屏可用。 +8. `node --check`、新模块单元测试和 `timeout 60s npm run regression` 全部通过。 +9. 隔离测试服务完成真实浏览器验收;生产服务仅在重启门禁通过后重启。 diff --git a/.trellis/tasks/08-11-task-board/research/integration-contract.md b/.trellis/tasks/08-11-task-board/research/integration-contract.md new file mode 100644 index 0000000..9816fe7 --- /dev/null +++ b/.trellis/tasks/08-11-task-board/research/integration-contract.md @@ -0,0 +1,38 @@ +# 并行实现共享契约 + +本文件是三个实现对话与最终集成对话的唯一共享协议摘要。实现前必须同时阅读 `prd.md` 和 `info.md`。 + +## 核心不变量 + +1. 普通会话默认不跟踪;`taskTracking.enabled` 是进入任务池的唯一开关。 +2. `archivedAt` 与 `statusId` 分离;归档绝不删除或移动会话。 +3. 系统状态 ID 稳定;自定义状态必须映射到系统 `baseStatus`。 +4. 用户写入优先于 MCP,MCP 优先于 Hook,Hook 不覆盖同轮 MCP。 +5. `turn_completed` 绝不自动等价为 `completed`。 +6. 三个并行对话不得修改中心接线文件;第四个对话统一接线。 + +## 系统状态 + +| ID | 默认标签 | 语义 | +|---|---|---| +| unassigned | 待认领 | 已加入看板但尚未开始 | +| in_progress | 处理中 | 任务仍在推进 | +| waiting_user | 等待确认 | 等待用户输入、验收或外部条件 | +| blocked | 遇到阻碍 | 发生错误或无法继续 | +| completed | 已完成 | 任务业务目标已经完成 | + +## 错误码 + +- `task_tracking_disabled` +- `task_status_unknown` +- `task_status_invalid` +- `task_status_in_use` +- `task_version_conflict` +- `task_session_not_found` + +## 集成原则 + +- 公共服务模块不依赖 `server.js`,由集成层注入 IO。 +- 前端模块通过适配器接收 `send`、`openSession` 和初始数据,不直接依赖现有全局变量。 +- 所有新增消息和工具使用 `task_` / `ccweb_task_` 前缀,避免覆盖现有协议。 +- 单元测试可独立运行,不启动生产 cc-web。 diff --git a/.trellis/tasks/08-11-task-board/task.json b/.trellis/tasks/08-11-task-board/task.json new file mode 100644 index 0000000..2637aa3 --- /dev/null +++ b/.trellis/tasks/08-11-task-board/task.json @@ -0,0 +1,26 @@ +{ + "id": "task-board", + "name": "task-board", + "title": "独立任务看板", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-11", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/check.jsonl b/.trellis/tasks/08-11-unify-ccweb-message-reply/check.jsonl new file mode 100644 index 0000000..d827799 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/index.md","reason":"复核后端改动与项目质量入口"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"复核所有跨层边界和回传数据完整性"} +{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"复核正式 MCP 与备用工具定义没有再次复制漂移"} +{"file":".trellis/tasks/08-11-unify-ccweb-message-reply/research/current-behavior.md","reason":"复核兼容行为和真实失败场景均有覆盖"} diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/implement.jsonl b/.trellis/tasks/08-11-unify-ccweb-message-reply/implement.jsonl new file mode 100644 index 0000000..fe2d58f --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/index.md","reason":"后端规范入口与质量检查索引"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"跨工具 Schema、dispatcher、pending 与运行提示的数据流契约"} +{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"正式 MCP 与备用定义必须复用单一来源"} +{"file":".trellis/tasks/08-11-unify-ccweb-message-reply/research/current-behavior.md","reason":"当前行为、真实误用和兼容边界"} diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/info.md b/.trellis/tasks/08-11-unify-ccweb-message-reply/info.md new file mode 100644 index 0000000..194d475 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/info.md @@ -0,0 +1,48 @@ +# 技术设计:统一 ccweb 消息回传接口 + +## 数据流 + +```text +MCP 工具 Schema + ↓ replyMode +内部 dispatcher + ↓ one_way / expectReply +sendCrossConversationMessage + ├─ one_way → 目标运行,结果留在目标 + └─ return_and_continue + ↓ pending(requestId + originalRequest) + 目标完成 → ready → 来源空闲 + ↓ + 写回展示消息 + 带关联信息的隐藏续跑消息 +``` + +## 建议模块边界 + +- `lib/ccweb-mcp-server.js` + - 继续作为正式工具定义的单一来源。 + - 导出 reply mode 常量;send schema 复用这些常量。 + - 不再公开 request-reply definition。 +- `server.js` + - dynamic tools 从已导入的 `CCWEB_MCP_TOOLS` 按白名单筛选并添加 namespace。 + - dispatcher 保留旧别名。 + - 发送、pending、目标提示和来源续跑接入 reply mode。 + +## 兼容边界 + +- 新 Schema:`replyMode` 必填。 +- 旧 `send_message`:内部缺省归一为 `one_way`。 +- 旧 `request_reply`:内部固定归一为 `return_and_continue`。 +- 旧 MCP/dynamic 客户端:隐藏 allowlist 接受 `ccweb_request_reply`,但工具列表不公开。 +- 旧 pending 数据:`originalRequest` 缺失时来源提示显示明确占位,不失败。 + +## 测试策略 + +先补失败断言,再实现: + +1. 共享定义:send schema 必填 replyMode、enum 正确、旧工具不公开。 +2. `one_way`:目标提示标注不自动回传,不创建 requestId。 +3. `return_and_continue`:创建 requestId,目标提示禁止手工回传,来源收到结果并自动续跑。 +4. 来源续跑:包含 requestId、目标信息、原始请求、完整性判断指令。 +5. 兼容别名:内部 `ccweb_request_reply` 仍建立回传。 +6. 隐藏入口:正式 MCP tools/call 与旧 dynamic call 接受旧名,tools/list 不包含旧名。 +7. 双路径一致:正式 MCP 和 dynamic tools 的 send definition 深度一致(namespace 除外)。 diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/prd.md b/.trellis/tasks/08-11-unify-ccweb-message-reply/prd.md new file mode 100644 index 0000000..140c020 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/prd.md @@ -0,0 +1,98 @@ +# PRD:统一 ccweb 消息回传接口 + +## 背景 + +cc-web 当前同时公开 `ccweb_send_message` 与 `ccweb_request_reply`。两者输入完全 +相同,后者在内部只是为发送函数开启 `expectReply`。真实会话已出现:任务文字明确 +要求“完成后汇报/最终验收”,模型仍选择 `ccweb_send_message`,导致来源对话拿不到 +自动回传。工具描述、Codex App 备用定义、目标运行提示和来源续跑提示也存在语义缺口。 + +## 目标 + +向新模型只公开一个 `ccweb_send_message`,用必填 `replyMode` 明确选择: + +- `one_way`:单向投递,目标本轮输出只留在目标对话。 +- `return_and_continue`:建立异步回传请求;目标完成后把结果写回来源,并触发来源续跑。 + +调用始终立即返回,任何模式都不表达同步阻塞等待。 + +## 功能要求 + +### 1. 公开工具契约 + +- `ccweb_send_message` 的输入必须包含: + - `targetConversationId: string` + - `content: string` + - `replyMode: "one_way" | "return_and_continue"` +- JSON Schema 必须把 `replyMode` 列入 `required`,且不得为新调用提供默认值。 +- 工具描述必须包含强选择规则: + - 仅当来源不需要目标结果时使用 `one_way`。 + - 分析、实现、测试、验收、完成后汇报或来源后续依赖结果时,必须使用 + `return_and_continue`。 +- `ccweb_request_reply` 不再出现在正式 MCP 工具列表或 Codex App 备用工具列表中。 + +### 2. 兼容策略 + +- 内部 dispatcher 继续接受旧工具名 `ccweb_request_reply`,语义固定映射为 + `return_and_continue`,保护已经加载旧工具定义的存量线程。 +- 正式 MCP 的 `tools/call` 与 Codex App 旧 dynamic call 必须继续接受该隐藏旧名; + 它只从 `tools/list`/新 dynamic tools 中移除,不能在 dispatcher 前被 allowlist 拒绝。 +- 内部 dispatcher 可继续把缺少 `replyMode` 的旧 `ccweb_send_message` 调用按 + `one_way` 处理;新公开 Schema 仍必须强制该字段,以避免破坏存量线程。 +- 现有返回字段保持兼容;自动回传模式继续返回 `requestId`、`status: waiting`、 + `replyDelivery` 与 `sourceAutoRun`。 + +### 3. 单一定义来源 + +- 以 `lib/ccweb-mcp-server.js` 导出的正式工具定义作为单一来源: + - 集中定义并导出 reply mode 常量; + - 在正式 `TOOLS` 中定义公开 `ccweb_send_message` 的 description 与 inputSchema。 +- `server.js` 已导入 `CCWEB_MCP_TOOLS`;Codex App 备用工具列表必须从该数组按通信 + 工具白名单筛选并添加 namespace,不能继续复制文案/Schema。 +- 筛选不得把 task-board、图片显示或用户表单等非备用通信工具意外加入 dynamic tools。 + +### 4. 目标对话运行提示 + +- `one_way` 模式必须明确:本轮输出不会自动回传来源。 +- `return_and_continue` 模式必须明确: + - 系统会自动回传本轮最终输出; + - 目标应在真正完成或明确阻塞后给出完整交付; + - 不要为回复本请求而手工调用跨对话发送工具,避免重复。 +- 目标提示继续包含来源标题、来源 ID 和原消息内容。 + +### 5. 来源续跑提示 + +- pending reply 状态需要保留原始请求文本,兼容旧状态缺少该字段。 +- 自动续跑运行文本至少包含: + - `requestId` + - 目标对话标题/ID + - 原始请求 + - 返回正文 +- 提示必须要求来源先判断返回是否完整满足原始请求;不能把“已返回”直接当作“已完成”。 + +### 6. 文档 + +- README 只把 `ccweb_send_message` 列为公开发送工具,并说明两个 `replyMode`。 +- README 可说明旧 `ccweb_request_reply` 仅为内部兼容别名,但不得继续推荐新调用使用。 + +## 非目标 + +- 不实现同步阻塞等待。 +- 不新增第三种回传模式。 +- 不重构跨对话 pending reply 的整体持久化机制。 +- 不在本任务中解决所有“turn 完成是否等于任务完成”的协议问题;只通过提示与来源完整性检查降低误判。 +- 不修改任务看板、失败插入卡片等并发任务的功能。 +- 不重启生产 `ccweb` 服务。 + +## 验收标准 + +1. 新工具清单中存在一个带必填 `replyMode` 的 `ccweb_send_message`,不存在公开 + `ccweb_request_reply`。 +2. 正式 MCP 与 Codex App 备用定义引用同一个共享契约。 +3. 两种新模式均有端到端回归;自动回传行为与现状兼容。 +4. 旧 `ccweb_request_reply` 通过隐藏兼容入口(MCP tools/call、旧 dynamic call、 + 内部 dispatcher)仍成功,但不出现在公开列表。 +5. 目标提示和来源续跑提示具有上述模式与关联信息。 +6. README 与实现一致。 +7. 语法检查、相关专项测试、完整回归和 `git diff --check` 通过。 +8. 实施不得覆盖任务开始前已有的并发改动。 diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/research/concurrency-baseline.md b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/concurrency-baseline.md new file mode 100644 index 0000000..76d5ea4 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/concurrency-baseline.md @@ -0,0 +1,25 @@ +# 并发改动基线 + +## 当前并发任务 + +- 任务看板:占用全局 `.planning/.active_plan` 与 `.trellis/.current-task`,仍在运行。 +- 失败插入卡片:有 `server.js`/前端/回归改动,当前对话显示运行中或刚完成。 +- `ccweb_list_conversations` children scope:已完成但改动仍未提交。 +- 本任务:只允许精确修改跨对话 MCP、提示、README 与相关回归,不覆盖上述改动。 + +## 高冲突文件与安全范围 + +| 文件 | 本任务安全范围 | 必须保留 | +|---|---|---| +| `server.js` | 5733-5777 提示、5796-6103 跨对话发送/回传、10221-10410 dynamic tools、dispatcher 对应 case | task-board lifecycle、children scope、failed-insert rollback | +| `lib/ccweb-mcp-server.js` | send/request 工具定义与导出常量 | children scope schema、task-board spread、其他 MCP 工具 | +| `scripts/regression.js` | 6392-6524、7349 附近跨对话断言及工具列表断言 | task-board、失败插入和 children scope 回归 | +| `README.md` | MCP 工具表与跨对话说明 | children scope 文档与其他功能说明 | + +## 实施纪律 + +1. 修改前重新查看目标函数当前文本,避免使用旧行号/旧上下文套补丁。 +2. 不做全文件格式化或 import 重排。 +3. 每个阶段检查 `git diff`,只确认本任务新增的 hunk。 +4. 不重启生产服务;验证使用隔离测试进程。 + diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/research/current-behavior.md b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/current-behavior.md new file mode 100644 index 0000000..a2434b5 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/current-behavior.md @@ -0,0 +1,25 @@ +# 当前行为与缺口 + +## 当前实现 + +- `requestCrossConversationReply()` 是 `sendCrossConversationMessage()` 的 `expectReply` + 包装,因此公开工具合并不需要新增通信机制。 +- 正式 MCP 与 Codex App 备用 dynamic tools 各自复制 description 和 inputSchema。 +- `server.js` 已经导入正式 `CCWEB_MCP_TOOLS`,可以直接按白名单派生备用定义, + 无需增加新的契约模块。 +- pending reply 已持久化 requestId、来源/目标、hopCount 和 replyText,可向后兼容地新增 + `originalRequest`。 +- 来源忙碌时 ready reply 会保留,空闲后由 flush 逻辑投递;该行为必须保持。 + +## 已观察失败 + +- 模型在明确要求“完成后汇报/最终验收”的任务上调用了单向发送工具。 +- 同一模型此前使用过自动回传工具,说明仅靠两个工具名称仍不能稳定表达选择规则。 +- 某次自动回传正文只是“完成定位、准备修改”,目标后来继续执行;来源续跑需要明确检查完整性。 + +## 设计结论 + +- 工具选择必须从“选两个近似工具”改为“调用一个工具时填写必填枚举”。 +- 新公开契约不提供默认值;内部兼容层可宽容旧调用。 +- 目标与来源的运行提示都必须携带通信模式和关联上下文。 +- 提示优化无法完全替代任务完成协议;本任务不扩大到生命周期重构。 diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/research/impact-map.md b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/impact-map.md new file mode 100644 index 0000000..fe109b5 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/impact-map.md @@ -0,0 +1,361 @@ +# ccweb 消息回传统一影响面映射 + +## 结论 + +本任务的核心影响面集中在 3 条链路: + +1. 公开工具契约:`lib/ccweb-mcp-server.js` 的 `TOOLS` 当前同时公开 + `ccweb_send_message` 与 `ccweb_request_reply`,`server.js` 的 Codex App + fallback dynamic tools 又复制了一份定义。 +2. 内部兼容分发:`server.js` 的 `callInternalMcpTool()` 已经把两个工具名分别 + 分发到 `sendCrossConversationMessage()` 和 `requestCrossConversationReply()`; + 旧别名兼容应保留在这里。 +3. 自动回传状态机:`sendCrossConversationMessage()` 通过 `expectReply` 创建 + pending reply;目标完成后由 `completeCrossConversationReply()`、`deliverCrossConversationReply()` + 写回来源并触发来源自动续跑。 + +`ccweb_request_reply` 不能简单从 `TOOLS` 删除后收工,因为当前正式 MCP 的 +`tools/call` 白名单也复用 `TOOLS`。如果公开列表不再包含旧名,就必须把 +“公开 tools/list” 与 “tools/call 内部兼容旧名” 分离。 + +## 代码索引状态 + +- `codebase-memory-mcp` 项目:`home-cc-web` +- 索引状态:`ready` +- 代码图节点/边:`5941` / `13063` + +## 文件影响清单 + +| 文件 | 当前职责 | 本任务影响 | +|---|---|---| +| `lib/ccweb-mcp-server.js` | 正式 MCP stdio 服务器、正式 `TOOLS` 定义、`tools/list` 和 stdio `tools/call` 白名单 | 新增/调整共享契约导出;`ccweb_send_message` 必填 `replyMode`;公开列表移除 `ccweb_request_reply`;stdio `tools/call` 仍需兼容旧名 | +| `server.js` | 导入 `CCWEB_MCP_TOOLS`、共享 HTTP MCP、内部 dispatcher、跨对话发送/回传、Codex App fallback dynamic tools、pending 持久化 | 复用共享定义;`ccweb_send_message` 按 `replyMode` 归一到 `expectReply`;旧 `ccweb_request_reply` 兼容;目标/来源提示增加模式和原始请求;pending 状态新增 `originalRequest` | +| `README.md` | MCP 工具用户文档 | 公开工具表只推荐 `ccweb_send_message`,说明 `replyMode=one_way/return_and_continue`;旧 `ccweb_request_reply` 只标内部兼容别名 | +| `scripts/regression.js` | 端到端回归覆盖 MCP 发送、回传、busy source 队列、Codex App running target | 现有 `ccweb_request_reply` 测试改为兼容测试;新增 `replyMode` 双模式、公开列表、正式 MCP/dynamic tools 定义一致性、提示内容断言 | +| `public/app.js` | 跨对话 reply 展示、折叠状态、ready count 前端消费 | 本任务通常不改;但回归会间接受到 returned reply metadata 影响 | + +## 正式 MCP 定义链路 + +### 当前位置 + +- `lib/ccweb-mcp-server.js:15` 定义 `const TOOLS = [...]`。 +- `lib/ccweb-mcp-server.js:110` 定义公开 `ccweb_send_message`。 +- `lib/ccweb-mcp-server.js:159` 定义公开 `ccweb_request_reply`。 +- `lib/ccweb-mcp-server.js:445` 在 stdio `tools/list` 返回 `{ tools: TOOLS }`。 +- `lib/ccweb-mcp-server.js:450` 在 stdio `tools/call` 用 `TOOLS.some(...)` 做白名单。 +- `lib/ccweb-mcp-server.js:503` 导出 `{ TOOLS, prepareImagePayload, runStdioServer }`。 + +### 需要调整 + +- 新建或拆出无副作用共享契约: + - reply mode 常量:`one_way`、`return_and_continue`。 + - 校验集合或归一函数。 + - `ccweb_send_message` 的 description 与 inputSchema。 +- `ccweb_send_message.inputSchema.required` 必须包含: + - `targetConversationId` + - `content` + - `replyMode` +- `replyMode` schema 必须是 enum: + - `one_way` + - `return_and_continue` +- `ccweb_request_reply` 不再出现在正式 `tools/list`。 +- `tools/call` 兼容旧名时不要再依赖公开 `TOOLS` 作为唯一白名单。可选策略: + - `PUBLIC_TOOLS` 用于 `tools/list`。 + - `CALLABLE_TOOL_NAMES` 或 `isCallableMcpTool(name)` 用于 `tools/call`,包含旧 + `ccweb_request_reply`。 + +## Codex App fallback dynamic tools 链路 + +### 当前位置 + +- `server.js:15` 导入 `TOOLS: CCWEB_MCP_TOOLS`。 +- `server.js:10221` 定义 `codexAppCommunicationDynamicTools()`。 +- `server.js:10273` 复制定义 `ccweb_send_message`,当前无 `replyMode`。 +- `server.js:10358` 复制定义 `ccweb_request_reply`。 +- `server.js:10391` 定义 `handleCodexAppDynamicToolCall()`。 +- `server.js:10395-10402` Codex App dynamic tool 白名单仍包含 `ccweb_request_reply`。 +- `server.js:10407` 调用 `callInternalMcpTool()`。 + +### 需要调整 + +- `codexAppCommunicationDynamicTools()` 不应再复制 send 的 description/schema。 +- 建议从共享正式定义派生 Codex App fallback 定义: + - 保留 `namespace: 'ccweb'`。 + - 复用同一 `description` 和 `inputSchema`。 + - 不包含 `ccweb_request_reply`。 +- `handleCodexAppDynamicToolCall()` 要区分“公开 fallback tool”与“旧线程动态调用兼容”: + - 新公开列表不含 `ccweb_request_reply`。 + - 已加载旧 dynamic tool 的线程调用 `ccweb_request_reply` 时,内部仍可接受并映射到 + `return_and_continue`。 + +## Composer MCP 候选链路 + +### 当前位置 + +- `server.js:2536` 定义 `listComposerMcpItems()`。 +- `server.js:2555` 遍历 `CCWEB_MCP_TOOLS` 生成 `/` 里的 `mcp:ccweb/` 候选。 + +### 需要调整 + +- 如果 `CCWEB_MCP_TOOLS` 改为公开 tools/list,则 Composer 会自动不再展示 + `mcp:ccweb/ccweb_request_reply`。 +- 必须确认 `/` 候选里 `mcp:ccweb/ccweb_send_message` 的描述与 schema 语义一致。 +- 不要从内部兼容白名单反推 Composer 候选,否则会重新暴露旧工具。 + +## 内部 dispatcher 链路 + +### 当前位置 + +- `server.js:6084` 定义 `callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount)`。 +- `server.js:6094-6095`:`ccweb_send_message` 直接调用 + `sendCrossConversationMessage(args, sourceSessionId, sourceHopCount)`。 +- `server.js:6100-6101`:`ccweb_request_reply` 调用 + `requestCrossConversationReply(args, sourceSessionId, sourceHopCount)`。 +- `server.js:5915-5917`:`requestCrossConversationReply()` 只是 + `sendCrossConversationMessage(..., { expectReply: true })`。 + +### 需要调整 + +- `callInternalMcpTool()` 是兼容旧工具名的最佳位置。 +- 新逻辑建议: + - `ccweb_request_reply` 固定归一为 `replyMode=return_and_continue`。 + - `ccweb_send_message` 读取 `args.replyMode`。 + - 缺少 `replyMode` 的旧 `ccweb_send_message` 内部按 `one_way` 处理。 + - 非法 `replyMode` 返回 `mcpToolError('invalid_reply_mode', ...)`。 +- 保持返回字段兼容: + - `one_way` 返回现有 `messageId`、`deliveryStatus` 等。 + - `return_and_continue` 继续返回 `requestId`、`status: waiting`、 + `replyDelivery`、`sourceAutoRun`。 + +## 正式 MCP HTTP 与 stdio 入口 + +### 共享 HTTP MCP + +- `server.js:6145` 定义 `handleMcpJsonRpcMessage()`。 +- `server.js:6161` 在 `tools/list` 返回 `CCWEB_MCP_TOOLS`。 +- `server.js:6165` 在 `tools/call` 用 `CCWEB_MCP_TOOLS.some(...)` 做白名单。 +- `server.js:6168-6174` 调用 `callInternalMcpTool()`。 +- `server.js:6188-6227` 定义 `handleSharedMcpHttpApi()`,从 URL query 读取 + `sourceSessionId`、`sourceHopCount` 后调用 `handleMcpJsonRpcMessage()`。 + +### 旧内部 HTTP API + +- `server.js:6230` 定义 `handleInternalMcpApi()`。 +- `server.js:6243-6247` 从 body 读取 `tool/args/sourceSessionId/sourceHopCount` + 并直接调用 `callInternalMcpTool()`。 +- `scripts/regression.js` 当前大量通过该内部 API 调用工具。 + +### stdio MCP 桥 + +- `lib/ccweb-mcp-server.js:327` 定义 `callCcweb(tool, args)`。 +- `lib/ccweb-mcp-server.js:352-357` 将 `tool/args/sourceSessionId/sourceHopCount` + POST 到内部 API。 +- `lib/ccweb-mcp-server.js:425` 定义 stdio `handleRequest()`。 +- `lib/ccweb-mcp-server.js:445` 返回 `TOOLS`。 +- `lib/ccweb-mcp-server.js:450` 当前用 `TOOLS` 做 stdio call 白名单。 + +### 关键风险 + +正式 `tools/list` 删除旧工具后: + +- `handleMcpJsonRpcMessage()` 的 `tools/call` 会拒绝旧 `ccweb_request_reply`。 +- stdio `handleRequest()` 的 `tools/call` 也会拒绝旧 `ccweb_request_reply`。 + +因此兼容旧工具名必须覆盖两个 `tools/call` 白名单,而不是只改 +`callInternalMcpTool()`。 + +## 发送与回传状态机 + +### 单向发送 / 创建 pending + +- `server.js:5808` 定义 `sendCrossConversationMessage()`。 +- `server.js:5814-5815` 由 `options.expectReply` 推导 `expectReply/sourceAutoRun`。 +- `server.js:5849` `expectReply` 为真时生成 `requestId`。 +- `server.js:5857-5874` 写入目标 user message 的 `crossConversation` metadata, + 并通过 `setPendingCrossConversationReply()` 创建 pending。 +- `server.js:5883-5887` 调用 `handleMessage()`,目标 runtimeText 来自 + `buildCrossConversationRuntimeText()`。 +- `server.js:5906-5911` 仅在 `requestId` 存在时返回 `status/replyDelivery/sourceAutoRun`。 + +### pending 持久化 + +- `server.js:222` pending 文件是 `config/cross-conversation-replies.json`。 +- `server.js:4376-4402` `normalizeCrossConversationReplyState()` 归一状态,当前字段包括: + `requestId/messageId/sourceConversationId/sourceTitle/targetConversationId/targetTitle/status/createdAt/hopCount/sourceAutoRun/replyText/completedAt/returnedAt/replyMessageId/lastError`。 +- `server.js:4419-4422` `saveCrossConversationReplies()` 写入文件。 +- `server.js:4430-4439` `loadCrossConversationReplies()` 启动加载未 returned 的 pending。 +- `server.js:4447-4452` `setPendingCrossConversationReply()` 创建 pending。 +- `server.js:4455-4468` `updatePendingCrossConversationReply()` 更新 pending。 +- `server.js:4471-4475` `deletePendingCrossConversationReply()` 删除 pending。 + +### 需要新增字段 + +- PRD 要求 pending reply 状态保留原始请求文本,建议字段名使用 + `originalRequest`。 +- 写入点:`sendCrossConversationMessage()` 创建 pending 的对象。 +- 归一点:`normalizeCrossConversationReplyState()`,旧状态缺失时填 `''` 或明确占位。 +- 输出点: + - `crossConversationReplySummary()` + - `getPendingCrossConversationReply()` + - `buildCrossConversationReplyAutoRunText()` + +### 目标完成与来源写回 + +- `server.js:9257` 普通 Codex 运行 entry 记录 `crossConversationReplyRequestId`。 +- `server.js:6796-6799` 普通 Codex 完成后调用 + `completeCrossConversationReply()`,再 flush 来源 pending。 +- `server.js:11158` Codex App 运行 entry 记录 `crossConversationReplyRequestId`。 +- `server.js:11322-11326` Codex App 完成后调用 + `completeCrossConversationReply()`,再 flush 来源 pending。 +- `server.js:6060-6081` `completeCrossConversationReply()` 提取目标输出,更新 + pending 为 `ready`,随后调用 `deliverCrossConversationReply()`。 +- `server.js:5969-6048` `deliverCrossConversationReply()`: + - 来源不存在或目标不存在时改为 `failed`。 + - 来源仍 running 时保持 `ready`,稍后 flush。 + - 已处理过则标记 returned 并删除 pending。 + - 追加 `ccwebDisplayOnly` assistant 消息到来源。 + - metadata 写入 `replyToRequestId/processed/autoRun`。 + - `sourceAutoRun` 为真时调用 `startCrossConversationReplyAutoRun()`。 +- `server.js:6050-6057` `flushPendingCrossConversationReplies()` 在来源空闲后投递 ready reply。 + +## 目标运行提示 + +### 当前位置 + +- `server.js:5733-5737` `buildCrossConversationRuntimeText(sourceSession, content)`。 +- 当前只包含来源标题、来源 ID、消息正文。 +- `server.js:5886` `sendCrossConversationMessage()` 发送目标运行时使用该提示。 + +### 需要调整 + +`buildCrossConversationRuntimeText()` 需要知道模式,建议参数扩展为: + +- `sourceSession` +- `content` +- `replyMode` 或 `{ replyMode, requestId }` + +提示必须覆盖: + +- `one_way`:明确本轮输出不会自动回传来源。 +- `return_and_continue`: + - 系统会自动回传本轮最终输出。 + - 目标应在真正完成或明确阻塞后给出完整交付。 + - 不要为回复本请求而手工调用跨对话发送工具,避免重复。 +- 继续保留来源标题、来源 ID、原消息正文。 + +## 来源自动续跑提示 + +### 当前位置 + +- `server.js:5744-5748` `buildCrossConversationReplyAutoRunText(targetSession, replyText)`。 +- `server.js:5750-5777` `startCrossConversationReplyAutoRun()` 使用该 runtimeText 触发来源隐藏续跑。 +- 当前提示只包含目标标题和返回正文。 + +### 需要调整 + +`buildCrossConversationReplyAutoRunText()` 需要额外输入 pending 或关联上下文,至少包含: + +- `requestId` +- 目标对话标题/ID +- 原始请求 `originalRequest` +- 返回正文 `replyText` +- 明确要求来源先判断返回是否完整满足原始请求,不能把“已返回”直接当作“已完成”。 + +建议让 `deliverCrossConversationReply()` 把 `pending` 或 `{ requestId, originalRequest }` +传给 `startCrossConversationReplyAutoRun()`,再传给 +`buildCrossConversationReplyAutoRunText()`。 + +## `ccweb_create_conversation` 间接受影响 + +### 当前位置 + +- `server.js:5646` 定义 `createMcpConversation()`。 +- `server.js:5664` 兼容 `args.requestReply === true || args.waitForReply === true`。 +- `server.js:5694-5697` 创建后首条消息调用 + `sendCrossConversationMessage(..., { expectReply: requestReply })`。 +- `server.js:5723-5728` 如果有 `requestId`,返回 `replyStatus/replyDelivery/sourceAutoRun`。 +- `lib/ccweb-mcp-server.js:101` 正式 `ccweb_create_conversation` schema 公开 `requestReply`。 +- `server.js:10349` Codex App fallback schema 也公开 `requestReply`。 + +### 需要保持 + +PRD 不要求修改 `ccweb_create_conversation` 的参数语义。实现 `replyMode` 时必须确保: + +- `requestReply=true` 仍创建 pending reply。 +- 返回字段仍兼容现有断言。 +- 目标提示也应按 `return_and_continue` 模式生成,因为底层仍是跨对话发送。 + +## 前端展示影响 + +前端不参与工具契约选择,但会消费返回消息 metadata: + +- `public/app.js:1481-1489` `getCrossConversationReplyCollapseKey()` 使用 + `replyToRequestId/messageId` 作为折叠 key。 +- `public/app.js:7135-7144` 收到 `session_message` 时,如果是 + `replyToRequestId`,会减少 ready reply count。 +- `public/app.js:7968-7974` `createMsgElement()` 用 `reply/replyToRequestId` + 标记跨对话回复气泡。 + +本任务如果只新增 `originalRequest/replyMode` metadata,前端无需改;如果改变 +`replyToRequestId/processed/ccwebDisplayOnly` 字段,则会影响展示和 ready count。 + +## 回归测试影响面 + +### 现有相关断言 + +- `scripts/regression.js:6348-6386` 覆盖 `ccweb_create_conversation` + + `requestReply=true` 自动回传。 +- `scripts/regression.js:6392-6415` 覆盖 `ccweb_send_message` 单向发送与目标 runtime prompt。 +- `scripts/regression.js:6417-6434` 覆盖跨对话 hop count。 +- `scripts/regression.js:6440-6490` 覆盖旧 `ccweb_request_reply` 自动回传。 +- `scripts/regression.js:6503-6578` 覆盖来源 busy 时 ready reply 排队、pending list/detail、 + 来源空闲后 flush 和 auto-run。 +- `scripts/regression.js:7348-7357` 覆盖目标 Codex App running 时拒绝发送。 + +### 需要新增/调整断言 + +- `tools/list` 中: + - 存在 `ccweb_send_message`。 + - `ccweb_send_message.inputSchema.required` 包含 `replyMode`。 + - `replyMode.enum` 为 `['one_way', 'return_and_continue']`。 + - 不存在公开 `ccweb_request_reply`。 +- 正式 MCP 与 Codex App fallback: + - send definition 共用同一 schema/description。 + - Codex App fallback 不公开 `ccweb_request_reply`。 +- `ccweb_send_message(replyMode='one_way')`: + - 不返回 `requestId`。 + - 不创建 pending。 + - 目标 runtime prompt 明确不会自动回传。 +- `ccweb_send_message(replyMode='return_and_continue')`: + - 返回 `requestId/status/replyDelivery/sourceAutoRun`。 + - 目标消息 metadata 有 `expectsReply/replyRequestId`。 + - pending 文件有 `originalRequest`。 + - 目标 runtime prompt 明确会自动回传并禁止手工重复发送。 + - 来源 auto-run prompt 包含 `requestId`、目标标题/ID、原始请求、返回正文、完整性判断指令。 +- 兼容: + - 内部 `ccweb_request_reply` 仍成功并等价于 `return_and_continue`。 + - 旧 `ccweb_send_message` 缺少 `replyMode` 仍按 `one_way` 成功。 + - stdio/shared HTTP `tools/call` 对旧 `ccweb_request_reply` 仍可执行,即使 `tools/list` + 不再公开它。 + +## 建议实现顺序 + +1. 抽出共享契约,定义 reply modes 和公开 `ccweb_send_message` definition。 +2. 让正式 MCP `tools/list` 和 Codex App fallback 复用共享 send definition。 +3. 分离公开工具列表与内部可调用工具白名单,保证旧 `ccweb_request_reply` 可 call 不可 list。 +4. 在 dispatcher 或发送函数中归一 `replyMode -> expectReply`。 +5. pending 增加 `originalRequest`,归一、保存、查询和旧数据兼容。 +6. 改目标 runtime prompt 和来源 auto-run prompt。 +7. 更新 README。 +8. 更新回归断言,覆盖正式 MCP、dynamic fallback、旧兼容和 busy source 队列。 + +## 主要风险 + +- 如果只删除 `TOOLS` 中的 `ccweb_request_reply`,旧线程的正式 MCP 调用会被 + `tools/call` 白名单挡住,达不到 PRD 的内部兼容要求。 +- 如果只改 `lib/ccweb-mcp-server.js`,Codex App fallback dynamic tools 仍会暴露旧工具。 +- 如果 `replyMode` 在 schema 必填但内部没有兼容缺省,已加载旧 schema 的 + `ccweb_send_message` 调用可能被拒绝。 +- 如果来源 auto-run prompt 仍只包含“已返回”,模型可能把不完整返回误判为任务完成。 +- 如果 pending 删除过早,`get_pending_reply` 对 returned 历史查询会依赖来源消息里的 + `replyToRequestId`,因此不能破坏 `processed/replyToRequestId/ccwebDisplayOnly`。 diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/research/test-map.md b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/test-map.md new file mode 100644 index 0000000..f9ed329 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/research/test-map.md @@ -0,0 +1,40 @@ +# 测试影响图 + +## 最小覆盖层次 + +1. **公开契约**:正式 `TOOLS/tools/list` 与 Codex App 备用列表只公开一个 + `ccweb_send_message`;`replyMode` 必填且 enum 精确。 +2. **统一发送 E2E**:`one_way` 不建 request;`return_and_continue` 建 request、 + 自动回传、来源忙碌时排队。 +3. **隐藏兼容**:旧 `ccweb_request_reply` 不在 tools/list,但正式 MCP `tools/call`、 + Codex App 旧 dynamic call 和内部 dispatcher 仍接受。 +4. **提示语义**:目标提示区分模式;来源续跑包含 requestId、目标、原请求和完整性检查。 + +## 现有可复用入口 + +- `scripts/regression.js` 已有 `TOOLS` 静态断言、server 源码片段断言和完整跨对话 E2E。 +- `callInternalMcp`、`nextMessage`、`waitForJsonCondition` 可复用。 +- 现有单向发送、request-reply、busy source 三段应改为: + - 公开单向:`ccweb_send_message(replyMode='one_way')`; + - 公开回传:`ccweb_send_message(replyMode='return_and_continue')`; + - 旧别名:单独保留一条隐藏兼容用例。 + +## 必须先失败的断言 + +- `TOOLS` 不包含 `ccweb_request_reply`。 +- send schema 的 required 包含 `replyMode`,enum 为 + `['one_way', 'return_and_continue']`。 +- send description 含“仅当来源不需要结果才 one_way / 依赖结果必须 return”。 +- Codex App 备用列表与正式通信定义一致,且无旧公开工具。 +- `return_and_continue` 返回 waiting requestId。 +- 自动回传目标提示禁止手工重复回传。 +- 来源 auto-run 文本包含 requestId、目标 ID、原始请求和完整性判断规则。 +- hidden legacy allowlist 仍允许 `ccweb_request_reply`。 + +## 关键风险 + +- `lib/ccweb-mcp-server.js` 的 JSON-RPC `tools/call` 当前使用公开 `TOOLS` allowlist。 + 直接删除定义会让已缓存旧工具 schema 的客户端在 dispatcher 前失败;必须增加隐藏兼容 allowlist。 +- 只测 Schema 不能证明 dispatcher 使用 replyMode,必须保留 E2E。 +- 完整 regression 覆盖面很大,TDD 循环应优先使用可聚焦的静态/跨对话目标,最终再跑完整回归。 + diff --git a/.trellis/tasks/08-11-unify-ccweb-message-reply/task.json b/.trellis/tasks/08-11-unify-ccweb-message-reply/task.json new file mode 100644 index 0000000..7555676 --- /dev/null +++ b/.trellis/tasks/08-11-unify-ccweb-message-reply/task.json @@ -0,0 +1,26 @@ +{ + "id": "unify-ccweb-message-reply", + "name": "unify-ccweb-message-reply", + "title": "统一 ccweb 消息回传接口", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-11", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index f4059f9..8b36e86 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,9 @@ ccweb 会给 Codex / Codex App 会话注入内置 MCP 配置,让 Agent 可以 | MCP 工具 | 用途 | |----------|------| -| `ccweb_list_conversations` | 列出可投递的 ccweb 对话,只返回轻量元数据 | +| `ccweb_list_conversations` | 列出可投递的 ccweb 对话;默认 `scope=all`,使用 `scope=children` 时只返回当前来源直接创建的持久子对话 | | `ccweb_create_conversation` | 创建新的 ccweb 持久对话,可指定 `cwd`、标题、模式和首条消息 | -| `ccweb_send_message` | 给指定对话投递消息,目标对话显示“来自某对话”的气泡 | -| `ccweb_request_reply` | 投递消息并等待目标对话本轮完成后返回结果 | +| `ccweb_send_message` | 给指定对话投递消息;必填 `replyMode=one_way` 时仅单向投递,`replyMode=return_and_continue` 时在目标本轮完成后自动回传并继续来源对话 | | `ccweb_list_pending_replies` | 查看当前来源对话等待中的跨对话回复 | | `ccweb_get_pending_reply` | 按 `requestId` 读取等待回复的状态和正文 | @@ -79,7 +78,10 @@ ccweb 会给 Codex / Codex App 会话注入内置 MCP 配置,让 Agent 可以 - **一次性并行研究** 优先使用 Codex App 原生子代理 - **需要长期可追踪、可继续打开聊天的任务** 使用 `ccweb_create_conversation` +- `ccweb_list_conversations` 的 `scope=children` 只匹配当前来源通过 `ccweb_create_conversation` 直接创建的对话,不包含分支会话或更深层后代 - `ccweb_create_conversation` 默认继承来源对话 Agent;未显式传 `mode` 时默认 `yolo` +- `ccweb_send_message` 的两种模式都会立即返回,不会同步等待目标对话;仅当来源后续不需要目标结果时使用 `one_way`,分析、实现、测试、验收或完成后需要汇报时使用 `return_and_continue` +- `ccweb_request_reply` 仅作为已加载旧工具定义的存量线程内部兼容别名保留,新调用不要再使用 - 跨对话返回内容会以只读展示消息写回来源对话,避免再次触发来源模型浪费 token ### 子代理状态展示 diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index c2936d7..1dda6e9 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-07-06T09:51:06.234Z", + "updatedAt": "2026-08-12T01:18:26.586Z", "replies": [] } \ No newline at end of file diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index e1966e9..4592af2 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 17b56e4..3525b3b 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -11,6 +11,24 @@ const SERVER_INFO = { version: '1.0.0', }; +const CCWEB_REPLY_MODES = Object.freeze({ + ONE_WAY: 'one_way', + RETURN_AND_CONTINUE: 'return_and_continue', +}); +const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时,必须使用 return_and_continue。工具调用会立即返回,不阻塞也不等待目标对话完成。'; +const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([ + 'ccweb_list_conversations', + 'ccweb_set_title', + 'ccweb_create_conversation', + 'ccweb_send_message', + 'ccweb_list_pending_replies', + 'ccweb_get_pending_reply', +]); +const HIDDEN_CALLABLE_TOOL_NAMES = new Set([ + 'ccweb_request_reply', + 'ccweb_task_update', +]); + const TOOLS = [ { name: 'ccweb_display_image', @@ -28,10 +46,15 @@ const TOOLS = [ }, { name: 'ccweb_list_conversations', - description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。', + description: '列出当前 ccweb 中可投递消息的对话。默认返回全部对话;scope=children 时只返回当前来源对话通过 ccweb_create_conversation 直接创建的持久子对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。', inputSchema: { type: 'object', properties: { + scope: { + type: 'string', + enum: ['all', 'children'], + description: '可选。all 返回全部对话;children 只返回当前来源对话直接创建的持久子对话,默认 all。', + }, agent: { type: 'string', enum: ['claude', 'codex', 'codexapp'], @@ -102,7 +125,7 @@ const TOOLS = [ }, { name: 'ccweb_send_message', - description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。', + description: CCWEB_SEND_MESSAGE_DESCRIPTION, inputSchema: { type: 'object', properties: { @@ -114,8 +137,13 @@ const TOOLS = [ type: 'string', description: '要发送到目标对话的纯文本消息。', }, + replyMode: { + type: 'string', + enum: Object.values(CCWEB_REPLY_MODES), + description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话;return_and_continue 表示目标完成后自动回传结果并触发来源继续运行。', + }, }, - required: ['targetConversationId', 'content'], + required: ['targetConversationId', 'content', 'replyMode'], additionalProperties: false, }, }, @@ -149,25 +177,6 @@ const TOOLS = [ additionalProperties: false, }, }, - { - name: 'ccweb_request_reply', - description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复写回当前对话,然后继续触发当前对话运行。', - inputSchema: { - type: 'object', - properties: { - targetConversationId: { - type: 'string', - description: '目标对话 ID。', - }, - content: { - type: 'string', - description: '要发送到目标对话的纯文本消息。', - }, - }, - required: ['targetConversationId', 'content'], - additionalProperties: false, - }, - }, { name: 'ccweb_prompt_user', description: '在当前来源 ccweb 对话前台渲染一个多问题表单。工具会立即返回,不等待用户;用户提交后,ccweb 会把问题、选择和答案作为一条普通用户消息发回当前对话。', @@ -278,6 +287,17 @@ function imageMimeFromPath(filePath) { return ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' })[ext] || 'application/octet-stream'; } +function isCallableToolName(name) { + const toolName = String(name || ''); + return TOOLS.some((tool) => tool.name === toolName) || HIDDEN_CALLABLE_TOOL_NAMES.has(toolName); +} + +function codexAppCommunicationTools() { + return TOOLS + .filter((tool) => CODEX_APP_COMMUNICATION_TOOL_NAMES.has(tool.name)) + .map((tool) => ({ ...tool, namespace: 'ccweb' })); +} + function prepareImagePayload(args = {}) { const source = String(args.source || '').trim(); if (!source) return { ok: false, code: 'missing_source', message: 'source 不能为空。' }; @@ -415,6 +435,12 @@ function toolResponse(payload) { }; } +async function listToolsForCurrentSource() { + const payload = await callCcweb('ccweb_internal_tools_list', {}); + if (!payload?.ok || !Array.isArray(payload.tools)) return TOOLS; + return [...TOOLS, ...payload.tools]; +} + async function handleRequest(message) { const { id, method } = message; const hasId = Object.prototype.hasOwnProperty.call(message, 'id'); @@ -435,12 +461,12 @@ async function handleRequest(message) { jsonRpcResult(id, {}); break; case 'tools/list': - jsonRpcResult(id, { tools: TOOLS }); + jsonRpcResult(id, { tools: await listToolsForCurrentSource() }); break; case 'tools/call': { const name = String(message.params?.name || ''); const args = message.params?.arguments || {}; - if (!TOOLS.some((tool) => tool.name === name)) { + if (!isCallableToolName(name)) { jsonRpcResult(id, toolResponse({ ok: false, code: 'unknown_tool', @@ -493,7 +519,14 @@ function runStdioServer() { }); } -module.exports = { TOOLS, prepareImagePayload, runStdioServer }; +module.exports = { + TOOLS, + CCWEB_REPLY_MODES, + codexAppCommunicationTools, + isCallableToolName, + prepareImagePayload, + runStdioServer, +}; if (require.main === module) { runStdioServer(); diff --git a/lib/task-board-lifecycle.js b/lib/task-board-lifecycle.js new file mode 100644 index 0000000..8e71449 --- /dev/null +++ b/lib/task-board-lifecycle.js @@ -0,0 +1,256 @@ +'use strict'; + +const LIFECYCLE_EVENT_TYPES = Object.freeze({ + USER_MESSAGE_RECEIVED: 'user_message_received', + TURN_STARTED: 'turn_started', + USER_INPUT_REQUESTED: 'user_input_requested', + TURN_COMPLETED: 'turn_completed', + TURN_FAILED: 'turn_failed', +}); + +const SUPPORTED_EVENT_TYPES = new Set(Object.values(LIFECYCLE_EVENT_TYPES)); +const EVENT_TYPE_ALIASES = Object.freeze({ + user_message: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED, + message_received: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED, + user_message_received: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED, + turn_start: LIFECYCLE_EVENT_TYPES.TURN_STARTED, + turn_started: LIFECYCLE_EVENT_TYPES.TURN_STARTED, + request_user_input: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED, + user_input_request: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED, + user_input_requested: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED, + item_tool_request_user_input: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED, + item_tool_requestuserinput: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED, + turn_complete: LIFECYCLE_EVENT_TYPES.TURN_COMPLETED, + turn_completed: LIFECYCLE_EVENT_TYPES.TURN_COMPLETED, + turn_error: LIFECYCLE_EVENT_TYPES.TURN_FAILED, + turn_failed: LIFECYCLE_EVENT_TYPES.TURN_FAILED, +}); + +function isObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function cleanText(value, maxLength = 4000) { + return typeof value === 'string' ? value.trim().slice(0, maxLength) : ''; +} + +function normalizeTimestamp(value, now) { + if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); + if (typeof value === 'number' && Number.isFinite(value)) { + const parsed = new Date(value); + if (Number.isFinite(parsed.getTime())) return parsed.toISOString(); + } + if (typeof value === 'string' && value.trim()) { + const parsed = new Date(value); + if (Number.isFinite(parsed.getTime())) return parsed.toISOString(); + } + return new Date(now()).toISOString(); +} + +function normalizeLifecycleEvent(input, options = {}) { + if (!isObject(input)) return null; + const now = typeof options.now === 'function' ? options.now : Date.now; + const rawType = cleanText(input.type || input.eventType || input.event, 80).toLowerCase(); + let type = EVENT_TYPE_ALIASES[rawType] || rawType; + const outcome = cleanText(input.outcome || input.status, 80).toLowerCase(); + if (type === LIFECYCLE_EVENT_TYPES.TURN_COMPLETED && /^(failed|failure|error|errored)$/.test(outcome)) { + type = LIFECYCLE_EVENT_TYPES.TURN_FAILED; + } + if (!SUPPORTED_EVENT_TYPES.has(type)) { + return { valid: false, reason: 'unsupported_event', type: type || null }; + } + + const errorValue = input.error; + const error = typeof errorValue === 'string' + ? cleanText(errorValue) + : (isObject(errorValue) ? cleanText(errorValue.message) : ''); + return { + valid: true, + type, + turnId: cleanText(input.turnId || input.turn_id, 128) || null, + eventId: cleanText(input.eventId || input.event_id || input.id, 160) || null, + occurredAt: normalizeTimestamp(input.occurredAt || input.timestamp, now), + outcome, + pendingUserInput: input.pendingUserInput === true, + structured: input.structured !== false, + trackingEnabled: input.trackingEnabled, + error: error || null, + }; +} + +function ignoredResult(sessionId, event, reason, extra = {}) { + return { + ok: true, + handled: false, + changed: false, + ignored: true, + reason, + sessionId, + eventType: event?.type || null, + ...extra, + }; +} + +function normalizeHandleArguments(sessionOrEnvelope, maybeEvent) { + if (typeof sessionOrEnvelope === 'string') { + return { sessionId: cleanText(sessionOrEnvelope, 128), event: maybeEvent }; + } + if (!isObject(sessionOrEnvelope)) return { sessionId: '', event: maybeEvent }; + const sessionId = cleanText( + sessionOrEnvelope.sessionId || sessionOrEnvelope.conversationId || sessionOrEnvelope.session_id, + 128, + ); + return { + sessionId, + event: isObject(sessionOrEnvelope.event) ? sessionOrEnvelope.event : sessionOrEnvelope, + }; +} + +function trackingEnabledFrom(value) { + if (typeof value === 'boolean') return value; + if (!isObject(value)) return null; + if (typeof value.enabled === 'boolean') return value.enabled; + if (typeof value.trackingEnabled === 'boolean') return value.trackingEnabled; + if (typeof value.taskTracking?.enabled === 'boolean') return value.taskTracking.enabled; + if (typeof value.task?.taskTracking?.enabled === 'boolean') return value.task.taskTracking.enabled; + return null; +} + +function serviceEventFrom(event) { + const output = { + type: event.type, + occurredAt: event.occurredAt, + }; + if (event.turnId) output.turnId = event.turnId; + if (event.outcome) output.outcome = event.outcome; + if (event.pendingUserInput) output.pendingUserInput = true; + if (event.error) output.error = event.error; + return output; +} + +function eventIdentity(event) { + if (event.eventId) return `event:${event.eventId}`; + if (event.turnId) return `turn:${event.turnId}:${event.type}`; + return ''; +} + +function createTaskBoardLifecycle(deps = {}, extraOptions = {}) { + const directService = isObject(deps) && typeof deps.recordLifecycleEvent === 'function' ? deps : null; + const options = directService + ? (isObject(extraOptions) ? extraOptions : {}) + : { ...(isObject(deps) ? deps : {}), ...(isObject(extraOptions) ? extraOptions : {}) }; + const taskBoardService = directService || options.taskBoardService || options.service; + if (!taskBoardService || typeof taskBoardService.recordLifecycleEvent !== 'function') { + throw new TypeError('createTaskBoardLifecycle 需要注入 TaskBoardService.recordLifecycleEvent'); + } + + const now = typeof options.now === 'function' ? options.now : Date.now; + const trackingChecker = typeof options.isTrackingEnabled === 'function' + ? options.isTrackingEnabled + : null; + const logger = options.logger || null; + const queues = new Map(); + const identitiesBySession = new Map(); + + function enqueue(sessionId, operation) { + const previous = queues.get(sessionId) || Promise.resolve(); + const next = previous.catch(() => undefined).then(operation); + queues.set(sessionId, next); + return next.finally(() => { + if (queues.get(sessionId) === next) queues.delete(sessionId); + }); + } + + function hasIdentity(sessionId, identity) { + return Boolean(identity && identitiesBySession.get(sessionId)?.has(identity)); + } + + function rememberIdentity(sessionId, identity) { + if (!identity) return; + let identities = identitiesBySession.get(sessionId); + if (!identities) { + identities = new Set(); + identitiesBySession.set(sessionId, identities); + } + identities.add(identity); + while (identities.size > 256) identities.delete(identities.values().next().value); + } + + function logFailure(error, sessionId, event) { + if (!logger) return; + const payload = { sessionId, eventType: event?.type || '', error: cleanText(error?.message || error) }; + if (typeof logger === 'function') logger('error', 'task_board_lifecycle_failed', payload); + else if (typeof logger.error === 'function') logger.error('task_board_lifecycle_failed', payload); + } + + async function processEvent(sessionId, input) { + const event = normalizeLifecycleEvent(input, { now }); + if (!event) return ignoredResult(sessionId, null, 'invalid_event'); + if (!event.valid) return ignoredResult(sessionId, event, event.reason); + if (event.trackingEnabled === false) return ignoredResult(sessionId, event, 'task_tracking_disabled'); + if (event.type === LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED && !event.structured) { + return ignoredResult(sessionId, event, 'unstructured_user_input'); + } + if (trackingChecker) { + const enabled = trackingEnabledFrom(await trackingChecker(sessionId, event)); + if (enabled === false) return ignoredResult(sessionId, event, 'task_tracking_disabled'); + } + const identity = eventIdentity(event); + if (hasIdentity(sessionId, identity)) { + return ignoredResult(sessionId, event, 'duplicate_event'); + } + + let serviceResult; + try { + serviceResult = await taskBoardService.recordLifecycleEvent(sessionId, serviceEventFrom(event)); + } catch (error) { + if (error?.code === 'task_tracking_disabled') { + return ignoredResult(sessionId, event, 'task_tracking_disabled'); + } + logFailure(error, sessionId, event); + throw error; + } + rememberIdentity(sessionId, identity); + return { + ok: true, + handled: serviceResult?.ignored !== true, + changed: serviceResult?.changed === true, + ignored: serviceResult?.ignored === true, + reason: serviceResult?.reason || null, + sessionId, + eventType: event.type, + ...(serviceResult?.task ? { task: serviceResult.task } : {}), + }; + } + + function handleEvent(sessionOrEnvelope, maybeEvent) { + const { sessionId, event } = normalizeHandleArguments(sessionOrEnvelope, maybeEvent); + if (!sessionId) return Promise.resolve(ignoredResult('', null, 'session_id_missing')); + return enqueue(sessionId, () => processEvent(sessionId, event)); + } + + function clearSession(sessionIdValue) { + const sessionId = cleanText(sessionIdValue, 128); + if (!sessionId) return Promise.resolve(false); + identitiesBySession.delete(sessionId); + return Promise.resolve(true); + } + + const eventHandler = (type) => (sessionId, event = {}) => handleEvent(sessionId, { ...event, type }); + return Object.freeze({ + handleEvent, + recordLifecycleEvent: handleEvent, + clearSession, + onUserMessageReceived: eventHandler(LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED), + onTurnStarted: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_STARTED), + onUserInputRequested: eventHandler(LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED), + onTurnCompleted: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_COMPLETED), + onTurnFailed: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_FAILED), + }); +} + +module.exports = { + LIFECYCLE_EVENT_TYPES, + createTaskBoardLifecycle, + normalizeLifecycleEvent, +}; diff --git a/lib/task-board-mcp.js b/lib/task-board-mcp.js new file mode 100644 index 0000000..683d5d0 --- /dev/null +++ b/lib/task-board-mcp.js @@ -0,0 +1,220 @@ +'use strict'; + +const { TaskBoardError } = require('./task-board-service'); + +// 任务工具不能静态注册;缺少来源会话上下文时必须保持为空。 +const TASK_BOARD_MCP_TOOL_DEFINITIONS = Object.freeze([]); +const TASK_BOARD_MCP_TOOLS = TASK_BOARD_MCP_TOOL_DEFINITIONS; +const TOOLS = TASK_BOARD_MCP_TOOL_DEFINITIONS; + +function isPlainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertArgsObject(args, allowedKeys, toolName) { + const value = args === undefined || args === null ? {} : args; + if (!isPlainObject(value)) { + throw new TaskBoardError('task_status_invalid', `${toolName} 参数必须是对象。`); + } + const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key)); + if (unexpected.length > 0) { + throw new TaskBoardError('task_status_invalid', `${toolName} 包含不支持的字段。`, { + fields: unexpected, + }); + } + return value; +} + +function contextSessionId(context, resolver) { + if (!isPlainObject(context)) { + throw new TaskBoardError('task_session_not_found', 'MCP 调用上下文缺少来源会话。'); + } + let value; + if (typeof resolver === 'function') value = resolver(context); + if (value === undefined || value === null || value === '') { + value = context.sourceSessionId + ?? context.sessionId + ?? context.source?.sessionId + ?? context.mcp?.sourceSessionId; + } + // 来源只取 MCP 连接上下文,绝不接受模型参数中的 sessionId。 + if (typeof value !== 'string' || value.length === 0) { + throw new TaskBoardError('task_session_not_found', 'MCP 调用上下文缺少来源会话。'); + } + return value; +} + +function actorFromContext(context) { + const actorId = isPlainObject(context) + ? (context.actorId || context.agentId || context.sourceAgentId || 'mcp') + : 'mcp'; + return { source: 'mcp', id: actorId }; +} + +function errorPayload(error) { + if (error instanceof TaskBoardError) { + const payload = { ok: false, code: error.code, message: error.message }; + if (error.details !== undefined) payload.details = error.details; + return payload; + } + return { + ok: false, + code: 'task_status_invalid', + message: '任务状态工具执行失败。', + }; +} + +function serviceFromOptions(serviceOrOptions) { + if (serviceOrOptions && typeof serviceOrOptions.getTask === 'function' + && typeof serviceOrOptions.updateStatus === 'function') { + return serviceOrOptions; + } + if (isPlainObject(serviceOrOptions)) { + if (serviceOrOptions.service) return serviceOrOptions.service; + if (serviceOrOptions.taskBoardService) return serviceOrOptions.taskBoardService; + } + throw new TypeError('任务看板 MCP 需要 TaskBoardService。'); +} + +function factoryOptionsFrom(serviceOrOptions, options) { + return isPlainObject(serviceOrOptions) && !serviceOrOptions.getTask + ? { ...serviceOrOptions, ...(isPlainObject(options) ? options : {}) } + : (isPlainObject(options) ? options : {}); +} + +function enabledDefinitions(service) { + const snapshot = typeof service.getStatusDefinitionSnapshot === 'function' + ? service.getStatusDefinitionSnapshot() + : { definitions: service.getStatusDefinitions() }; + return (Array.isArray(snapshot.definitions) ? snapshot.definitions : []) + .filter((definition) => definition?.enabled === true); +} + +function compactPrompt(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function taskUpdateDescription(task, definitions) { + const current = task.status || definitions.find((definition) => ( + definition.id === task.taskTracking.statusId + )); + const currentLabel = compactPrompt(current?.label) || task.taskTracking.statusId; + const currentId = task.taskTracking.statusId; + const mappings = definitions.map((definition) => ( + `- ${compactPrompt(definition.label)} / ${definition.id}: ${compactPrompt(definition.prompt)}` + )); + return [ + `更新当前来源会话的任务看板列。当前状态:${currentLabel} / ${currentId}。`, + '仅当当前对话任务状态真实发生变化时调用;单轮开始、结束、运行停止不代表任务状态变化。', + '可选列分类映射:', + ...mappings, + ].join('\n'); +} + +function createTaskBoardMcpToolDefinitions(serviceOrOptions, context = {}, options = {}) { + const service = serviceFromOptions(serviceOrOptions); + const factoryOptions = factoryOptionsFrom(serviceOrOptions, options); + const resolver = factoryOptions.sourceSessionIdResolver + || factoryOptions.getSourceSessionId + || null; + try { + const sessionId = contextSessionId(context, resolver); + const task = service.getTask(sessionId); + if (task.taskTracking.enabled !== true) return []; + const definitions = enabledDefinitions(service); + return [{ + name: 'ccweb_task_update', + description: taskUpdateDescription(task, definitions), + inputSchema: { + type: 'object', + properties: { + statusId: { + type: 'string', + enum: definitions.map((definition) => definition.id), + description: '选择一个当前已启用的看板列 ID。', + }, + reason: { + type: 'string', + maxLength: 2000, + description: '可选,简要说明状态变化原因。', + }, + summary: { + type: 'string', + maxLength: 4000, + description: '可选,更新面向看板的任务摘要。', + }, + }, + required: ['statusId'], + additionalProperties: false, + }, + }]; + } catch (error) { + if (error instanceof TaskBoardError + && (error.code === 'task_session_not_found' || error.code === 'task_tracking_disabled')) { + return []; + } + throw error; + } +} + +function statusPayload(task) { + return { + ok: true, + changed: task.changed === true, + sessionId: task.sessionId, + currentStatus: { ...task.taskTracking }, + currentStatusDefinition: task.status ? { ...task.status } : null, + task, + }; +} + +function createTaskBoardMcpHandlers(serviceOrOptions, options = {}) { + const service = serviceFromOptions(serviceOrOptions); + const factoryOptions = factoryOptionsFrom(serviceOrOptions, options); + const sourceSessionResolver = factoryOptions.sourceSessionIdResolver + || factoryOptions.getSourceSessionId + || null; + + function updateStatus(args = {}, context = {}) { + try { + const input = assertArgsObject(args, new Set(['statusId', 'reason', 'summary']), 'ccweb_task_update'); + const sessionId = contextSessionId(context, sourceSessionResolver); + const task = service.updateStatus(sessionId, input, actorFromContext(context)); + return statusPayload(task); + } catch (error) { + return errorPayload(error); + } + } + + return Object.freeze({ ccweb_task_update: updateStatus }); +} + +function createTaskBoardMcpHandler(serviceOrOptions, options = {}) { + const handlers = createTaskBoardMcpHandlers(serviceOrOptions, options); + return function handleTaskBoardMcpCall(name, args = {}, context = {}) { + const handler = handlers[name]; + if (!handler) { + return { + ok: false, + code: 'unknown_tool', + message: `未知任务看板工具: ${String(name || '')}`, + }; + } + return handler(args, context); + }; +} + +const createTaskBoardMcpAdapter = createTaskBoardMcpHandler; + +module.exports = { + TASK_BOARD_MCP_TOOL_DEFINITIONS, + TASK_BOARD_MCP_TOOLS, + TOOLS, + createTaskBoardMcpToolDefinitions, + createTaskBoardMcpHandlers, + createTaskBoardMcpHandler, + createTaskBoardMcpAdapter, + errorPayload, +}; diff --git a/lib/task-board-service.js b/lib/task-board-service.js new file mode 100644 index 0000000..e5b21fe --- /dev/null +++ b/lib/task-board-service.js @@ -0,0 +1,1037 @@ +'use strict'; + +const MAX_VERSION = 2_147_483_647; +const MAX_STATUS_PROMPT_LENGTH = 4000; +const STATUS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/; +const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +const ACTOR_SOURCE_ALIASES = Object.freeze({ + user: 'user', + human: 'user', + mcp: 'mcp', + agent: 'mcp', + hook: 'hook', + lifecycle: 'hook', + default: 'default', + system: 'default', +}); +const TRACKING_SOURCE_SET = new Set(['default', 'hook', 'mcp', 'user']); + +const SYSTEM_STATUS_DEFINITIONS = Object.freeze([ + Object.freeze({ + id: 'unassigned', + label: '待认领', + description: '已加入任务看板,但尚未开始处理。', + prompt: '当任务尚未开始、尚未分配,或缺少足够信息判断处理阶段时选择此列。', + color: '#64748b', + order: 10, + system: true, + enabled: true, + }), + Object.freeze({ + id: 'in_progress', + label: '处理中', + description: '任务正在推进。', + prompt: '当任务正在实现、排查、验证或持续推进,且没有等待用户或遇到阻碍时选择此列。', + color: '#06b6d4', + order: 20, + system: true, + enabled: true, + }), + Object.freeze({ + id: 'waiting_user', + label: '等待确认', + description: '等待用户输入、验收或外部条件。', + prompt: '当继续推进必须等待用户补充信息、确认方案、授权或验收时选择此列。', + color: '#f59e0b', + order: 30, + system: true, + enabled: true, + }), + Object.freeze({ + id: 'blocked', + label: '遇到阻碍', + description: '发生错误或当前无法继续。', + prompt: '当任务因错误、依赖缺失、权限或外部条件而无法继续时选择此列。', + color: '#ef4444', + order: 40, + system: true, + enabled: true, + }), + Object.freeze({ + id: 'completed', + label: '已完成', + description: '任务业务目标已经完成。', + prompt: '仅当用户目标已经实际完成并完成必要验证时选择此列;单轮对话结束不代表完成。', + color: '#22c55e', + order: 50, + system: true, + enabled: true, + }), +]); +const SYSTEM_STATUS_IDS = Object.freeze(SYSTEM_STATUS_DEFINITIONS.map((item) => item.id)); +const SYSTEM_STATUS_ID_SET = new Set(SYSTEM_STATUS_IDS); +const LIFECYCLE_EVENT_TYPES = new Set([ + 'turn_started', + 'user_input_requested', + 'turn_completed', + 'turn_failed', + 'user_message_received', +]); + +const ERROR_MESSAGES = Object.freeze({ + task_tracking_disabled: '当前会话未开启任务跟踪。', + task_tracking_forbidden: 'Agent 和生命周期 Hook 无权启用或关闭任务跟踪。', + task_archive_forbidden: '只有用户操作可以归档或取消归档任务。', + task_status_forbidden: '只有用户操作可以管理状态定义。', + task_status_unknown: '指定的任务状态不存在。', + task_status_invalid: '任务状态输入无效。', + task_status_in_use: '该自定义状态仍被任务引用,必须指定迁移目标。', + task_version_conflict: '任务版本已变化,请刷新后重试。', + task_session_not_found: '找不到指定会话。', + task_persistence_failed: '任务状态保存失败。', +}); + +class TaskBoardError extends Error { + constructor(code, message, details) { + super(message || ERROR_MESSAGES[code] || '任务看板操作失败。'); + this.name = 'TaskBoardError'; + this.code = code; + if (details !== undefined) this.details = details; + } +} + +function fail(code, message, details) { + throw new TaskBoardError(code, message, details); +} + +function isPlainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function cloneJson(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function assertPlainObject(value, label = 'input') { + if (!isPlainObject(value)) { + fail('task_status_invalid', `${label} 必须是对象。`); + } +} + +function assertAllowedKeys(value, allowedKeys, label = 'input') { + const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key)); + if (unexpected.length > 0) { + fail('task_status_invalid', `${label} 包含不支持的字段。`, { fields: unexpected }); + } +} + +function codePointLength(value) { + return Array.from(value).length; +} + +function validateString(value, options = {}) { + const { + field = 'value', + maxLength = 1000, + minLength = 0, + trim = false, + pattern = null, + } = options; + if (typeof value !== 'string') { + fail('task_status_invalid', `${field} 必须是字符串。`); + } + const normalized = trim ? value.trim() : value; + const length = codePointLength(normalized); + if (length < minLength || length > maxLength || normalized.includes('\0')) { + fail('task_status_invalid', `${field} 长度无效。`, { minLength, maxLength }); + } + if (pattern && !pattern.test(normalized)) { + fail('task_status_invalid', `${field} 格式无效。`); + } + return normalized; +} + +function validateStatusId(value, field = 'statusId') { + return validateString(value, { + field, + minLength: 1, + maxLength: 80, + trim: true, + pattern: STATUS_ID_PATTERN, + }); +} + +function validateStatusPrompt(value) { + return validateString(value, { + field: 'prompt', + minLength: 1, + maxLength: MAX_STATUS_PROMPT_LENGTH, + trim: true, + }); +} + +function legacyStatusPrompt(label, description) { + const subject = description || label; + return `当任务符合“${subject}”时选择此列。`; +} + +function validateSessionId(value) { + if (typeof value !== 'string' || value !== value.trim() || !SESSION_ID_PATTERN.test(value)) { + fail('task_status_invalid', 'sessionId 格式无效。'); + } + return value; +} + +function validateColor(value) { + const color = validateString(value, { + field: 'color', + minLength: 7, + maxLength: 7, + trim: true, + pattern: COLOR_PATTERN, + }); + return color.toLowerCase(); +} + +function validateOrder(value) { + if (!Number.isSafeInteger(value) || value < -100_000 || value > 100_000) { + fail('task_status_invalid', 'order 必须是 -100000 到 100000 之间的整数。'); + } + return value; +} + +function validateVersion(value, field = 'version') { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_VERSION) { + fail('task_status_invalid', `${field} 必须是 0 到 ${MAX_VERSION} 之间的整数。`); + } + return value; +} + +function validateBoolean(value, field) { + if (typeof value !== 'boolean') { + fail('task_status_invalid', `${field} 必须是布尔值。`); + } + return value; +} + +function normalizeIsoTimestamp(value, field = 'occurredAt') { + if (typeof value !== 'string' || codePointLength(value) > 64) { + fail('task_status_invalid', `${field} 必须是有效的 ISO-8601 字符串。`); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + fail('task_status_invalid', `${field} 必须是有效的 ISO-8601 字符串。`); + } + return parsed.toISOString(); +} + +function storedIsoOrNull(value) { + if (typeof value !== 'string' || value.length > 64) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function nextVersion(currentVersion) { + if (!Number.isSafeInteger(currentVersion) || currentVersion < 0 || currentVersion >= MAX_VERSION) { + fail('task_version_conflict', '任务版本已达到上限。', { currentVersion }); + } + return currentVersion + 1; +} + +function readExpectedVersion(input, actor) { + const candidates = []; + if (isPlainObject(input)) { + if (hasOwn(input, 'version')) candidates.push(['version', input.version]); + if (hasOwn(input, 'expectedVersion')) candidates.push(['expectedVersion', input.expectedVersion]); + } + if (isPlainObject(actor)) { + if (hasOwn(actor, 'version')) candidates.push(['actor.version', actor.version]); + if (hasOwn(actor, 'expectedVersion')) candidates.push(['actor.expectedVersion', actor.expectedVersion]); + } + if (candidates.length === 0) return null; + const validated = candidates.map(([field, value]) => validateVersion(value, field)); + if (validated.some((value) => value !== validated[0])) { + fail('task_status_invalid', 'version 与 expectedVersion 不一致。'); + } + return validated[0]; +} + +function assertVersion(expectedVersion, currentVersion, scope = 'task') { + if (expectedVersion !== null && expectedVersion !== currentVersion) { + fail('task_version_conflict', undefined, { + scope, + expectedVersion, + currentVersion, + }); + } +} + +function normalizeActor(actor, fallbackSource = 'user') { + let rawSource = fallbackSource; + let rawId = ''; + if (typeof actor === 'string') { + rawSource = actor; + rawId = actor; + } else if (actor !== undefined && actor !== null) { + assertPlainObject(actor, 'actor'); + rawSource = actor.source || actor.type || actor.kind || fallbackSource; + rawId = actor.id || actor.actorId || actor.userId || ''; + } + if (typeof rawSource !== 'string') { + fail('task_status_invalid', 'actor.source 无效。'); + } + const source = ACTOR_SOURCE_ALIASES[rawSource.trim().toLowerCase()]; + if (!source) fail('task_status_invalid', 'actor.source 无效。'); + const id = rawId + ? validateString(rawId, { field: 'actor.id', minLength: 1, maxLength: 120, trim: true }) + : source; + return { source, id }; +} + +function compareDefinitions(left, right) { + return left.order - right.order + || Number(right.system) - Number(left.system) + || left.label.localeCompare(right.label, 'zh-CN') + || left.id.localeCompare(right.id); +} + +function defaultStatusMap() { + return new Map(SYSTEM_STATUS_DEFINITIONS.map((definition) => [definition.id, { ...definition }])); +} + +function normalizeStoredStatusDefinition(raw, seenIds) { + assertPlainObject(raw, 'statusDefinition'); + const id = validateStatusId(raw.id, 'statusDefinition.id'); + if (seenIds.has(id)) { + fail('task_status_invalid', '状态定义 ID 重复。', { statusId: id }); + } + seenIds.add(id); + + if (SYSTEM_STATUS_ID_SET.has(id)) { + const original = SYSTEM_STATUS_DEFINITIONS.find((item) => item.id === id); + if (hasOwn(raw, 'system') && raw.system !== true) { + fail('task_status_invalid', '系统状态的 system 必须保持为 true。', { statusId: id }); + } + if (hasOwn(raw, 'enabled') && raw.enabled !== true) { + fail('task_status_invalid', '系统状态不可停用。', { statusId: id }); + } + return { + ...original, + label: hasOwn(raw, 'label') + ? validateString(raw.label, { field: 'label', minLength: 1, maxLength: 120, trim: true }) + : original.label, + prompt: hasOwn(raw, 'prompt') ? validateStatusPrompt(raw.prompt) : original.prompt, + color: hasOwn(raw, 'color') ? validateColor(raw.color) : original.color, + order: hasOwn(raw, 'order') ? validateOrder(raw.order) : original.order, + }; + } + + if (hasOwn(raw, 'system') && raw.system !== false) { + fail('task_status_invalid', '自定义状态的 system 必须为 false。', { statusId: id }); + } + if (typeof raw.enabled !== 'boolean') { + fail('task_status_invalid', '自定义状态必须包含布尔类型 enabled。', { statusId: id }); + } + const label = validateString(raw.label, { + field: 'label', minLength: 1, maxLength: 120, trim: true, + }); + const description = hasOwn(raw, 'description') + ? validateString(raw.description, { field: 'description', maxLength: 1000 }) + : ''; + return { + id, + label, + description, + prompt: hasOwn(raw, 'prompt') + ? validateStatusPrompt(raw.prompt) + : legacyStatusPrompt(label, description), + color: validateColor(raw.color), + order: validateOrder(raw.order), + system: false, + enabled: raw.enabled, + }; +} + +function normalizeStatusConfig(rawConfig) { + if (rawConfig === undefined || rawConfig === null) { + return { version: 0, definitions: defaultStatusMap() }; + } + const config = Array.isArray(rawConfig) + ? { version: 0, definitions: rawConfig } + : rawConfig; + assertPlainObject(config, 'statusConfig'); + const version = hasOwn(config, 'version') ? validateVersion(config.version, 'statusConfig.version') : 0; + const rawDefinitions = config.definitions || config.statuses || config.items || []; + if (!Array.isArray(rawDefinitions) || rawDefinitions.length > 105) { + fail('task_status_invalid', '状态定义列表无效或数量超过上限。'); + } + const definitions = defaultStatusMap(); + const seenIds = new Set(); + for (const raw of rawDefinitions) { + const normalized = normalizeStoredStatusDefinition(raw, seenIds); + definitions.set(normalized.id, normalized); + } + return { version, definitions }; +} + +function callSyncDependency(fn, args, label) { + const result = fn(...args); + if (result && typeof result.then === 'function') { + throw new TypeError(`${label} 必须是同步依赖。`); + } + return result; +} + +function createTaskBoardService(deps = {}) { + if (!isPlainObject(deps)) throw new TypeError('deps 必须是对象。'); + + const sessionMap = deps.sessions instanceof Map ? deps.sessions : null; + const loadSession = deps.loadSession || deps.getSession || ((sessionId) => sessionMap?.get(sessionId) || null); + const saveSession = deps.saveSession || deps.persistSession || deps.updateSession || ((session) => { + if (!sessionMap) return false; + sessionMap.set(session.id, cloneJson(session)); + return true; + }); + const listSessions = deps.listSessions || deps.getSessions || (() => sessionMap ? Array.from(sessionMap.values()) : []); + const loadStatusDefinitions = deps.loadStatusDefinitions + || deps.loadStatusConfig + || deps.readStatusDefinitions + || (() => deps.statusConfig || deps.statusDefinitions || null); + const saveStatusDefinitions = deps.saveStatusDefinitions + || deps.saveStatusConfig + || deps.writeStatusDefinitions + || null; + const nowProvider = typeof deps.now === 'function' ? deps.now : () => new Date(); + + for (const [label, dependency] of [ + ['loadSession', loadSession], + ['saveSession', saveSession], + ['listSessions', listSessions], + ['loadStatusDefinitions', loadStatusDefinitions], + ]) { + if (typeof dependency !== 'function') throw new TypeError(`${label} 必须是函数。`); + } + if (saveStatusDefinitions !== null && typeof saveStatusDefinitions !== 'function') { + throw new TypeError('saveStatusDefinitions 必须是函数。'); + } + + const loadedStatusConfig = callSyncDependency(loadStatusDefinitions, [], 'loadStatusDefinitions'); + let { version: statusConfigVersion, definitions: statusMap } = normalizeStatusConfig(loadedStatusConfig); + + function timestamp(override) { + if (override !== undefined && override !== null) return normalizeIsoTimestamp(override); + const value = callSyncDependency(nowProvider, [], 'now'); + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) fail('task_status_invalid', 'now 返回了无效时间。'); + return value.toISOString(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) fail('task_status_invalid', 'now 返回了无效时间。'); + return parsed.toISOString(); + } + return normalizeIsoTimestamp(value, 'now'); + } + + function sortedDefinitions(map = statusMap) { + return Array.from(map.values()).map((item) => ({ ...item })).sort(compareDefinitions); + } + + function getStatusDefinitions(options = {}) { + if (options !== undefined && !isPlainObject(options)) { + fail('task_status_invalid', 'options 必须是对象。'); + } + assertAllowedKeys(options, new Set(['enabledOnly']), 'options'); + if (hasOwn(options, 'enabledOnly')) validateBoolean(options.enabledOnly, 'enabledOnly'); + const enabledOnly = options.enabledOnly === true; + const result = sortedDefinitions().filter((item) => !enabledOnly || item.enabled); + Object.defineProperty(result, 'version', { + configurable: false, + enumerable: false, + writable: false, + value: statusConfigVersion, + }); + return result; + } + + function getStatusDefinitionSnapshot(options = {}) { + return { + version: statusConfigVersion, + definitions: getStatusDefinitions(options), + }; + } + + function persistStatusConfig(nextMap, nextConfigVersion) { + const payload = { + version: nextConfigVersion, + definitions: sortedDefinitions(nextMap), + }; + if (saveStatusDefinitions) { + const saved = callSyncDependency(saveStatusDefinitions, [cloneJson(payload)], 'saveStatusDefinitions'); + if (saved === false) fail('task_persistence_failed', '状态定义保存失败。'); + } + statusMap = nextMap; + statusConfigVersion = nextConfigVersion; + } + + function loadSessionForTask(sessionId) { + const id = validateSessionId(sessionId); + const loaded = callSyncDependency(loadSession, [id], 'loadSession'); + if (!loaded || typeof loaded !== 'object') { + fail('task_session_not_found', undefined, { sessionId: id }); + } + const session = cloneJson(loaded); + if (!session.id) session.id = id; + if (session.id !== id) { + fail('task_session_not_found', '会话 ID 与请求不一致。', { sessionId: id }); + } + return session; + } + + function persistSession(session) { + const saved = callSyncDependency(saveSession, [cloneJson(session)], 'saveSession'); + if (saved === false) { + fail('task_persistence_failed', undefined, { sessionId: session.id }); + } + } + + function normalizeTracking(rawTracking) { + const raw = isPlainObject(rawTracking) ? rawTracking : {}; + const rawStatusId = typeof raw.statusId === 'string' && STATUS_ID_PATTERN.test(raw.statusId) + ? raw.statusId + : 'unassigned'; + const definition = statusMap.get(rawStatusId) || statusMap.get('unassigned'); + const source = ACTOR_SOURCE_ALIASES[String(raw.source || 'default').toLowerCase()] || 'default'; + return { + enabled: raw.enabled === true, + statusId: definition.id, + source, + reason: typeof raw.reason === 'string' && codePointLength(raw.reason) <= 2000 ? raw.reason : '', + summary: typeof raw.summary === 'string' && codePointLength(raw.summary) <= 4000 ? raw.summary : '', + version: Number.isSafeInteger(raw.version) && raw.version >= 0 && raw.version <= MAX_VERSION + ? raw.version + : 0, + enabledAt: storedIsoOrNull(raw.enabledAt), + disabledAt: storedIsoOrNull(raw.disabledAt), + statusUpdatedAt: storedIsoOrNull(raw.statusUpdatedAt), + archivedAt: storedIsoOrNull(raw.archivedAt), + archivedBy: typeof raw.archivedBy === 'string' && codePointLength(raw.archivedBy) <= 120 + ? raw.archivedBy + : null, + }; + } + + function hasCanonicalTracking(rawTracking) { + if (!isPlainObject(rawTracking)) return false; + const definition = typeof rawTracking.statusId === 'string' + ? statusMap.get(rawTracking.statusId) + : null; + if (!definition) return false; + const requiredFields = [ + 'enabled', 'statusId', 'source', 'reason', 'summary', + 'version', 'enabledAt', 'disabledAt', 'statusUpdatedAt', 'archivedAt', 'archivedBy', + ]; + return requiredFields.every((field) => hasOwn(rawTracking, field)) + && Object.keys(rawTracking).every((field) => requiredFields.includes(field)) + && typeof rawTracking.enabled === 'boolean' + && TRACKING_SOURCE_SET.has(rawTracking.source) + && Number.isSafeInteger(rawTracking.version) + && rawTracking.version >= 0 + && rawTracking.version <= MAX_VERSION; + } + + function taskSnapshot(session) { + const taskTracking = normalizeTracking(session.taskTracking); + const definition = statusMap.get(taskTracking.statusId) || statusMap.get('unassigned'); + return { + id: session.id, + sessionId: session.id, + title: typeof session.title === 'string' ? session.title : '', + agent: typeof session.agent === 'string' ? session.agent : '', + cwd: typeof session.cwd === 'string' ? session.cwd : '', + projectName: typeof session.projectName === 'string' ? session.projectName : '', + created: storedIsoOrNull(session.created), + updated: storedIsoOrNull(session.updated), + archived: Boolean(taskTracking.archivedAt), + status: { ...definition }, + taskTracking, + }; + } + + function getTask(sessionId) { + return taskSnapshot(loadSessionForTask(sessionId)); + } + + function listSessionRecords() { + const records = callSyncDependency(listSessions, [], 'listSessions'); + if (!Array.isArray(records)) { + fail('task_status_invalid', 'listSessions 必须返回数组。'); + } + return records.map((record) => { + if (typeof record === 'string') return loadSessionForTask(record); + if (!record || typeof record !== 'object') return null; + if (record.taskTracking || !record.id) return cloneJson(record); + try { + return loadSessionForTask(record.id); + } catch (error) { + if (error instanceof TaskBoardError && error.code === 'task_session_not_found') return cloneJson(record); + throw error; + } + }).filter(Boolean); + } + + function normalizeFilterValues(value, field, validator) { + if (value === undefined || value === null) return null; + if (typeof value === 'string' && value.trim() === '') return null; + const values = Array.isArray(value) ? value : [value]; + if (values.length === 0 || values.length > 50) { + fail('task_status_invalid', `${field} 过滤数量无效。`); + } + return new Set(values.map((item) => validator(item, field))); + } + + function listTasks(filters = {}) { + assertPlainObject(filters, 'filters'); + assertAllowedKeys(filters, new Set([ + 'sessionId', 'includeDisabled', 'archived', 'includeArchived', 'statusId', 'statusIds', + 'search', 'query', + ]), 'filters'); + if (hasOwn(filters, 'includeDisabled')) validateBoolean(filters.includeDisabled, 'includeDisabled'); + if (hasOwn(filters, 'includeArchived')) validateBoolean(filters.includeArchived, 'includeArchived'); + + const sessionId = hasOwn(filters, 'sessionId') ? validateSessionId(filters.sessionId) : null; + const statusIds = normalizeFilterValues(filters.statusIds ?? filters.statusId, 'statusId', validateStatusId); + const searchValue = hasOwn(filters, 'search') ? filters.search : filters.query; + const search = searchValue !== undefined + ? validateString(searchValue, { field: 'search', maxLength: 240, trim: true }).toLocaleLowerCase('zh-CN') + : ''; + + let archivedFilter = filters.includeArchived === true ? 'all' : false; + if (hasOwn(filters, 'archived')) { + if (filters.archived !== true && filters.archived !== false && filters.archived !== 'all') { + fail('task_status_invalid', 'archived 必须是 true、false 或 all。'); + } + archivedFilter = filters.archived; + } + + return listSessionRecords() + .filter((session) => { + const tracking = normalizeTracking(session.taskTracking); + return tracking.enabled || (filters.includeDisabled === true && isPlainObject(session.taskTracking)); + }) + .map(taskSnapshot) + .filter((task) => !sessionId || task.sessionId === sessionId) + .filter((task) => archivedFilter === 'all' || task.archived === archivedFilter) + .filter((task) => !statusIds || statusIds.has(task.taskTracking.statusId)) + .filter((task) => { + if (!search) return true; + const haystack = [ + task.sessionId, + task.title, + task.cwd, + task.projectName, + task.taskTracking.reason, + task.taskTracking.summary, + ].join('\n').toLocaleLowerCase('zh-CN'); + return haystack.includes(search); + }) + .sort((left, right) => { + const leftTime = left.taskTracking.statusUpdatedAt || left.updated || left.created || ''; + const rightTime = right.taskTracking.statusUpdatedAt || right.updated || right.created || ''; + return rightTime.localeCompare(leftTime) || left.sessionId.localeCompare(right.sessionId); + }); + } + + function setTracking(sessionId, enabled, actor = { source: 'user' }) { + validateBoolean(enabled, 'enabled'); + const normalizedActor = normalizeActor(actor, 'user'); + if (normalizedActor.source !== 'user') fail('task_tracking_forbidden'); + const session = loadSessionForTask(sessionId); + const current = normalizeTracking(session.taskTracking); + const expectedVersion = readExpectedVersion(null, actor); + assertVersion(expectedVersion, current.version); + const needsInitialization = enabled && !hasCanonicalTracking(session.taskTracking); + if (current.enabled === enabled && !needsInitialization) return taskSnapshot(session); + + const occurredAt = timestamp(); + const next = { + ...current, + enabled, + version: nextVersion(current.version), + }; + if (enabled) { + next.enabledAt = current.enabledAt || occurredAt; + next.disabledAt = null; + if (!next.statusUpdatedAt) next.statusUpdatedAt = occurredAt; + } else { + next.disabledAt = occurredAt; + } + session.taskTracking = next; + persistSession(session); + return taskSnapshot(session); + } + + function normalizeStatusUpdate(update) { + assertPlainObject(update, 'update'); + assertAllowedKeys(update, new Set([ + 'statusId', 'reason', 'summary', 'version', 'expectedVersion', 'turnId', + ]), 'update'); + if (!hasOwn(update, 'statusId')) fail('task_status_invalid', 'statusId 为必填项。'); + const normalized = { + statusId: validateStatusId(update.statusId), + hasReason: hasOwn(update, 'reason'), + hasSummary: hasOwn(update, 'summary'), + }; + if (normalized.hasReason) { + normalized.reason = validateString(update.reason, { field: 'reason', maxLength: 2000 }); + } + if (normalized.hasSummary) { + normalized.summary = validateString(update.summary, { field: 'summary', maxLength: 4000 }); + } + if (hasOwn(update, 'turnId')) { + normalized.turnId = validateString(update.turnId, { + field: 'turnId', minLength: 1, maxLength: 128, trim: true, + }); + } + return normalized; + } + + function updateStatus(sessionId, update, actor = { source: 'user' }) { + const normalizedActor = normalizeActor(actor, 'user'); + if (normalizedActor.source !== 'user' && normalizedActor.source !== 'mcp') { + fail('task_status_forbidden', '只有用户或当前来源会话的 MCP 可以更新任务状态。'); + } + const session = loadSessionForTask(sessionId); + const current = normalizeTracking(session.taskTracking); + if (!current.enabled) fail('task_tracking_disabled'); + + const normalizedUpdate = normalizeStatusUpdate(update); + const expectedVersion = readExpectedVersion(update, actor); + assertVersion(expectedVersion, current.version); + const definition = statusMap.get(normalizedUpdate.statusId); + if (!definition) fail('task_status_unknown', undefined, { statusId: normalizedUpdate.statusId }); + if (!definition.enabled) { + fail('task_status_invalid', '目标状态已停用。', { statusId: normalizedUpdate.statusId }); + } + const occurredAt = timestamp(); + const next = { + ...current, + statusId: definition.id, + source: normalizedActor.source, + statusUpdatedAt: occurredAt, + version: nextVersion(current.version), + }; + if (normalizedUpdate.hasReason) next.reason = normalizedUpdate.reason; + if (normalizedUpdate.hasSummary) next.summary = normalizedUpdate.summary; + session.taskTracking = next; + persistSession(session); + return { + ...taskSnapshot(session), + changed: true, + }; + } + + function normalizeDefinitionUpsert(input, current) { + assertPlainObject(input, 'statusDefinition'); + assertAllowedKeys(input, new Set([ + 'id', 'label', 'description', 'prompt', 'color', 'order', 'system', 'enabled', + 'version', 'expectedVersion', + ]), 'statusDefinition'); + const id = validateStatusId(input.id, 'statusDefinition.id'); + const systemDefinition = SYSTEM_STATUS_ID_SET.has(id); + + if (systemDefinition) { + const original = SYSTEM_STATUS_DEFINITIONS.find((item) => item.id === id); + for (const immutableField of ['description', 'system', 'enabled']) { + if (hasOwn(input, immutableField) && input[immutableField] !== original[immutableField]) { + fail('task_status_invalid', `系统状态的 ${immutableField} 不可修改。`, { statusId: id }); + } + } + return { + ...(current || original), + id, + description: original.description, + system: true, + enabled: true, + label: hasOwn(input, 'label') + ? validateString(input.label, { field: 'label', minLength: 1, maxLength: 120, trim: true }) + : (current || original).label, + prompt: hasOwn(input, 'prompt') + ? validateStatusPrompt(input.prompt) + : (current || original).prompt, + color: hasOwn(input, 'color') ? validateColor(input.color) : (current || original).color, + order: hasOwn(input, 'order') ? validateOrder(input.order) : (current || original).order, + }; + } + + if (hasOwn(input, 'system') && input.system !== false) { + fail('task_status_invalid', '自定义状态的 system 必须为 false。'); + } + if (!current) { + for (const requiredField of ['label', 'prompt', 'color', 'order', 'enabled']) { + if (!hasOwn(input, requiredField)) { + fail('task_status_invalid', `新建自定义状态缺少 ${requiredField}。`); + } + } + } + const enabled = hasOwn(input, 'enabled') + ? validateBoolean(input.enabled, 'enabled') + : current.enabled; + return { + id, + label: hasOwn(input, 'label') + ? validateString(input.label, { field: 'label', minLength: 1, maxLength: 120, trim: true }) + : current.label, + description: hasOwn(input, 'description') + ? validateString(input.description, { field: 'description', maxLength: 1000 }) + : (current ? current.description : ''), + prompt: hasOwn(input, 'prompt') ? validateStatusPrompt(input.prompt) : current.prompt, + color: hasOwn(input, 'color') ? validateColor(input.color) : current.color, + order: hasOwn(input, 'order') ? validateOrder(input.order) : current.order, + system: false, + enabled, + }; + } + + function upsertStatusDefinition(input, actor = { source: 'user' }) { + const normalizedActor = normalizeActor(actor, 'user'); + if (normalizedActor.source !== 'user') fail('task_status_forbidden'); + assertPlainObject(input, 'statusDefinition'); + const id = validateStatusId(input.id, 'statusDefinition.id'); + const current = statusMap.get(id) || null; + const expectedVersion = readExpectedVersion(input, actor); + assertVersion(expectedVersion, statusConfigVersion, 'statusDefinitions'); + const definition = normalizeDefinitionUpsert(input, current); + const nextMap = new Map(statusMap); + nextMap.set(id, definition); + const nextConfigVersion = nextVersion(statusConfigVersion); + persistStatusConfig(nextMap, nextConfigVersion); + return { + version: statusConfigVersion, + definition: { ...definition }, + migratedSessionIds: [], + definitions: getStatusDefinitions(), + }; + } + + function parseMigrationTarget(migrateTo) { + if (migrateTo === undefined || migrateTo === null || migrateTo === '') { + return { statusId: null, expectedVersion: null }; + } + if (typeof migrateTo === 'string') { + return { statusId: validateStatusId(migrateTo, 'migrateTo'), expectedVersion: null }; + } + assertPlainObject(migrateTo, 'migrateTo'); + assertAllowedKeys(migrateTo, new Set(['statusId', 'version', 'expectedVersion']), 'migrateTo'); + const statusId = validateStatusId(migrateTo.statusId, 'migrateTo.statusId'); + return { statusId, expectedVersion: readExpectedVersion(migrateTo, null) }; + } + + function removeStatusDefinition(idInput, migrateTo, actor = { source: 'user' }) { + const normalizedActor = normalizeActor(actor, 'user'); + if (normalizedActor.source !== 'user') fail('task_status_forbidden'); + const id = validateStatusId(idInput, 'statusDefinition.id'); + if (SYSTEM_STATUS_ID_SET.has(id)) { + fail('task_status_invalid', '系统状态不可删除。', { statusId: id }); + } + if (!statusMap.has(id)) fail('task_status_unknown', undefined, { statusId: id }); + + const migration = parseMigrationTarget(migrateTo); + const actorExpectedVersion = readExpectedVersion(null, actor); + if (migration.expectedVersion !== null + && actorExpectedVersion !== null + && migration.expectedVersion !== actorExpectedVersion) { + fail('task_status_invalid', '迁移版本与 actor 版本不一致。'); + } + assertVersion(migration.expectedVersion ?? actorExpectedVersion, statusConfigVersion, 'statusDefinitions'); + + let targetDefinition = null; + if (migration.statusId) { + if (migration.statusId === id) fail('task_status_invalid', '迁移目标不能是待删除状态。'); + targetDefinition = statusMap.get(migration.statusId); + if (!targetDefinition) fail('task_status_unknown', undefined, { statusId: migration.statusId }); + if (!targetDefinition.enabled) { + fail('task_status_invalid', '迁移目标状态已停用。', { statusId: migration.statusId }); + } + } + + const referencedSessions = listSessionRecords().filter((session) => ( + normalizeTracking(session.taskTracking).statusId === id + )); + if (referencedSessions.length > 0 && !targetDefinition) { + fail('task_status_in_use', undefined, { + statusId: id, + referenceCount: referencedSessions.length, + }); + } + + const occurredAt = timestamp(); + const migrations = referencedSessions.map((session) => { + const original = cloneJson(session); + const current = normalizeTracking(session.taskTracking); + const next = { + ...current, + statusId: targetDefinition.id, + source: normalizedActor.source, + statusUpdatedAt: occurredAt, + version: nextVersion(current.version), + }; + session.taskTracking = next; + return { original, session }; + }); + + const persisted = []; + try { + for (const migrationEntry of migrations) { + persistSession(migrationEntry.session); + persisted.push(migrationEntry); + } + const nextMap = new Map(statusMap); + nextMap.delete(id); + persistStatusConfig(nextMap, nextVersion(statusConfigVersion)); + } catch (error) { + for (const migrationEntry of persisted.reverse()) { + try { + callSyncDependency(saveSession, [cloneJson(migrationEntry.original)], 'saveSession'); + } catch { + // 回滚是尽力而为;保留原始错误,便于集成层记录。 + } + } + throw error; + } + + return { + version: statusConfigVersion, + removedId: id, + migrateTo: targetDefinition?.id || null, + migratedSessionIds: migrations.map((entry) => entry.session.id), + definitions: getStatusDefinitions(), + }; + } + + function setArchived(sessionId, archived, actor = { source: 'user' }) { + validateBoolean(archived, 'archived'); + const normalizedActor = normalizeActor(actor, 'user'); + if (normalizedActor.source !== 'user') fail('task_archive_forbidden'); + const session = loadSessionForTask(sessionId); + const current = normalizeTracking(session.taskTracking); + if (!current.enabled) fail('task_tracking_disabled'); + const expectedVersion = readExpectedVersion(null, actor); + assertVersion(expectedVersion, current.version); + if (Boolean(current.archivedAt) === archived) return taskSnapshot(session); + + const next = { + ...current, + archivedAt: archived ? timestamp() : null, + archivedBy: archived ? normalizedActor.id : null, + version: nextVersion(current.version), + }; + session.taskTracking = next; + persistSession(session); + return taskSnapshot(session); + } + + function normalizeLifecycleEvent(event) { + assertPlainObject(event, 'event'); + assertAllowedKeys(event, new Set([ + 'type', 'turnId', 'occurredAt', 'outcome', 'pendingUserInput', + 'error', 'version', 'expectedVersion', + ]), 'event'); + const type = validateString(event.type, { field: 'event.type', minLength: 1, maxLength: 64, trim: true }); + if (!LIFECYCLE_EVENT_TYPES.has(type)) { + fail('task_status_invalid', '不支持的生命周期事件。', { type }); + } + if (hasOwn(event, 'turnId')) { + validateString(event.turnId, { field: 'turnId', minLength: 1, maxLength: 128, trim: true }); + } + if (hasOwn(event, 'outcome')) { + validateString(event.outcome, { field: 'outcome', maxLength: 64, trim: true }); + } + if (hasOwn(event, 'pendingUserInput')) validateBoolean(event.pendingUserInput, 'pendingUserInput'); + + let errorMessage = ''; + if (hasOwn(event, 'error') && event.error !== null) { + if (typeof event.error === 'string') { + errorMessage = validateString(event.error, { field: 'error', maxLength: 2000 }); + } else if (isPlainObject(event.error) && typeof event.error.message === 'string') { + errorMessage = validateString(event.error.message, { field: 'error.message', maxLength: 2000 }); + } else { + fail('task_status_invalid', 'error 必须是字符串、null 或包含 message 的对象。'); + } + } + return { + ...event, + type, + occurredAt: hasOwn(event, 'occurredAt') ? normalizeIsoTimestamp(event.occurredAt) : timestamp(), + errorMessage, + }; + } + + function lifecycleResult(session, options = {}) { + return { + changed: options.changed === true, + ignored: options.ignored === true, + reason: options.reason || null, + task: taskSnapshot(session), + }; + } + + function recordLifecycleEvent(sessionId, event) { + const normalizedEvent = normalizeLifecycleEvent(event); + const session = loadSessionForTask(sessionId); + const current = normalizeTracking(session.taskTracking); + if (!current.enabled) { + return lifecycleResult(session, { ignored: true, reason: 'task_tracking_disabled' }); + } + const expectedVersion = readExpectedVersion(event, null); + assertVersion(expectedVersion, current.version); + if (current.archivedAt && normalizedEvent.type !== 'user_message_received') { + return lifecycleResult(session, { ignored: true, reason: 'task_archived' }); + } + + if (normalizedEvent.type !== 'user_message_received' || !current.archivedAt) { + return lifecycleResult(session, { ignored: true, reason: 'no_change' }); + } + + const next = { + ...current, + archivedAt: null, + archivedBy: null, + }; + + next.version = nextVersion(current.version); + session.taskTracking = next; + persistSession(session); + return lifecycleResult(session, { changed: true }); + } + + return Object.freeze({ + getStatusDefinitions, + getStatusDefinitionSnapshot, + upsertStatusDefinition, + removeStatusDefinition, + getTask, + listTasks, + setTracking, + updateStatus, + setArchived, + recordLifecycleEvent, + }); +} + +module.exports = { + MAX_VERSION, + STATUS_ID_PATTERN, + SYSTEM_STATUS_IDS, + SYSTEM_STATUS_DEFINITIONS, + TaskBoardError, + createTaskBoardService, +}; diff --git a/public/app.js b/public/app.js index e2c3731..12c4315 100644 --- a/public/app.js +++ b/public/app.js @@ -295,6 +295,12 @@ savedSelection: null, }; let usageDashboardRequestSeq = 0; + const taskBoardViewState = { + open: false, + savedFocus: null, + }; + let taskBoardController = null; + let taskTrackingController = null; const usageEchartInstances = new Set(); let usageChartResizeFrame = 0; let usageChartRefreshFrame = 0; @@ -318,6 +324,7 @@ const pendingNotesByTarget = new Map(); const queuedMessagesByTarget = new Map(); const userMessageIndex = new Map(); + const codexAppSteerRecords = new Map(); const pendingSlashDraftsByRequestId = new Map(); const expandedOldSessionGroups = new Set(); document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide'; @@ -388,6 +395,11 @@ const usageDashboardProjectEmpty = $('#usage-dashboard-project-empty'); const usageDashboardSessionRows = $('#usage-dashboard-session-rows'); const usageDashboardSessionEmpty = $('#usage-dashboard-session-empty'); + const taskBoardOpen = $('#task-board-open'); + const taskBoardPanel = $('#task-board-panel'); + const taskBoardClose = $('#task-board-close'); + const taskBoardRoot = $('#task-board-root'); + const taskTrackingControl = $('#task-tracking-control'); const sessionList = $('#session-list'); const chatHeader = chatMain?.querySelector('.chat-header') || null; const messagesWrap = chatMain?.querySelector('.messages-wrap') || null; @@ -1549,6 +1561,7 @@ function clearUserMessageIndex() { userMessageIndex.clear(); + codexAppSteerRecords.clear(); } function registerUserMessage(messageId, element, content, timestamp = '') { @@ -1562,6 +1575,73 @@ }); } + function cloneCodexAppSteerAttachments(attachments) { + return Array.isArray(attachments) + ? attachments.map((attachment) => ({ ...attachment })) + : []; + } + + function registerCodexAppSteerRecord(messageId, options = {}) { + const id = String(messageId || '').trim(); + if (!id || !options.element) return null; + const previous = codexAppSteerRecords.get(id) || {}; + const record = { + id, + element: options.element, + text: String(options.text || ''), + attachments: cloneCodexAppSteerAttachments(options.attachments), + sessionId: options.sessionId || null, + mode: options.mode || currentMode, + agent: options.agent || currentAgent, + status: options.status || previous.status || 'pending', + message: options.message || previous.message || '', + committed: previous.committed === true, + inFlight: false, + timestamp: options.timestamp || previous.timestamp || '', + }; + codexAppSteerRecords.set(id, record); + return record; + } + + function clearCodexAppSteerRecord(messageId) { + const id = String(messageId || '').trim(); + if (!id) return; + codexAppSteerRecords.delete(id); + } + + function commitCodexAppSteerMessage(record) { + if (!record || record.committed) return false; + const element = record.element; + if (!element || record.sessionId !== currentSessionId) return false; + if (element.dataset.sessionMessage === 'true') { + record.committed = true; + return false; + } + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(element, messageIndex); + record.committed = true; + updateUserOutlinePanel(); + return true; + } + + function registerCodexAppSteerSessionMessage(sessionId, message, element) { + if (!message || message.role !== 'user' || !message.codexAppSteerStatus || !element) return null; + const messageId = String(message.id || message.messageId || element.dataset.messageId || '').trim(); + if (!messageId) return null; + return registerCodexAppSteerRecord(messageId, { + element, + text: message.content, + attachments: message.attachments, + sessionId, + mode: message.mode || currentMode, + agent: message.agent || currentAgent, + status: message.codexAppSteerStatus, + message: message.codexAppSteerMessage, + timestamp: message.timestamp, + }); + } + function normalizeOutlineTitleHistory(history) { if (!Array.isArray(history)) return []; const normalized = []; @@ -2118,6 +2198,13 @@ waitingReplyCount: Number(payload.waitingReplyCount || 0), failedReplyCount: Number(payload.failedReplyCount || 0), pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [], + taskTracking: payload.taskTracking ? deepClone(payload.taskTracking) : { + enabled: payload.taskTrackingEnabled === true, + statusId: 'unassigned', + version: 0, + }, + taskTrackingEnabled: payload.taskTrackingEnabled === true + || payload.taskTracking?.enabled === true, historyTotal, historyBaseIndex, historyPending: !!payload.historyPending, @@ -5287,6 +5374,84 @@ }); } + function setTaskBoardUnderlyingInert(inert) { + if (!chatMain) return; + if (inert) chatMain.setAttribute('data-task-board-inert', 'true'); + else chatMain.removeAttribute('data-task-board-inert'); + if (messagesWrap) messagesWrap.toggleAttribute('inert', inert); + if (inputArea) inputArea.toggleAttribute('inert', inert); + if (chatHeader) chatHeader.toggleAttribute('inert', inert); + } + + function closeTaskBoard(options = {}) { + if (!taskBoardPanel || !taskBoardViewState.open) return; + taskBoardViewState.open = false; + taskBoardPanel.hidden = true; + taskBoardPanel.setAttribute('aria-hidden', 'true'); + taskBoardOpen?.setAttribute('aria-expanded', 'false'); + setTaskBoardUnderlyingInert(false); + requestAnimationFrame(() => { + if (options.restoreFocus !== false) { + const target = taskBoardViewState.savedFocus; + if (target?.isConnected && typeof target.focus === 'function') target.focus(); + else taskBoardOpen?.focus(); + } + }); + } + + function openTaskBoard() { + if (!taskBoardPanel || !taskBoardRoot || !window.CcwebTaskBoard) return; + if (usageDashboardState.open) closeUsageDashboard({ restoreFocus: false }); + if (advancedSessionSearchState.open) closeAdvancedSessionSearch({ restoreFocus: false }); + taskBoardViewState.savedFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + taskBoardViewState.open = true; + taskBoardPanel.hidden = false; + taskBoardPanel.setAttribute('aria-hidden', 'false'); + taskBoardOpen?.setAttribute('aria-expanded', 'true'); + setTaskBoardUnderlyingInert(true); + if (isSidebarDrawerMode()) closeSidebar(); + if (!taskBoardController) { + taskBoardController = window.CcwebTaskBoard.mount(taskBoardRoot, { + send, + openSession(sessionId) { + closeTaskBoard({ restoreFocus: false }); + openSession(sessionId); + }, + onError(error) { showToast(error?.message || '任务看板操作失败'); }, + }); + } else { + taskBoardController.refresh(); + } + requestAnimationFrame(() => taskBoardPanel.querySelector('button, input, select')?.focus()); + } + + function syncTaskTrackingControl(snapshot = null) { + if (!taskTrackingControl || !window.CcwebTaskBoard) return; + const sessionId = snapshot?.sessionId || currentSessionId || ''; + const tracking = snapshot?.taskTracking || {}; + if (!sessionId) { + taskTrackingControl.hidden = true; + if (taskTrackingController) taskTrackingController.setState({ sessionId: '', disabled: true }); + return; + } + taskTrackingControl.hidden = false; + const state = { + sessionId, + enabled: tracking.enabled === true || snapshot?.taskTrackingEnabled === true, + version: Number.isFinite(Number(tracking.version)) ? Number(tracking.version) : null, + disabled: false, + }; + if (!taskTrackingController) { + taskTrackingController = window.CcwebTaskBoard.renderTrackingControl(taskTrackingControl, { + ...state, + send, + onError(error) { showToast(error?.message || '保存任务跟踪设置失败'); }, + }); + } else { + taskTrackingController.setState(state); + } + } + function handleUsageStatisticsResult(msg) { const requestId = String(msg?.requestId || ''); if (!requestId || requestId !== usageDashboardState.requestId) return; @@ -5703,6 +5868,7 @@ closeCcwebPromptOutlinePanel(); closeFileBrowser(); currentSessionId = null; + syncTaskTrackingControl(null); loadedHistorySessionId = null; currentSessionMessageCount = 0; currentOutlineTitleHistory = []; @@ -5762,6 +5928,7 @@ activeTodoCallTargets.clear(); } currentSessionId = snapshot.sessionId; + syncTaskTrackingControl(snapshot); loadedHistorySessionId = snapshot.sessionId; currentOutlineTitleHistory = normalizeOutlineTitleHistory(snapshot.titleHistory); currentSessionMessageCount = Math.max( @@ -6130,6 +6297,7 @@ function openSession(sessionId, options = {}) { if (!sessionId) return; + if (taskBoardViewState.open) closeTaskBoard({ restoreFocus: false }); if (usageDashboardState.open) closeUsageDashboard({ restoreFocus: false }); const meta = getSessionMeta(sessionId); const cachedAgent = sessionCache.get(sessionId)?.snapshot?.agent; @@ -6728,7 +6896,46 @@ return true; } + function handleTaskBoardProtocolMessage(msg) { + const taskMessageTypes = new Set([ + 'task_board_result', + 'task_board_event', + 'task_tracking_result', + 'task_status_result', + 'task_status_definitions_result', + 'task_archive_result', + ]); + if (!taskMessageTypes.has(msg?.type)) return false; + + taskBoardController?.handleMessage(msg); + taskTrackingController?.handleMessage(msg); + + const task = msg.task && typeof msg.task === 'object' ? msg.task : null; + const sessionId = String(task?.sessionId || msg.sessionId || ''); + if (task?.taskTracking && sessionId) { + const listItem = sessions.find((session) => session.id === sessionId); + if (listItem) { + listItem.taskTracking = deepClone(task.taskTracking); + listItem.taskTrackingEnabled = task.taskTracking.enabled === true; + } + const cached = sessionCache.get(sessionId)?.snapshot; + if (cached) { + cached.taskTracking = deepClone(task.taskTracking); + cached.taskTrackingEnabled = task.taskTracking.enabled === true; + } + if (sessionId === currentSessionId) { + syncTaskTrackingControl({ + sessionId, + taskTracking: task.taskTracking, + taskTrackingEnabled: task.taskTracking.enabled === true, + }); + } + } + return true; + } + function handleServerMessage(msg) { + if (handleTaskBoardProtocolMessage(msg)) return; switch (msg.type) { case 'auth_result': if (msg.success) { @@ -6928,9 +7135,10 @@ case 'session_message': if (msg.sessionId && msg.message) { const isCrossConversationReply = !!msg.message.crossConversation?.replyToRequestId; + const isProvisionalCodexAppSteer = !!msg.message.codexAppSteerStatus; updateCachedSession(msg.sessionId, (snapshot) => { snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; - snapshot.messages.push(deepClone(msg.message)); + if (!isProvisionalCodexAppSteer) snapshot.messages.push(deepClone(msg.message)); snapshot.updated = msg.message.timestamp || new Date().toISOString(); if (isCrossConversationReply) { snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1); @@ -6953,13 +7161,16 @@ } } if (msg.sessionId === currentSessionId && msg.message) { - const messageIndex = currentSessionMessageCount; - currentSessionMessageCount += 1; + const isProvisionalCodexAppSteer = !!msg.message.codexAppSteerStatus; + const messageIndex = isProvisionalCodexAppSteer ? null : currentSessionMessageCount; + if (!isProvisionalCodexAppSteer) currentSessionMessageCount += 1; collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom(); - messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex)); + const element = buildMsgElement(msg.message, messageIndex); + messagesDiv.appendChild(element); + registerCodexAppSteerSessionMessage(msg.sessionId, msg.message, element); followOutputIfNeeded(shouldFollow); setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); renderPendingCcwebPrompts({ scroll: false }); @@ -7231,6 +7442,7 @@ title: request.title, branchSourceSessionId: request.branchSourceSessionId, branchMessageIndex: request.branchMessageIndex, + taskTrackingEnabled: request.taskTrackingEnabled === true, createCwd: true, requestId: request.requestId, }); @@ -7244,6 +7456,7 @@ title: request.title, branchSourceSessionId: request.branchSourceSessionId, branchMessageIndex: request.branchMessageIndex, + taskTrackingEnabled: request.taskTrackingEnabled === true, }); }, }); @@ -7490,13 +7703,81 @@ return '引导中...'; } + function retryCodexAppSteerMessage(messageId) { + const id = String(messageId || '').trim(); + const record = id ? codexAppSteerRecords.get(id) : null; + if (!record || record.status !== 'failed' || record.inFlight) return false; + if (record.sessionId && currentSessionId && record.sessionId !== currentSessionId) { + appendError('该失败项不属于当前会话,无法重试。'); + setCodexAppSteerStatusElement(record.element, 'failed', record.message || '插入失败'); + return false; + } + if (!isGenerating || !currentSessionRunning || !isCodexAppAgent(currentAgent)) { + appendError('当前对话已结束,无法在原 turn 中重试插入。'); + setCodexAppSteerStatusElement(record.element, 'failed', record.message || '插入失败'); + return false; + } + if (!ws || ws.readyState !== 1) { + appendError('WebSocket 已断开,请恢复连接后重试。'); + setCodexAppSteerStatusElement(record.element, 'failed', record.message || '插入失败'); + return false; + } + + record.inFlight = true; + setCodexAppSteerStatusElement(record.element, 'pending', '引导中...'); + try { + send({ + type: 'message', + text: record.text, + attachments: cloneCodexAppSteerAttachments(record.attachments), + sessionId: record.sessionId, + mode: record.mode, + agent: record.agent, + clientMessageId: id, + }); + return true; + } catch (err) { + record.inFlight = false; + record.status = 'failed'; + record.message = record.message || '插入失败'; + setCodexAppSteerStatusElement(record.element, 'failed', record.message); + appendError(err?.message || '重试发送失败。'); + return false; + } + } + + function deleteCodexAppSteerMessage(messageId) { + const id = String(messageId || '').trim(); + if (!id) return false; + const record = codexAppSteerRecords.get(id); + const indexed = userMessageIndex.get(id); + const element = record?.element || indexed?.element || messagesDiv.querySelector(`[data-message-id="${cssEscape(id)}"]`); + const status = record?.status || element?.querySelector('.codex-steer-status')?.dataset.status; + if (status !== 'failed') return false; + if (record?.inFlight) return false; + if (element) element.remove(); + clearCodexAppSteerRecord(id); + userMessageIndex.delete(id); + updateUserOutlinePanel(); + updateScrollbar(); + return true; + } + function setCodexAppSteerStatusElement(element, status, message) { if (!element) return false; const normalized = ['pending', 'inserted', 'failed'].includes(status) ? status : 'pending'; + const messageId = String(element.dataset.messageId || '').trim(); + const record = messageId ? codexAppSteerRecords.get(messageId) : null; + if (record) { + record.status = normalized; + record.message = message || codexAppSteerStatusLabel(normalized); + if (normalized !== 'pending') record.inFlight = false; + } element.classList.add('codex-steer-message'); element.classList.toggle('codex-steer-pending', normalized === 'pending'); element.classList.toggle('codex-steer-inserted', normalized === 'inserted'); element.classList.toggle('codex-steer-failed', normalized === 'failed'); + element.setAttribute('aria-busy', normalized === 'pending' ? 'true' : 'false'); const bubble = element.querySelector('.msg-bubble'); if (!bubble) return false; let statusEl = bubble.querySelector('.codex-steer-status'); @@ -7506,7 +7787,45 @@ bubble.appendChild(statusEl); } statusEl.dataset.status = normalized; + statusEl.setAttribute('role', 'status'); + statusEl.setAttribute('aria-live', 'polite'); statusEl.textContent = message || codexAppSteerStatusLabel(normalized); + let actions = bubble.querySelector('.codex-steer-actions'); + if (normalized !== 'failed') { + if (actions) actions.remove(); + if (normalized === 'inserted' && record) commitCodexAppSteerMessage(record); + return true; + } + if (!actions) { + actions = document.createElement('div'); + actions.className = 'codex-steer-actions'; + const retryButton = document.createElement('button'); + retryButton.type = 'button'; + retryButton.className = 'codex-steer-action codex-steer-retry'; + retryButton.textContent = '重试'; + retryButton.setAttribute('aria-label', '重试插入此消息'); + retryButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + retryCodexAppSteerMessage(messageId); + }); + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'codex-steer-action codex-steer-delete'; + deleteButton.textContent = '删除'; + deleteButton.setAttribute('aria-label', '删除失败插入项'); + deleteButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + deleteCodexAppSteerMessage(messageId); + }); + actions.appendChild(retryButton); + actions.appendChild(deleteButton); + bubble.appendChild(actions); + } + actions.querySelectorAll('button').forEach((button) => { + button.disabled = !!record?.inFlight; + }); return true; } @@ -10883,12 +11202,16 @@ } else { messagesDiv.appendChild(element); } - if (currentSessionId) { - const messageIndex = currentSessionMessageCount; - currentSessionMessageCount += 1; - markSessionMessageElement(element, messageIndex); - } registerUserMessage(messageId, element, text, timestamp); + registerCodexAppSteerRecord(messageId, { + element, + text, + attachments, + sessionId: currentSessionId, + mode: currentMode, + agent: currentAgent, + timestamp, + }); updateUserOutlinePanel(); if (shouldFollow) { scrollToBottom(); @@ -11154,6 +11477,11 @@ setUsageDashboardFeatureEnabled(false); } + if (taskBoardOpen && taskBoardPanel && taskBoardRoot) { + taskBoardOpen.addEventListener('click', openTaskBoard); + taskBoardClose?.addEventListener('click', () => closeTaskBoard()); + } + document.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'f') { event.preventDefault(); @@ -11170,6 +11498,11 @@ closeUsageDashboard(); return; } + if (event.key === 'Escape' && taskBoardViewState.open) { + event.preventDefault(); + closeTaskBoard(); + return; + } if (event.key === 'Escape' && advancedSessionSearchState.open) { event.preventDefault(); closeAdvancedSessionSearch(); @@ -12563,6 +12896,8 @@ const branchMessageIndex = Number.isFinite(Number(options.branchMessageIndex)) ? Number(options.branchMessageIndex) : null; + const taskTrackingEnabled = options.taskTrackingEnabled === true + || options.taskTracking?.enabled === true; const requestId = createSessionSwitchRequestId('new'); pendingNewSessionRequest = { cwd, @@ -12573,6 +12908,7 @@ title, branchSourceSessionId, branchMessageIndex, + taskTrackingEnabled, requestId, }; if (cwd) saveRecentCwd(cwd); @@ -12581,6 +12917,7 @@ if (title) payload.title = title; if (branchSourceSessionId) payload.branchSourceSessionId = branchSourceSessionId; if (branchMessageIndex !== null) payload.branchMessageIndex = branchMessageIndex; + if (taskTrackingEnabled) payload.taskTrackingEnabled = true; send(payload); } @@ -12717,6 +13054,7 @@ title: options.title || '', branchSourceSessionId: options.branchSourceSessionId || '', branchMessageIndex: options.branchMessageIndex, + taskTrackingEnabled: options.taskTrackingEnabled === true, }); } diff --git a/public/index.html b/public/index.html index f3cd751..7d5b443 100644 --- a/public/index.html +++ b/public/index.html @@ -25,6 +25,7 @@ })(); + @@ -74,6 +75,9 @@ + CC-Web @@ -117,6 +121,14 @@ + + @@ -330,6 +342,7 @@ + @@ -387,6 +400,7 @@ + diff --git a/public/style.css b/public/style.css index dcc3bbe..12695a1 100644 --- a/public/style.css +++ b/public/style.css @@ -3143,6 +3143,41 @@ html[data-theme='wasteland'] .advanced-search-panel { .codex-steer-status[data-status='failed'] { color: var(--danger); } +.codex-steer-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; +} +.codex-steer-action { + min-height: 24px; + padding: 3px 9px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + line-height: 1.2; + cursor: pointer; +} +.codex-steer-action:hover:not(:disabled), +.codex-steer-action:focus-visible:not(:disabled) { + border-color: var(--accent); + color: var(--text-primary); +} +.codex-steer-action:disabled { + cursor: not-allowed; + opacity: 0.58; +} +.codex-steer-retry { + border-color: rgba(var(--accent-rgb, 66, 153, 225), 0.42); + color: var(--accent); +} +.codex-steer-delete { + border-color: rgba(220, 53, 69, 0.32); + color: var(--danger); +} .msg.user.codex-steer-pending .msg-copy-btn { background: rgba(255, 255, 255, 0.08); border-color: var(--border-color); @@ -4805,20 +4840,36 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { padding-right: 6px; } .chat-controls { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: stretch; + display: flex; + flex-wrap: wrap; + align-items: center; + } + .user-outline-anchor, + .ccweb-prompt-outline-anchor { + position: static; } .ccweb-prompt-outline-anchor { - width: 100%; + width: auto; + max-width: 100%; + } + .user-outline-panel, + .ccweb-prompt-outline-panel { + left: 0; + right: 0; + width: auto; + bottom: calc(100% + 6px); + max-height: min(44vh, 300px); + } + .chat-cwd { + width: 100%; + max-width: none; + justify-content: center; } - .chat-cwd, .mode-select, .user-outline-btn, .ccweb-prompt-outline-btn, .reload-mcp-btn, .chat-runtime-state { - width: 100%; max-width: none; justify-content: center; } @@ -4842,17 +4893,6 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { left: 0; right: auto; } - .user-outline-panel { - left: auto; - right: 0; - width: min(320px, calc(100vw - 20px)); - max-height: min(44vh, 300px); - bottom: calc(100% + 6px); - } - .ccweb-prompt-outline-panel { - left: 0; - right: auto; - } .msg-copy-btn { opacity: 1; width: 24px; diff --git a/public/task-board.css b/public/task-board.css new file mode 100644 index 0000000..51f8000 --- /dev/null +++ b/public/task-board.css @@ -0,0 +1,1141 @@ +.ccweb-task-board { + position: relative; + isolation: isolate; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: 100%; + height: 100%; + min-width: 0; + min-height: 32rem; + overflow: hidden; + color: var(--text-primary); + background: var(--bg-primary); + font-family: var(--font-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC", sans-serif); + line-height: 1.45; + animation: ccweb-task-board-enter 180ms ease-out both; +} + +.ccweb-task-board *, +.ccweb-task-board *::before, +.ccweb-task-board *::after, +.ccweb-task-tracking, +.ccweb-task-tracking *, +.ccweb-task-tracking *::before, +.ccweb-task-tracking *::after { + box-sizing: border-box; +} + +.ccweb-task-board button, +.ccweb-task-board input, +.ccweb-task-board select, +.ccweb-task-board textarea, +.ccweb-task-tracking input { + font: inherit; +} + +.ccweb-task-board [hidden] { + display: none !important; +} + +.ccweb-task-board__sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.ccweb-task-board__toolbar { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: minmax(9rem, auto) minmax(0, 1fr); + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); +} + +.ccweb-task-board__heading { + min-width: 0; +} + +.ccweb-task-board__title, +.ccweb-task-board__summary, +.ccweb-task-board__column-title, +.ccweb-task-board__empty h3, +.ccweb-task-board__empty p, +.ccweb-task-board__status-title, +.ccweb-task-board__status-summary { + margin: 0; +} + +.ccweb-task-board__title { + color: var(--text-primary); + font-size: clamp(1.05rem, 1.6vw, 1.28rem); + font-weight: 760; + letter-spacing: -0.025em; +} + +.ccweb-task-board__summary { + margin-top: 0.16rem; + color: var(--text-muted); + font-size: 0.76rem; +} + +.ccweb-task-board[data-error] .ccweb-task-board__summary { + color: var(--danger); +} + +.ccweb-task-board__controls { + display: grid; + grid-template-columns: minmax(13rem, 1fr) auto auto; + align-items: center; + justify-content: end; + gap: 0.55rem; + min-width: 0; +} + +.ccweb-task-board__search { + position: relative; + display: flex; + align-items: center; + min-width: 0; +} + +.ccweb-task-board__search-glyph { + position: absolute; + left: 0.76rem; + z-index: 1; + color: var(--text-muted); + font-size: 1.2rem; + line-height: 1; + pointer-events: none; +} + +.ccweb-task-board__input, +.ccweb-task-board__field-control, +.ccweb-task-board__card-select { + width: 100%; + min-width: 0; + border: 1px solid var(--border-color); + border-radius: 0.72rem; + outline: 0; + color: var(--text-primary); + background: var(--bg-tertiary); + transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease; +} + +.ccweb-task-board__input { + height: 2.6rem; +} + +.ccweb-task-board__input { + padding: 0 0.8rem 0 2.25rem; +} + +.ccweb-task-board__input::placeholder, +.ccweb-task-board__field-control::placeholder { + color: var(--text-muted); + opacity: 0.84; +} + +.ccweb-task-board__button, +.ccweb-task-board__icon-button, +.ccweb-task-board__card-action { + appearance: none; + border: 1px solid var(--border-color); + outline: 0; + color: var(--text-secondary); + background: transparent; + cursor: pointer; + transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease, transform 140ms ease; +} + +.ccweb-task-board__button { + min-height: 2.6rem; + padding: 0.55rem 0.76rem; + border-radius: 0.72rem; + white-space: nowrap; + font-size: 0.78rem; + font-weight: 680; +} + +.ccweb-task-board__button--primary { + border-color: var(--accent); + color: var(--accent-ink, var(--surface-strong)); + background: var(--accent); +} + +.ccweb-task-board__button--danger { + border-color: color-mix(in srgb, var(--danger) 38%, var(--border-color)); + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} + +.ccweb-task-board__icon-button { + display: inline-grid; + place-items: center; + width: 2.6rem; + height: 2.6rem; + padding: 0; + border-radius: 0.72rem; + font-size: 1.12rem; +} + +.ccweb-task-board__button:hover, +.ccweb-task-board__icon-button:hover, +.ccweb-task-board__card-action:hover { + border-color: var(--accent); + color: var(--text-primary); + background: var(--accent-light); +} + +.ccweb-task-board__button--primary:hover { + border-color: var(--accent-hover); + color: var(--accent-ink, var(--surface-strong)); + background: var(--accent-hover); +} + +.ccweb-task-board__button--danger:hover { + border-color: var(--danger); + color: var(--text-primary); + background: color-mix(in srgb, var(--danger) 20%, transparent); +} + +.ccweb-task-board__button:disabled, +.ccweb-task-board__icon-button:disabled, +.ccweb-task-board__card-action:disabled, +.ccweb-task-board__card-select:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.ccweb-task-board__workspace { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.ccweb-task-board__lanes { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(18.5rem, 22rem); + align-items: stretch; + gap: 0.9rem; + width: 100%; + height: 100%; + min-height: 0; + padding: 1rem; + overflow-x: auto; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-color: color-mix(in srgb, var(--text-muted) 50%, transparent) transparent; + scrollbar-width: thin; +} + +.ccweb-task-board__lanes:focus-visible { + outline: 2px solid var(--accent-light); + outline-offset: -3px; +} + +.ccweb-task-board__lanes::-webkit-scrollbar, +.ccweb-task-board__status-body::-webkit-scrollbar { + width: 0.55rem; + height: 0.55rem; +} + +.ccweb-task-board__lanes::-webkit-scrollbar-thumb, +.ccweb-task-board__status-body::-webkit-scrollbar-thumb { + border: 0.12rem solid transparent; + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 50%, transparent); + background-clip: padding-box; +} + +.ccweb-task-board__column { + --task-board-status-color: var(--text-muted); + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 100%; + overflow: visible; + border: 1px solid var(--border-color); + border-radius: 1rem; + background: var(--bg-secondary); +} + +.ccweb-task-board__column--motion { + z-index: 3; +} + +.ccweb-task-board__column-header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + min-height: 3.65rem; + padding: 0.88rem 0.9rem; + border-bottom: 1px solid var(--border-color); + border-radius: 1rem 1rem 0 0; + background: var(--bg-secondary); +} + +.ccweb-task-board__column-title-wrap { + display: flex; + align-items: center; + min-width: 0; + gap: 0.55rem; +} + +.ccweb-task-board__column-dot, +.ccweb-task-board__definition-dot { + display: inline-block; + flex: 0 0 auto; + width: 0.58rem; + height: 0.58rem; + border-radius: 50%; + background: var(--task-board-status-color); + box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--task-board-status-color) 18%, transparent); +} + +.ccweb-task-board__column-title { + overflow: hidden; + color: var(--text-primary); + font-size: 0.97rem; + font-weight: 730; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ccweb-task-board__column-count { + display: inline-grid; + place-items: center; + min-width: 1.55rem; + height: 1.42rem; + padding: 0 0.4rem; + border-radius: 999px; + color: var(--text-secondary); + background: var(--accent-light); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.ccweb-task-board__column-disabled { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 0.68rem; +} + +.ccweb-task-board__column-body { + display: flex; + flex: 1 0 auto; + flex-direction: column; + gap: 0.68rem; + min-height: 11rem; + padding: 0.7rem; +} + +.ccweb-task-board__column-empty { + display: grid; + place-items: center; + align-content: center; + gap: 0.25rem; + min-height: 9rem; + color: var(--text-muted); + font-size: 0.79rem; +} + +.ccweb-task-board__column-empty-mark { + color: var(--task-board-status-color); + font-size: 1.65rem; + line-height: 1; +} + +.ccweb-task-board__card { + --task-board-status-color: var(--text-muted); + position: relative; + display: flex; + flex-direction: column; + gap: 0.68rem; + padding: 0.78rem; + border: 1px solid var(--border-color); + border-radius: 0.82rem; + background: var(--surface-strong); + box-shadow: none; + transition: transform 150ms ease, border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; +} + +.ccweb-task-board__card[data-motion] { + pointer-events: none; + transition: none; + will-change: height, opacity, transform; +} + +.ccweb-task-board__card[data-motion="moving"] { + z-index: 4; +} + +.ccweb-task-board__card[data-motion="entering"], +.ccweb-task-board__card[data-motion="leaving"] { + overflow: hidden; + transform-origin: 50% 0; +} + +.ccweb-task-board__card:hover, +.ccweb-task-board__card:focus-within { + z-index: 1; + border-color: var(--accent); + background: var(--bg-tertiary); + box-shadow: none; + transform: translateY(-2px); +} + +.ccweb-task-board__card-top, +.ccweb-task-board__card-footer, +.ccweb-task-board__card-actions, +.ccweb-task-board__state-row, +.ccweb-task-board__metadata { + display: flex; + align-items: center; +} + +.ccweb-task-board__card-top, +.ccweb-task-board__card-footer { + justify-content: space-between; + gap: 0.6rem; +} + +.ccweb-task-board__session-id { + overflow: hidden; + color: var(--text-muted); + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ccweb-task-board__card-open { + appearance: none; + display: flex; + flex-direction: column; + gap: 0.28rem; + width: 100%; + padding: 0; + border: 0; + outline: 0; + color: inherit; + background: transparent; + text-align: left; + cursor: pointer; +} + +.ccweb-task-board__card-title { + display: -webkit-box; + overflow: hidden; + color: var(--text-primary); + font-size: 0.94rem; + font-weight: 680; + letter-spacing: -0.012em; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.ccweb-task-board__card-summary { + display: -webkit-box; + overflow: hidden; + color: var(--text-secondary); + font-size: 0.76rem; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.ccweb-task-board__state-row { + flex-wrap: wrap; + gap: 0.34rem; +} + +.ccweb-task-board__runtime-state { + display: inline-flex; + align-items: center; + min-height: 1.45rem; + padding: 0.16rem 0.42rem; + border-radius: 0.38rem; + font-size: 0.66rem; + line-height: 1.2; +} + +.ccweb-task-board__runtime-state { + border: 1px dashed var(--accent); + color: var(--text-secondary); + background: var(--bg-tertiary); +} + +.ccweb-task-board__card-footer { + align-items: flex-end; + padding-top: 0.12rem; + border-top: 1px solid var(--border-color); +} + +.ccweb-task-board__metadata { + flex: 1 1 auto; + flex-direction: column; + align-items: flex-start; + min-width: 0; + color: var(--text-muted); + font-size: 0.65rem; +} + +.ccweb-task-board__metadata span, +.ccweb-task-board__metadata time { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ccweb-task-board__card-actions { + flex: 0 0 auto; + gap: 0.34rem; +} + +.ccweb-task-board__status-select-wrap { + display: block; + width: 6.8rem; +} + +.ccweb-task-board__card-select { + height: 1.85rem; + padding: 0 1.35rem 0 0.45rem; + font-size: 0.67rem; + cursor: pointer; +} + +.ccweb-task-board__card-action { + min-height: 1.85rem; + padding: 0.22rem 0.48rem; + border-radius: 0.48rem; + font-size: 0.67rem; +} + +.ccweb-task-board__empty { + display: grid; + grid-column: 1 / -1; + place-items: center; + align-content: center; + width: min(31rem, calc(100vw - 3rem)); + min-height: 19rem; + padding: 2rem; + border: 1px dashed var(--border-color); + border-radius: 1rem; + color: var(--text-muted); + text-align: center; +} + +.ccweb-task-board__empty-mark { + color: var(--accent); + font-size: 2.5rem; + line-height: 1; +} + +.ccweb-task-board__empty h3 { + margin-top: 0.85rem; + color: var(--text-primary); + font-size: 1rem; +} + +.ccweb-task-board__empty p { + max-width: 26rem; + margin-top: 0.35rem; + font-size: 0.78rem; +} + +.ccweb-task-board__status-layer { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(22rem, 32rem); +} + +.ccweb-task-board__status-scrim { + appearance: none; + width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: color-mix(in srgb, var(--bg-primary) 72%, transparent); + cursor: default; + animation: ccweb-task-board-fade-in 160ms ease-out both; +} + +.ccweb-task-board__status-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + border-left: 1px solid var(--accent); + background: var(--bg-primary); + box-shadow: none; + animation: ccweb-task-board-panel-in 180ms ease-out both; +} + +.ccweb-task-board__status-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.05rem; + border-bottom: 1px solid var(--border-color); +} + +.ccweb-task-board__status-title { + color: var(--text-primary); + font-size: 1.08rem; +} + +.ccweb-task-board__status-summary { + margin-top: 0.2rem; + color: var(--text-muted); + font-size: 0.72rem; +} + +.ccweb-task-board__status-body { + min-height: 0; + padding: 0.9rem; + overflow-y: auto; + scrollbar-color: color-mix(in srgb, var(--text-muted) 50%, transparent) transparent; + scrollbar-width: thin; +} + +.ccweb-task-board__add-status { + margin-bottom: 0.8rem; + border: 1px solid var(--border-color); + border-radius: 0.8rem; + background: var(--bg-secondary); +} + +.ccweb-task-board__add-status-summary { + padding: 0.78rem 0.86rem; + color: var(--accent); + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.ccweb-task-board__add-status[open] > .ccweb-task-board__add-status-summary { + border-bottom: 1px solid var(--border-color); +} + +.ccweb-task-board__definition-list { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.ccweb-task-board__definition { + --task-board-status-color: var(--text-muted); + display: flex; + flex-direction: column; + gap: 0.72rem; + padding: 0.82rem; + border: 1px solid var(--border-color); + border-left-color: var(--task-board-status-color); + border-radius: 0.76rem; + background: var(--bg-secondary); +} + +.ccweb-task-board__add-status .ccweb-task-board__definition { + border: 0; + border-radius: 0 0 0.76rem 0.76rem; +} + +.ccweb-task-board__definition-header, +.ccweb-task-board__definition-identity, +.ccweb-task-board__definition-actions, +.ccweb-task-board__migration { + display: flex; + align-items: center; +} + +.ccweb-task-board__definition-header { + justify-content: space-between; + gap: 0.8rem; +} + +.ccweb-task-board__definition-identity { + min-width: 0; + gap: 0.5rem; + color: var(--text-primary); + font-size: 0.8rem; +} + +.ccweb-task-board__definition-identity strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ccweb-task-board__definition-kind { + flex: 0 0 auto; + color: var(--text-muted); + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 0.58rem; + letter-spacing: 0.09em; +} + +.ccweb-task-board__definition-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.58rem; +} + +.ccweb-task-board__field { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.ccweb-task-board__field--wide { + grid-column: 1 / -1; +} + +.ccweb-task-board__field--checkbox { + align-self: end; + flex-direction: row; + align-items: center; + min-height: 2.28rem; + padding: 0 0.15rem; +} + +.ccweb-task-board__field--checkbox .ccweb-task-board__field-label { + order: 2; +} + +.ccweb-task-board__field-label, +.ccweb-task-board__migration > span { + color: var(--text-muted); + font-size: 0.65rem; +} + +.ccweb-task-board__field-control { + min-height: 2.28rem; + padding: 0.42rem 0.55rem; + font-size: 0.73rem; +} + +.ccweb-task-board__field-control:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.ccweb-task-board__prompt { + min-height: 3.4rem; + resize: vertical; +} + +.ccweb-task-board__color-input { + padding: 0.22rem; +} + +.ccweb-task-board__definition-enabled { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--accent); +} + +.ccweb-task-board__definition-actions { + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.55rem; +} + +.ccweb-task-board__definition-actions .ccweb-task-board__button { + min-height: 2.15rem; +} + +.ccweb-task-board__migration { + flex: 1 1 12rem; + justify-content: flex-start; + gap: 0.42rem; + min-width: 0; +} + +.ccweb-task-board__migration .ccweb-task-board__field-control { + flex: 1 1 auto; +} + +.ccweb-task-board__status-empty { + margin: 0; + padding: 2rem 1rem; + color: var(--text-muted); + text-align: center; + font-size: 0.78rem; +} + +.ccweb-task-board :is( + .ccweb-task-board__input, + .ccweb-task-board__field-control, + .ccweb-task-board__card-select, + .ccweb-task-board__button, + .ccweb-task-board__icon-button, + .ccweb-task-board__card-action, + .ccweb-task-board__card-open, + .ccweb-task-board__status-scrim, + summary +):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 0.25rem var(--accent-light); +} + +.ccweb-task-board[data-view='archived'] .ccweb-task-board__toolbar { + box-shadow: inset 0 -2px var(--accent); +} + +.ccweb-task-board[aria-busy='true'] .ccweb-task-board__title::after { + content: ''; + display: inline-block; + width: 0.46rem; + height: 0.46rem; + margin-left: 0.52rem; + border-radius: 50%; + background: var(--accent); + animation: ccweb-task-board-pulse 800ms ease-in-out infinite alternate; + vertical-align: 0.12em; +} + +.ccweb-task-tracking { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 0.34rem; + width: auto; + max-width: 100%; + min-width: 0; + min-height: 1.75rem; + padding: 0.25rem 0.625rem; + border: 1px solid var(--border-color); + border-radius: 999px; + color: var(--text-muted); + background: var(--bg-tertiary); + cursor: pointer; + font-size: 0.6875rem; + font-weight: 800; + line-height: 1; + transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease, opacity 150ms ease; +} + +.ccweb-task-tracking__title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ccweb-task-tracking:hover { + border-color: var(--accent); + color: var(--text-primary); + background: var(--accent-light); +} + +.ccweb-task-tracking[data-enabled='true'] { + color: var(--success); +} + +.ccweb-task-tracking[data-pending='true'] { + color: var(--accent); +} + +.ccweb-task-tracking__dot { + display: block; + flex: 0 0 auto; + width: 0.44rem; + height: 0.44rem; + border: 1px solid currentColor; + border-radius: 50%; + background: transparent; + transition: color 150ms ease, background-color 150ms ease, transform 150ms ease; +} + +.ccweb-task-tracking__input { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.ccweb-task-tracking__status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.ccweb-task-tracking[data-enabled='true'] .ccweb-task-tracking__dot, +.ccweb-task-tracking[data-pending='true'] .ccweb-task-tracking__dot { + background: currentColor; +} + +.ccweb-task-tracking:has(.ccweb-task-tracking__input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 0.25rem var(--accent-light); +} + +.ccweb-task-tracking__input:disabled { + cursor: not-allowed; +} + +.ccweb-task-tracking:has(.ccweb-task-tracking__input:disabled) { + cursor: not-allowed; + opacity: 0.58; +} + +.ccweb-task-tracking[data-pending='true'] .ccweb-task-tracking__dot { + animation: ccweb-task-board-pulse 700ms ease-in-out infinite alternate; +} + +.task-board-panel { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--bg-primary); + animation: ccweb-task-board-panel-in 180ms ease-out both; +} + +.task-board-panel[hidden] { display: none !important; } + +.task-board-panel__header { + display: flex; + justify-content: flex-end; + align-items: center; + min-height: 3.1rem; + padding: 0.42rem 0.7rem; + border-bottom: 1px solid var(--border-color); +} + +.task-board-panel__root { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.task-tracking-control { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + width: auto; + max-width: 100%; + margin: 0 0 0 auto; + padding: 0; +} + +.chat-controls > .task-tracking-control { + margin-left: auto; +} + +.task-tracking-control[hidden] { display: none !important; } + +@media (max-width: 520px) { + .task-board-panel__header { min-height: 2.8rem; } + .task-tracking-control { + flex-basis: auto; + margin-left: auto; + } +} + +@media (max-width: 480px) { + .chat-controls > .task-tracking-control { + justify-self: end; + align-self: center; + } +} + +@keyframes ccweb-task-board-enter { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes ccweb-task-board-panel-in { + from { opacity: 0; transform: translateX(1rem); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes ccweb-task-board-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes ccweb-task-board-pulse { + from { opacity: 0.42; } + to { opacity: 1; } +} + +@media (max-width: 1180px) { + .ccweb-task-board__toolbar { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .ccweb-task-board__controls { + grid-template-columns: minmax(13rem, 1fr) auto auto; + } +} + +@media (max-width: 820px) { + .ccweb-task-board { + min-height: 28rem; + } + + .ccweb-task-board__toolbar { + padding: 0.8rem; + } + + .ccweb-task-board__controls { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .ccweb-task-board__search { + grid-column: 1 / -1; + } + + .ccweb-task-board__button { + min-width: 0; + } + + .ccweb-task-board__lanes { + grid-auto-columns: min(84vw, 20rem); + gap: 0.7rem; + padding: 0.75rem; + scroll-snap-type: x proximity; + } + + .ccweb-task-board__column { + scroll-snap-align: start; + } + + .ccweb-task-board__status-layer { + grid-template-columns: minmax(0, 1fr) minmax(20rem, 88vw); + } +} + +@media (max-width: 520px) { + .ccweb-task-board__controls { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .ccweb-task-board__search { + grid-column: 1 / -1; + } + + .ccweb-task-board__lanes { + grid-auto-columns: calc(100vw - 2rem); + padding: 0.6rem; + } + + .ccweb-task-board__card-footer { + align-items: stretch; + flex-direction: column; + } + + .ccweb-task-board__card-actions, + .ccweb-task-board__status-select-wrap { + width: 100%; + } + + .ccweb-task-board__card-actions > * { + flex: 1 1 50%; + } + + .ccweb-task-board__status-layer { + display: block; + } + + .ccweb-task-board__status-scrim { + position: absolute; + inset: 0; + } + + .ccweb-task-board__status-panel { + position: absolute; + inset: 0; + width: 100%; + border-left: 0; + } + + .ccweb-task-board__definition-grid { + grid-template-columns: 1fr; + } + + .ccweb-task-tracking { + gap: 0.3rem; + padding: 0.25rem 0.5rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .ccweb-task-board, + .ccweb-task-board *, + .ccweb-task-board *::before, + .ccweb-task-board *::after, + .ccweb-task-tracking, + .ccweb-task-tracking *, + .ccweb-task-tracking *::before, + .ccweb-task-tracking *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .ccweb-task-board__card:hover, + .ccweb-task-board__card:focus-within { + transform: none; + } +} + +@media (forced-colors: active) { + .ccweb-task-board__column-dot, + .ccweb-task-board__definition-dot { + border: 1px solid CanvasText; + } + + .ccweb-task-board__button--primary { + color: ButtonText; + background: ButtonFace; + } + + .ccweb-task-tracking[data-enabled='true'] .ccweb-task-tracking__dot { + background: ButtonText; + } +} diff --git a/public/task-board.js b/public/task-board.js new file mode 100644 index 0000000..d8a686d --- /dev/null +++ b/public/task-board.js @@ -0,0 +1,1700 @@ +(function installCcwebTaskBoard(global) { + 'use strict'; + + const VERSION = 1; + const MESSAGE_TYPES = Object.freeze({ + query: 'task_board_query', + trackingSet: 'task_tracking_set', + statusSet: 'task_status_set', + definitionUpsert: 'task_status_definition_upsert', + definitionRemove: 'task_status_definition_remove', + archiveSet: 'task_archive_set', + boardResult: 'task_board_result', + boardEvent: 'task_board_event', + trackingResult: 'task_tracking_result', + statusResult: 'task_status_result', + definitionsResult: 'task_status_definitions_result', + archiveResult: 'task_archive_result', + }); + + const RESPONSE_TYPES = new Set([ + MESSAGE_TYPES.boardResult, + MESSAGE_TYPES.boardEvent, + MESSAGE_TYPES.trackingResult, + MESSAGE_TYPES.statusResult, + MESSAGE_TYPES.definitionsResult, + MESSAGE_TYPES.archiveResult, + ]); + + let requestSequence = 0; + + function isObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + + function hasOwn(value, key) { + return isObject(value) && Object.prototype.hasOwnProperty.call(value, key); + } + + function text(value, fallback = '') { + if (value === null || value === undefined) return fallback; + const normalized = String(value).trim(); + return normalized || fallback; + } + + function finiteNumber(value, fallback = null) { + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : fallback; + } + + function labelFromValue(value, fallback = '') { + if (isObject(value)) { + return text(value.label || value.name || value.title || value.id, fallback); + } + return text(value, fallback); + } + + function createRequestId(prefix = 'task') { + const cryptoApi = global.crypto; + if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { + return `${prefix}-${cryptoApi.randomUUID()}`; + } + requestSequence += 1; + return `${prefix}-${Date.now().toString(36)}-${requestSequence.toString(36)}`; + } + + function normalizeColor(value) { + const candidate = text(value); + if (!candidate) return ''; + if (global.CSS && typeof global.CSS.supports === 'function' && !global.CSS.supports('color', candidate)) { + return ''; + } + return candidate; + } + + function normalizeDefinition(raw, index = 0) { + const input = isObject(raw) ? raw : {}; + const id = text(input.id || input.statusId, `status-${index + 1}`); + return { + id, + label: text(input.label || input.name, id), + prompt: text(input.prompt), + color: normalizeColor(input.color), + order: finiteNumber(input.order, index * 10), + system: input.system === true, + enabled: input.enabled !== false, + missingDefinition: input.missingDefinition === true, + }; + } + + function normalizeTask(raw, index = 0) { + const input = isObject(raw) ? raw : {}; + const tracking = isObject(input.taskTracking) ? input.taskTracking : {}; + const session = isObject(input.session) ? input.session : {}; + const runtime = isObject(input.runtime) ? input.runtime : {}; + const sessionId = text(input.sessionId || session.id || input.id, `task-${index + 1}`); + const hasTrackingFlag = hasOwn(tracking, 'enabled') || hasOwn(input, 'trackingEnabled'); + const explicitRuntimeState = labelFromValue( + input.runtimeState || input.runtimeStatus || input.runState || input.sessionStatus || runtime.status || session.runtimeStatus, + ); + const runtimeState = explicitRuntimeState + || (input.waitingOnChildren === true ? '等待子任务' : '') + || (input.isRunning === true ? '运行中' : ''); + const archivedAt = hasOwn(tracking, 'archivedAt') ? tracking.archivedAt : input.archivedAt; + const archivedBy = hasOwn(tracking, 'archivedBy') ? tracking.archivedBy : input.archivedBy; + + return { + sessionId, + title: text(input.title || session.title || input.name, '未命名任务'), + statusId: text(tracking.statusId || input.statusId), + summary: text(tracking.summary || input.summary), + reason: text(tracking.reason || input.reason), + source: text(tracking.source || input.source), + runtimeState, + runtimeDetail: text(input.runtimeDetail || runtime.detail || input.lastRuntimeMessage), + agent: labelFromValue(input.agent || input.agentName || session.agent || runtime.agent), + project: labelFromValue(input.project || input.projectName || session.project), + cwd: text(input.cwd || session.cwd), + archivedAt: archivedAt || null, + archivedBy: text(archivedBy), + updatedAt: tracking.statusUpdatedAt || input.updatedAt || input.updated || session.updatedAt || null, + version: finiteNumber(tracking.version, finiteNumber(input.version, null)), + order: finiteNumber(input.order, index), + trackingEnabled: hasTrackingFlag + ? (hasOwn(tracking, 'enabled') ? tracking.enabled === true : input.trackingEnabled === true) + : true, + }; + } + + function sortDefinitions(definitions) { + return definitions.slice().sort((left, right) => ( + left.order - right.order + || left.label.localeCompare(right.label, 'zh-CN') + || left.id.localeCompare(right.id) + )); + } + + function sortTasks(tasks) { + return tasks.slice().sort((left, right) => { + if (left.order !== right.order) return left.order - right.order; + const leftTime = Date.parse(left.updatedAt || '') || 0; + const rightTime = Date.parse(right.updatedAt || '') || 0; + if (leftTime !== rightTime) return rightTime - leftTime; + return left.sessionId.localeCompare(right.sessionId); + }); + } + + function findArray(source, names) { + if (!isObject(source)) return null; + for (const name of names) { + if (Array.isArray(source[name])) return source[name]; + } + return null; + } + + function collectEnvelope(message) { + const sources = []; + if (isObject(message)) sources.push(message); + for (const key of ['payload', 'result', 'data', 'event']) { + if (isObject(message?.[key])) sources.push(message[key]); + } + const nestedEvent = sources.find((source) => isObject(source.event)); + if (nestedEvent) sources.push(nestedEvent.event); + return Object.assign({}, ...sources); + } + + function getProtocolError(message) { + const envelope = collectEnvelope(message); + const rawError = envelope.error; + if (isObject(rawError)) { + return { + code: text(rawError.code || envelope.code, 'task_board_error'), + message: text(rawError.message || envelope.message, '任务看板操作失败'), + }; + } + if (rawError || envelope.ok === false || envelope.success === false) { + return { + code: text(envelope.code || rawError, 'task_board_error'), + message: text(envelope.message || rawError, '任务看板操作失败'), + }; + } + return null; + } + + function createNode(doc, tagName, className, content) { + const node = doc.createElement(tagName); + if (className) node.className = className; + if (content !== undefined && content !== null) node.textContent = String(content); + return node; + } + + function setStatusColor(node, color) { + const normalized = normalizeColor(color); + if (!node?.style || typeof node.style.setProperty !== 'function') return; + node.style.setProperty('--task-board-status-color', normalized); + } + + function optionNode(doc, value, label, selected = false) { + const option = createNode(doc, 'option', '', label); + option.value = value; + option.selected = selected; + return option; + } + + function shortSessionId(sessionId) { + const normalized = text(sessionId, '—'); + return normalized.length > 14 ? `${normalized.slice(0, 6)}…${normalized.slice(-5)}` : normalized; + } + + function domToken(value, suffix = '') { + const normalized = text(value, 'status').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'status'; + return `${normalized}${suffix ? `-${suffix}` : ''}`; + } + + function formatTime(value) { + const timestamp = Date.parse(value || ''); + if (!Number.isFinite(timestamp)) return ''; + try { + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestamp)); + } catch (_) { + return new Date(timestamp).toISOString(); + } + } + + function safeFocus(node) { + if (node && typeof node.focus === 'function') node.focus(); + } + + function mount(target, options = {}) { + const doc = options.document || target?.ownerDocument || global.document; + if (!doc || typeof doc.createElement !== 'function') { + throw new TypeError('CcwebTaskBoard.mount 需要可用的 document'); + } + + const root = typeof target === 'string' ? doc.querySelector(target) : target; + if (!root || typeof root.replaceChildren !== 'function') { + throw new TypeError('CcwebTaskBoard.mount 需要有效的挂载节点'); + } + + const adapters = { + send: typeof options.send === 'function' ? options.send : null, + openSession: typeof options.openSession === 'function' ? options.openSession : null, + onError: typeof options.onError === 'function' ? options.onError : null, + onStateChange: typeof options.onStateChange === 'function' ? options.onStateChange : null, + requestId: typeof options.createRequestId === 'function' + ? options.createRequestId + : (typeof options.requestId === 'function' ? options.requestId : createRequestId), + }; + + const state = { + tasks: [], + statusDefinitions: [], + definitionsVersion: null, + filters: { + search: '', + archived: false, + }, + statusManagerOpen: false, + pending: 0, + error: null, + destroyed: false, + }; + + let searchTimer = null; + let previousFocus = null; + let latestQueryRequestId = ''; + let hasRenderedBoard = false; + const columnRecords = new Map(); + const cardRecords = new Map(); + let boardEmpty = null; + + const shell = createNode(doc, 'section', 'ccweb-task-board'); + shell.setAttribute('aria-label', text(options.ariaLabel, '独立任务看板')); + shell.dataset.taskBoardVersion = String(VERSION); + + const header = createNode(doc, 'header', 'ccweb-task-board__toolbar'); + const headingGroup = createNode(doc, 'div', 'ccweb-task-board__heading'); + const title = createNode(doc, 'h2', 'ccweb-task-board__title', text(options.title, '任务看板')); + const summary = createNode(doc, 'p', 'ccweb-task-board__summary', '等待任务数据'); + summary.setAttribute('aria-live', 'polite'); + headingGroup.append(title, summary); + + const controls = createNode(doc, 'div', 'ccweb-task-board__controls'); + controls.setAttribute('role', 'group'); + controls.setAttribute('aria-label', '任务看板搜索与操作'); + + const searchLabel = createNode(doc, 'label', 'ccweb-task-board__search'); + const searchCaption = createNode(doc, 'span', 'ccweb-task-board__sr-only', '搜索任务'); + const searchGlyph = createNode(doc, 'span', 'ccweb-task-board__search-glyph', '⌕'); + searchGlyph.setAttribute('aria-hidden', 'true'); + const searchInput = createNode(doc, 'input', 'ccweb-task-board__input'); + searchInput.type = 'search'; + searchInput.placeholder = '搜索任务、会话或摘要'; + searchInput.autocomplete = 'off'; + searchLabel.append(searchCaption, searchGlyph, searchInput); + + const archiveButton = createNode(doc, 'button', 'ccweb-task-board__button', '查看归档'); + archiveButton.type = 'button'; + archiveButton.dataset.action = 'toggle-archive'; + archiveButton.setAttribute('aria-pressed', 'false'); + + const statusButton = createNode(doc, 'button', 'ccweb-task-board__button', '列管理'); + statusButton.type = 'button'; + statusButton.dataset.action = 'manage-statuses'; + statusButton.setAttribute('aria-haspopup', 'dialog'); + statusButton.setAttribute('aria-expanded', 'false'); + + controls.append( + searchLabel, + archiveButton, + statusButton, + ); + header.append(headingGroup, controls); + + const workspace = createNode(doc, 'div', 'ccweb-task-board__workspace'); + const board = createNode(doc, 'div', 'ccweb-task-board__lanes'); + board.setAttribute('aria-label', '任务状态列'); + board.tabIndex = 0; + workspace.appendChild(board); + + const statusLayer = createNode(doc, 'div', 'ccweb-task-board__status-layer'); + statusLayer.hidden = true; + const statusScrim = createNode(doc, 'button', 'ccweb-task-board__status-scrim'); + statusScrim.type = 'button'; + statusScrim.dataset.action = 'close-statuses'; + statusScrim.setAttribute('aria-label', '关闭列管理'); + const statusPanel = createNode(doc, 'aside', 'ccweb-task-board__status-panel'); + statusPanel.setAttribute('role', 'dialog'); + statusPanel.setAttribute('aria-modal', 'true'); + statusPanel.setAttribute('aria-labelledby', 'ccweb-task-board-status-title'); + const statusHeader = createNode(doc, 'header', 'ccweb-task-board__status-header'); + const statusHeaderCopy = createNode(doc, 'div', 'ccweb-task-board__status-header-copy'); + const statusPanelTitle = createNode(doc, 'h3', 'ccweb-task-board__status-title', '列管理'); + statusPanelTitle.id = 'ccweb-task-board-status-title'; + const statusPanelSummary = createNode(doc, 'p', 'ccweb-task-board__status-summary', '看板列及分类提示词由服务端数据驱动'); + statusHeaderCopy.append(statusPanelTitle, statusPanelSummary); + const statusClose = createNode(doc, 'button', 'ccweb-task-board__icon-button', '×'); + statusClose.type = 'button'; + statusClose.dataset.action = 'close-statuses'; + statusClose.setAttribute('aria-label', '关闭列管理'); + statusHeader.append(statusHeaderCopy, statusClose); + const statusBody = createNode(doc, 'div', 'ccweb-task-board__status-body'); + statusPanel.append(statusHeader, statusBody); + statusLayer.append(statusScrim, statusPanel); + + const liveRegion = createNode(doc, 'div', 'ccweb-task-board__sr-only'); + liveRegion.setAttribute('role', 'status'); + liveRegion.setAttribute('aria-live', 'polite'); + + shell.append(header, workspace, statusLayer, liveRegion); + root.replaceChildren(shell); + + const refs = { + shell, + summary, + searchInput, + archiveButton, + statusButton, + board, + statusLayer, + statusPanel, + statusClose, + statusPanelSummary, + statusBody, + liveRegion, + }; + + function announce(message) { + refs.liveRegion.textContent = text(message); + } + + function emitStateChange(reason) { + if (!adapters.onStateChange) return; + adapters.onStateChange(getState(), reason); + } + + function reportError(error) { + const normalized = isObject(error) + ? { code: text(error.code, 'task_board_error'), message: text(error.message, '任务看板操作失败') } + : { code: 'task_board_error', message: text(error, '任务看板操作失败') }; + state.error = normalized; + refs.shell.dataset.error = normalized.code; + refs.summary.textContent = normalized.message; + announce(normalized.message); + if (adapters.onError) adapters.onError(normalized); + } + + function clearError() { + state.error = null; + delete refs.shell.dataset.error; + } + + function syncBusy() { + const busy = state.pending > 0; + refs.shell.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + + function handleAdapterResult(result) { + if (isObject(result) && RESPONSE_TYPES.has(result.type)) handleMessage(result); + const protocolError = getProtocolError(result); + if (protocolError) reportError(protocolError); + return result; + } + + function sendRequest(message, requestPrefix = 'task') { + if (!adapters.send) { + reportError({ code: 'task_board_transport_missing', message: '任务看板尚未连接消息适配器' }); + return null; + } + const suppliedRequestId = text(message.requestId); + const request = { + ...message, + requestId: suppliedRequestId || adapters.requestId(requestPrefix), + }; + clearError(); + let output; + try { + output = adapters.send(request); + } catch (error) { + reportError(error); + return null; + } + if (output && typeof output.then === 'function') { + state.pending += 1; + syncBusy(); + return Promise.resolve(output) + .then(handleAdapterResult) + .catch((error) => { + reportError(error); + return null; + }) + .finally(() => { + state.pending = Math.max(0, state.pending - 1); + syncBusy(); + }); + } + return handleAdapterResult(output); + } + + function statusById(statusId) { + return state.statusDefinitions.find((definition) => definition.id === statusId) || null; + } + + function getBoardDefinitions() { + const referencedIds = new Set(state.tasks.map((task) => task.statusId).filter(Boolean)); + const definitions = state.statusDefinitions.filter((definition) => definition.enabled || referencedIds.has(definition.id)); + const knownIds = new Set(definitions.map((definition) => definition.id)); + for (const statusId of referencedIds) { + if (knownIds.has(statusId)) continue; + definitions.push(normalizeDefinition({ + id: statusId, + label: statusId, + prompt: '该列由任务数据引用,但状态定义暂不可用。', + enabled: true, + missingDefinition: true, + }, definitions.length)); + } + return sortDefinitions(definitions); + } + + function matchesFilters(task) { + if (Boolean(task.archivedAt) !== state.filters.archived) return false; + const query = state.filters.search.toLocaleLowerCase('zh-CN').trim(); + if (!query) return true; + return [ + task.title, + task.sessionId, + task.summary, + task.reason, + task.agent, + task.project, + task.cwd, + ].some((value) => text(value).toLocaleLowerCase('zh-CN').includes(query)); + } + + function createEmptyColumn() { + const empty = createNode(doc, 'div', 'ccweb-task-board__column-empty'); + const mark = createNode(doc, 'span', 'ccweb-task-board__column-empty-mark', '·'); + mark.setAttribute('aria-hidden', 'true'); + empty.append(mark, createNode(doc, 'span', '', '暂无任务')); + return empty; + } + + function prefersReducedBoardMotion() { + if (typeof global.matchMedia !== 'function') return false; + try { + return global.matchMedia('(prefers-reduced-motion: reduce)').matches === true; + } catch (_) { + return false; + } + } + + function readCardRect(card) { + if (!card || typeof card.getBoundingClientRect !== 'function' || !refs.board.contains(card)) return null; + const rect = card.getBoundingClientRect(); + if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) return null; + return { + left: rect.left, + top: rect.top, + width: Number.isFinite(rect.width) ? rect.width : 0, + height: Number.isFinite(rect.height) ? rect.height : 0, + }; + } + + function readCardBox(card, rect) { + let computed = null; + if (typeof global.getComputedStyle === 'function') { + try { + computed = global.getComputedStyle(card); + } catch (_) { + computed = null; + } + } + return { + height: `${Math.max(1, rect?.height || 1)}px`, + paddingTop: text(computed?.paddingTop, '0.78rem'), + paddingBottom: text(computed?.paddingBottom, '0.78rem'), + borderTopWidth: text(computed?.borderTopWidth, '1px'), + borderBottomWidth: text(computed?.borderBottomWidth, '1px'), + }; + } + + function releaseMotionColumn(record) { + const column = record.motionColumn; + record.motionColumn = null; + if (!column || column.querySelector('[data-motion="moving"]')) return; + column.classList.remove('ccweb-task-board__column--motion'); + } + + function cancelCardMotion(record) { + const animation = record.animation; + record.animation = null; + delete record.element.dataset.motion; + releaseMotionColumn(record); + if (!animation || typeof animation.cancel !== 'function') return; + try { + animation.cancel(); + } catch (_) { + // 动画可能已由浏览器回收;节点复用不应因此中断。 + } + } + + function runCardMotion(record, kind, keyframes, options, onFinish) { + cancelCardMotion(record); + const card = record.element; + card.dataset.motion = kind; + if (kind === 'moving') { + record.motionColumn = card.closest('.ccweb-task-board__column'); + record.motionColumn?.classList.add('ccweb-task-board__column--motion'); + } + + const completeWithoutAnimation = () => { + delete card.dataset.motion; + releaseMotionColumn(record); + if (typeof onFinish === 'function') onFinish(); + }; + if (prefersReducedBoardMotion() || typeof card.animate !== 'function') { + completeWithoutAnimation(); + return; + } + + let animation; + try { + animation = card.animate(keyframes, { fill: 'both', ...options }); + } catch (_) { + completeWithoutAnimation(); + return; + } + record.animation = animation; + + animation.onfinish = () => { + if (record.animation !== animation) return; + record.animation = null; + try { + animation.cancel(); + } catch (_) { + // 清除 fill 效果失败时,基础样式仍会在后续渲染中接管。 + } + delete card.dataset.motion; + releaseMotionColumn(record); + if (typeof onFinish === 'function') onFinish(); + }; + animation.oncancel = () => { + if (record.animation !== animation) return; + record.animation = null; + delete card.dataset.motion; + releaseMotionColumn(record); + }; + } + + function animateCardMove(record, before, after) { + if (!before || !after) return; + const deltaX = before.left - after.left; + const deltaY = before.top - after.top; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) return; + runCardMotion(record, 'moving', [ + { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, + { transform: 'translate3d(0, 0, 0)' }, + ], { + id: 'ccweb-task-board-card-move', + duration: 240, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + }); + } + + function animateCardEnter(record) { + const rect = readCardRect(record.element); + const box = readCardBox(record.element, rect); + runCardMotion(record, 'entering', [ + { + opacity: 0, + height: '0px', + paddingTop: '0px', + paddingBottom: '0px', + borderTopWidth: '0px', + borderBottomWidth: '0px', + overflow: 'hidden', + transform: 'translateY(-0.3rem) scale(0.985)', + }, + { + opacity: 1, + height: box.height, + paddingTop: box.paddingTop, + paddingBottom: box.paddingBottom, + borderTopWidth: box.borderTopWidth, + borderBottomWidth: box.borderBottomWidth, + overflow: 'hidden', + transform: 'translateY(0) scale(1)', + }, + ], { + id: 'ccweb-task-board-card-enter', + duration: 190, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + }); + } + + function discardCardRecord(sessionId, record) { + record.leaving = false; + cancelCardMotion(record); + record.element.remove(); + if (cardRecords.get(sessionId) === record) cardRecords.delete(sessionId); + } + + function animateCardLeave(sessionId, record) { + const rect = readCardRect(record.element); + const box = readCardBox(record.element, rect); + record.leaving = true; + runCardMotion(record, 'leaving', [ + { + opacity: 1, + height: box.height, + paddingTop: box.paddingTop, + paddingBottom: box.paddingBottom, + borderTopWidth: box.borderTopWidth, + borderBottomWidth: box.borderBottomWidth, + overflow: 'hidden', + transform: 'scale(1)', + }, + { + opacity: 0, + height: '0px', + paddingTop: '0px', + paddingBottom: '0px', + borderTopWidth: '0px', + borderBottomWidth: '0px', + overflow: 'hidden', + transform: 'scale(0.985)', + }, + ], { + id: 'ccweb-task-board-card-leave', + duration: 170, + easing: 'cubic-bezier(0.4, 0, 1, 1)', + }, () => { + if (!record.leaving || cardRecords.get(sessionId) !== record) return; + record.leaving = false; + record.element.remove(); + cardRecords.delete(sessionId); + }); + } + + function taskCardSignature(task, definition) { + const selectableDefinitions = sortDefinitions( + state.statusDefinitions.filter((item) => item.enabled || item.id === task.statusId), + ).map((item) => [item.id, item.label, item.enabled, item.order]); + return JSON.stringify([ + task.sessionId, + task.title, + task.statusId, + task.summary, + task.reason, + task.runtimeState, + task.runtimeDetail, + task.agent, + task.updatedAt, + task.archivedAt, + definition.label, + definition.color, + Boolean(adapters.send), + selectableDefinitions, + ]); + } + + function renderTaskCard(task, definition, existingCard = null) { + const card = existingCard || createNode(doc, 'article', 'ccweb-task-board__card'); + card.replaceChildren(); + card.dataset.sessionId = task.sessionId; + card.dataset.statusId = task.statusId; + card.setAttribute('role', 'listitem'); + setStatusColor(card, definition.color); + + const cardTop = createNode(doc, 'div', 'ccweb-task-board__card-top'); + const sessionCode = createNode(doc, 'span', 'ccweb-task-board__session-id', shortSessionId(task.sessionId)); + sessionCode.title = task.sessionId; + cardTop.appendChild(sessionCode); + + const openButton = createNode(doc, 'button', 'ccweb-task-board__card-open'); + openButton.type = 'button'; + openButton.dataset.action = 'open-session'; + openButton.dataset.sessionId = task.sessionId; + openButton.setAttribute('aria-label', `打开会话:${task.title}`); + openButton.appendChild(createNode(doc, 'span', 'ccweb-task-board__card-title', task.title)); + if (task.summary || task.reason) { + openButton.appendChild(createNode(doc, 'span', 'ccweb-task-board__card-summary', task.summary || task.reason)); + } + + const stateRow = createNode(doc, 'div', 'ccweb-task-board__state-row'); + const runtimeLabel = task.runtimeState || '未运行'; + const runtimeState = createNode(doc, 'span', 'ccweb-task-board__runtime-state', `运行:${runtimeLabel}`); + if (task.runtimeDetail) runtimeState.title = task.runtimeDetail; + stateRow.appendChild(runtimeState); + + card.append(cardTop, openButton, stateRow); + + const cardFooter = createNode(doc, 'div', 'ccweb-task-board__card-footer'); + const metadata = createNode(doc, 'div', 'ccweb-task-board__metadata'); + const updatedLabel = formatTime(task.updatedAt); + if (updatedLabel) metadata.appendChild(createNode(doc, 'time', '', updatedLabel)); + if (!updatedLabel) metadata.appendChild(createNode(doc, 'span', '', '暂无补充信息')); + + const cardActions = createNode(doc, 'div', 'ccweb-task-board__card-actions'); + const statusLabel = createNode(doc, 'label', 'ccweb-task-board__status-select-wrap'); + statusLabel.appendChild(createNode(doc, 'span', 'ccweb-task-board__sr-only', `调整 ${task.title} 的任务状态`)); + const statusSelect = createNode(doc, 'select', 'ccweb-task-board__card-select'); + statusSelect.dataset.action = 'set-status'; + statusSelect.dataset.sessionId = task.sessionId; + statusSelect.setAttribute('aria-label', `调整 ${task.title} 的任务状态`); + const selectableDefinitions = state.statusDefinitions.filter((item) => item.enabled || item.id === task.statusId); + for (const item of sortDefinitions(selectableDefinitions)) { + statusSelect.appendChild(optionNode(doc, item.id, item.label, item.id === task.statusId)); + } + statusSelect.value = task.statusId; + statusSelect.disabled = !adapters.send; + statusLabel.appendChild(statusSelect); + + const archive = createNode( + doc, + 'button', + 'ccweb-task-board__card-action', + task.archivedAt ? '恢复' : '归档', + ); + archive.type = 'button'; + archive.dataset.action = 'set-archive'; + archive.dataset.sessionId = task.sessionId; + archive.dataset.archived = task.archivedAt ? 'false' : 'true'; + archive.disabled = !adapters.send; + archive.setAttribute('aria-label', `${task.archivedAt ? '取消归档' : '归档'}:${task.title}`); + + cardActions.append(statusLabel, archive); + cardFooter.append(metadata, cardActions); + card.appendChild(cardFooter); + return card; + } + + function createColumnRecord(definition, definitionIndex) { + const column = createNode(doc, 'section', 'ccweb-task-board__column'); + column.dataset.statusId = definition.id; + const header = createNode(doc, 'header', 'ccweb-task-board__column-header'); + const titleWrap = createNode(doc, 'div', 'ccweb-task-board__column-title-wrap'); + const dot = createNode(doc, 'span', 'ccweb-task-board__column-dot'); + dot.setAttribute('aria-hidden', 'true'); + const title = createNode(doc, 'h3', 'ccweb-task-board__column-title'); + const count = createNode(doc, 'span', 'ccweb-task-board__column-count'); + titleWrap.append(dot, title, count); + header.appendChild(titleWrap); + const body = createNode(doc, 'div', 'ccweb-task-board__column-body'); + body.setAttribute('role', 'list'); + column.append(header, body); + return { + element: column, + header, + title, + count, + body, + empty: null, + disabled: null, + definitionIndex, + }; + } + + function updateColumnRecord(record, definition, definitionIndex, taskCount) { + const columnTitleId = `task-board-column-${domToken(definition.id, definitionIndex)}`; + record.element.dataset.statusId = definition.id; + record.element.setAttribute('aria-labelledby', columnTitleId); + setStatusColor(record.element, definition.color); + record.title.id = columnTitleId; + record.title.textContent = definition.label; + record.count.textContent = String(taskCount); + record.count.setAttribute('aria-label', `${taskCount} 个任务`); + record.header.title = definition.prompt || ''; + record.body.setAttribute('aria-label', `${definition.label}任务`); + record.definitionIndex = definitionIndex; + + if (!definition.enabled && !record.disabled) { + record.disabled = createNode(doc, 'span', 'ccweb-task-board__column-disabled', '已停用'); + record.header.appendChild(record.disabled); + } else if (definition.enabled && record.disabled) { + record.disabled.remove(); + record.disabled = null; + } + } + + function syncColumnEmpty(record, taskCount) { + if (taskCount > 0) { + record.empty?.remove(); + return; + } + if (!record.empty) record.empty = createEmptyColumn(); + if (record.empty.parentNode !== record.body) record.body.appendChild(record.empty); + } + + function syncNodeOrder(parent, desiredNodes) { + const desiredSet = new Set(desiredNodes); + const currentNodes = Array.from(parent.children).filter((node) => desiredSet.has(node)); + const alreadyOrdered = currentNodes.length === desiredNodes.length + && desiredNodes.every((node, index) => node.parentNode === parent && currentNodes[index] === node); + if (alreadyOrdered) return; + for (const node of desiredNodes) parent.appendChild(node); + } + + function ensureBoardEmpty() { + if (!boardEmpty) { + boardEmpty = createNode(doc, 'section', 'ccweb-task-board__empty'); + boardEmpty.setAttribute('role', 'status'); + boardEmpty.append( + createNode(doc, 'span', 'ccweb-task-board__empty-mark', '◇'), + createNode(doc, 'h3', '', '暂无状态定义'), + createNode(doc, 'p', '', '等待服务端提供状态定义,或打开状态管理进行检查。'), + ); + } + if (boardEmpty.parentNode !== refs.board) refs.board.appendChild(boardEmpty); + } + + function clearBoardRecords() { + for (const [sessionId, record] of cardRecords) discardCardRecord(sessionId, record); + for (const record of columnRecords.values()) record.element.remove(); + columnRecords.clear(); + } + + function renderBoard(options = {}) { + if (state.destroyed) return; + const visibleTasks = sortTasks(state.tasks.filter(matchesFilters)); + const definitions = getBoardDefinitions(); + const animateChanges = options.animate === true && hasRenderedBoard; + const oldRects = new Map(); + // 数据事件只移动 keyed 节点;更新前后的位置差用于 FLIP,避免整板重绘闪烁。 + if (animateChanges) { + for (const [sessionId, record] of cardRecords) { + const rect = readCardRect(record.element); + if (rect) oldRects.set(sessionId, rect); + } + } + refs.board.dataset.empty = visibleTasks.length ? 'false' : 'true'; + refs.archiveButton.textContent = state.filters.archived ? '返回任务' : '查看归档'; + refs.archiveButton.setAttribute('aria-pressed', state.filters.archived ? 'true' : 'false'); + refs.shell.dataset.view = state.filters.archived ? 'archived' : 'active'; + + if (!definitions.length) { + clearBoardRecords(); + ensureBoardEmpty(); + } else { + boardEmpty?.remove(); + const tasksByStatus = new Map(definitions.map((definition) => [definition.id, []])); + for (const task of visibleTasks) tasksByStatus.get(task.statusId)?.push(task); + const desiredStatusIds = new Set(definitions.map((definition) => definition.id)); + const desiredColumnNodes = []; + + definitions.forEach((definition, definitionIndex) => { + const tasks = tasksByStatus.get(definition.id) || []; + let record = columnRecords.get(definition.id); + if (!record) { + record = createColumnRecord(definition, definitionIndex); + columnRecords.set(definition.id, record); + } + updateColumnRecord(record, definition, definitionIndex, tasks.length); + desiredColumnNodes.push(record.element); + }); + syncNodeOrder(refs.board, desiredColumnNodes); + + const desiredSessionIds = new Set(); + const desiredCardsByStatus = new Map(definitions.map((definition) => [definition.id, []])); + const transitions = []; + definitions.forEach((definition) => { + const tasks = tasksByStatus.get(definition.id) || []; + for (const task of tasks) { + desiredSessionIds.add(task.sessionId); + let record = cardRecords.get(task.sessionId); + const isNew = !record; + if (!record) { + record = { + element: createNode(doc, 'article', 'ccweb-task-board__card'), + signature: '', + statusId: '', + animation: null, + motionColumn: null, + leaving: false, + }; + cardRecords.set(task.sessionId, record); + } else { + record.leaving = false; + cancelCardMotion(record); + } + + const focusedAction = record.element.contains(doc.activeElement) + ? text(doc.activeElement?.dataset?.action) + : ''; + const restoreFocusAction = ['open-session', 'set-status', 'set-archive'].includes(focusedAction) + ? focusedAction + : ''; + const signature = taskCardSignature(task, definition); + if (record.signature !== signature) { + renderTaskCard(task, definition, record.element); + record.signature = signature; + } + record.statusId = task.statusId; + desiredCardsByStatus.get(definition.id).push(record.element); + transitions.push({ record, sessionId: task.sessionId, isNew, restoreFocusAction }); + } + }); + + definitions.forEach((definition) => { + const column = columnRecords.get(definition.id); + syncNodeOrder(column.body, desiredCardsByStatus.get(definition.id)); + }); + for (const transition of transitions) { + if (!transition.restoreFocusAction) continue; + safeFocus(transition.record.element.querySelector(`[data-action="${transition.restoreFocusAction}"]`)); + } + + for (const [sessionId, record] of Array.from(cardRecords.entries())) { + if (desiredSessionIds.has(sessionId)) continue; + if (animateChanges && readCardRect(record.element)) animateCardLeave(sessionId, record); + else discardCardRecord(sessionId, record); + } + + definitions.forEach((definition) => { + const record = columnRecords.get(definition.id); + syncColumnEmpty(record, (tasksByStatus.get(definition.id) || []).length); + }); + for (const [statusId, record] of Array.from(columnRecords.entries())) { + if (desiredStatusIds.has(statusId)) continue; + record.element.remove(); + columnRecords.delete(statusId); + } + + if (animateChanges) { + for (const transition of transitions) { + if (transition.isNew) { + animateCardEnter(transition.record); + continue; + } + animateCardMove( + transition.record, + oldRects.get(transition.sessionId), + readCardRect(transition.record.element), + ); + } + } + } + + const archiveLabel = state.filters.archived ? '归档任务' : '当前任务'; + refs.summary.textContent = `${visibleTasks.length} 个${archiveLabel} · ${definitions.length} 个状态`; + if (state.statusManagerOpen) renderStatusManager(); + hasRenderedBoard = true; + } + + function createField(labelText, control, className = '') { + const label = createNode(doc, 'label', `ccweb-task-board__field${className ? ` ${className}` : ''}`); + label.append(createNode(doc, 'span', 'ccweb-task-board__field-label', labelText), control); + return label; + } + + function createDefinitionForm(definition, isNew = false) { + const form = createNode(doc, 'form', 'ccweb-task-board__definition'); + form.dataset.formAction = 'upsert-definition'; + form.dataset.definitionId = isNew ? '' : definition.id; + if (definition.system) form.dataset.system = 'true'; + setStatusColor(form, definition.color); + + const formHeader = createNode(doc, 'div', 'ccweb-task-board__definition-header'); + const identity = createNode(doc, 'div', 'ccweb-task-board__definition-identity'); + identity.append( + createNode(doc, 'span', 'ccweb-task-board__definition-dot'), + createNode(doc, 'strong', '', isNew ? '新增看板列' : definition.label), + ); + const kind = createNode( + doc, + 'span', + 'ccweb-task-board__definition-kind', + isNew ? 'CUSTOM' : (definition.system ? 'SYSTEM' : 'CUSTOM'), + ); + formHeader.append(identity, kind); + + const grid = createNode(doc, 'div', 'ccweb-task-board__definition-grid'); + const idInput = createNode(doc, 'input', 'ccweb-task-board__field-control'); + idInput.name = 'id'; + idInput.value = isNew ? '' : definition.id; + idInput.required = true; + idInput.maxLength = 80; + idInput.pattern = '[A-Za-z0-9][A-Za-z0-9_-]*'; + idInput.disabled = !isNew; + idInput.placeholder = '例如 waiting-release'; + + const labelInput = createNode(doc, 'input', 'ccweb-task-board__field-control'); + labelInput.name = 'label'; + labelInput.value = isNew ? '' : definition.label; + labelInput.required = true; + labelInput.maxLength = 120; + + const colorInput = createNode(doc, 'input', 'ccweb-task-board__field-control ccweb-task-board__color-input'); + colorInput.name = 'color'; + colorInput.type = 'color'; + colorInput.value = /^#[0-9a-f]{6}$/i.test(definition.color) ? definition.color : '#6f8d9a'; + + const orderInput = createNode(doc, 'input', 'ccweb-task-board__field-control'); + orderInput.name = 'order'; + orderInput.type = 'number'; + orderInput.step = '1'; + orderInput.value = String(definition.order); + + const enabledInput = createNode(doc, 'input', 'ccweb-task-board__definition-enabled'); + enabledInput.name = 'enabled'; + enabledInput.type = 'checkbox'; + enabledInput.checked = definition.system || definition.enabled; + enabledInput.disabled = definition.system; + const enabledField = createField('启用', enabledInput, 'ccweb-task-board__field--checkbox'); + + grid.append( + createField('列 ID', idInput), + createField('列名称', labelInput), + createField('颜色', colorInput), + createField('排序', orderInput), + enabledField, + ); + + const promptControl = createNode(doc, 'textarea', 'ccweb-task-board__field-control ccweb-task-board__prompt'); + promptControl.name = 'prompt'; + promptControl.rows = 4; + promptControl.maxLength = 4000; + promptControl.required = true; + promptControl.placeholder = '填写任务应归入此列的明确条件'; + promptControl.value = isNew ? '' : definition.prompt; + const promptField = createField('分类提示词', promptControl, 'ccweb-task-board__field--wide'); + + const actions = createNode(doc, 'div', 'ccweb-task-board__definition-actions'); + const save = createNode(doc, 'button', 'ccweb-task-board__button ccweb-task-board__button--primary', isNew ? '新增列' : '保存'); + save.type = 'submit'; + save.disabled = !adapters.send; + actions.appendChild(save); + + if (!isNew && !definition.system) { + const migrateLabel = createNode(doc, 'label', 'ccweb-task-board__migration'); + migrateLabel.appendChild(createNode(doc, 'span', '', '删除后迁移到')); + const migration = createNode(doc, 'select', 'ccweb-task-board__field-control'); + migration.dataset.migrationFor = definition.id; + const candidates = sortDefinitions(state.statusDefinitions.filter((item) => item.enabled && item.id !== definition.id)); + migration.appendChild(optionNode(doc, '', '由服务端判断', true)); + for (const candidate of candidates) { + migration.appendChild(optionNode(doc, candidate.id, candidate.label)); + } + migrateLabel.appendChild(migration); + const remove = createNode(doc, 'button', 'ccweb-task-board__button ccweb-task-board__button--danger', '删除'); + remove.type = 'button'; + remove.dataset.action = 'remove-definition'; + remove.dataset.definitionId = definition.id; + remove.disabled = !adapters.send; + actions.prepend(migrateLabel); + actions.appendChild(remove); + } + + form.append(formHeader, grid, promptField, actions); + return form; + } + + function renderStatusManager() { + if (!state.statusManagerOpen) return; + const definitions = sortDefinitions(state.statusDefinitions); + const systemCount = definitions.filter((definition) => definition.system).length; + refs.statusPanelSummary.textContent = `${definitions.length} 个看板列 · ${systemCount} 个内置列 · ${definitions.length - systemCount} 个自定义列`; + refs.statusBody.replaceChildren(); + + const addDetails = createNode(doc, 'details', 'ccweb-task-board__add-status'); + const addSummary = createNode(doc, 'summary', 'ccweb-task-board__add-status-summary', '新增看板列'); + addDetails.append( + addSummary, + createDefinitionForm(normalizeDefinition({ + id: '', + label: '', + prompt: '', + color: '#6f8d9a', + order: definitions.length * 10, + enabled: true, + system: false, + }), true), + ); + refs.statusBody.appendChild(addDetails); + + const list = createNode(doc, 'div', 'ccweb-task-board__definition-list'); + if (!definitions.length) { + list.appendChild(createNode(doc, 'p', 'ccweb-task-board__status-empty', '尚未收到任何状态定义。')); + } else { + for (const definition of definitions) list.appendChild(createDefinitionForm(definition)); + } + refs.statusBody.appendChild(list); + } + + function readNamedControl(form, name) { + return form.querySelector(`[name="${name}"]`); + } + + function submitDefinition(form) { + const existingId = text(form.dataset.definitionId); + const idControl = readNamedControl(form, 'id'); + const labelControl = readNamedControl(form, 'label'); + const colorControl = readNamedControl(form, 'color'); + const orderControl = readNamedControl(form, 'order'); + const enabledControl = readNamedControl(form, 'enabled'); + const promptControl = readNamedControl(form, 'prompt'); + const system = form.dataset.system === 'true'; + const definition = { + id: existingId || text(idControl?.value), + label: text(labelControl?.value), + color: text(colorControl?.value, '#6f8d9a'), + order: finiteNumber(orderControl?.value, 0), + enabled: system ? true : !!enabledControl?.checked, + system, + prompt: text(promptControl?.value), + }; + if (!definition.id || !definition.label || !definition.prompt) { + reportError({ code: 'task_status_invalid', message: '请完整填写列 ID、列名称和分类提示词' }); + return; + } + sendRequest({ + type: MESSAGE_TYPES.definitionUpsert, + definition, + ...(state.definitionsVersion === null ? {} : { expectedVersion: state.definitionsVersion }), + }, 'task-status-definition'); + } + + function mergeTask(rawTask) { + if (!isObject(rawTask)) return; + const candidateId = text(rawTask.sessionId || rawTask.id || rawTask.session?.id); + if (!candidateId) return; + const existingIndex = state.tasks.findIndex((task) => task.sessionId === candidateId); + const existing = existingIndex >= 0 ? state.tasks[existingIndex] : null; + const mergedTracking = { + ...(existing ? { + enabled: existing.trackingEnabled, + statusId: existing.statusId, + summary: existing.summary, + reason: existing.reason, + source: existing.source, + archivedAt: existing.archivedAt, + archivedBy: existing.archivedBy, + statusUpdatedAt: existing.updatedAt, + version: existing.version, + } : {}), + ...(isObject(rawTask.taskTracking) ? rawTask.taskTracking : {}), + }; + for (const key of [ + 'statusId', + 'summary', + 'reason', + 'source', + 'archivedAt', + 'archivedBy', + 'version', + ]) { + if (hasOwn(rawTask, key) && !hasOwn(rawTask.taskTracking, key)) mergedTracking[key] = rawTask[key]; + } + if (hasOwn(rawTask, 'trackingEnabled') && !hasOwn(rawTask.taskTracking, 'enabled')) { + mergedTracking.enabled = rawTask.trackingEnabled; + } + if (hasOwn(rawTask, 'updatedAt') && !hasOwn(rawTask.taskTracking, 'statusUpdatedAt')) { + mergedTracking.statusUpdatedAt = rawTask.updatedAt; + } + const normalized = normalizeTask({ ...(existing || {}), ...rawTask, taskTracking: mergedTracking }); + if (existing && existing.version !== null && normalized.version !== null && normalized.version < existing.version) { + return; + } + if (!normalized.trackingEnabled) { + if (existingIndex >= 0) state.tasks.splice(existingIndex, 1); + return; + } + if (existingIndex >= 0) state.tasks.splice(existingIndex, 1, normalized); + else state.tasks.push(normalized); + } + + function applyData(data, options = {}) { + const envelope = collectEnvelope(data); + const definitions = findArray(envelope, ['statusDefinitions', 'definitions', 'statuses']); + const tasks = findArray(envelope, ['tasks', 'items']); + const incomingDefinitionsVersion = finiteNumber(envelope.definitionsVersion, null); + const definitionsAreCurrent = state.definitionsVersion === null + || incomingDefinitionsVersion === null + || incomingDefinitionsVersion >= state.definitionsVersion; + if (definitions && definitionsAreCurrent) { + state.statusDefinitions = sortDefinitions(definitions.map(normalizeDefinition)); + if (incomingDefinitionsVersion !== null) state.definitionsVersion = incomingDefinitionsVersion; + } + if (tasks) { + const normalizedTasks = tasks.map(normalizeTask).filter((task) => task.trackingEnabled); + if (options.replaceTasks !== false) state.tasks = normalizedTasks; + else normalizedTasks.forEach(mergeTask); + } + const task = isObject(envelope.task) + ? envelope.task + : (envelope.sessionId && (envelope.taskTracking || envelope.statusId || hasOwn(envelope, 'archivedAt')) ? envelope : null); + if (task) mergeTask(task); + const removedSessionId = text(envelope.removedSessionId || envelope.deletedSessionId); + if (removedSessionId) state.tasks = state.tasks.filter((item) => item.sessionId !== removedSessionId); + renderBoard({ animate: true }); + emitStateChange(options.reason || 'data'); + } + + function setData(data) { + applyData(data, { replaceTasks: true, reason: 'setData' }); + return controller; + } + + function handleMessage(message) { + if (!isObject(message) || !RESPONSE_TYPES.has(message.type)) return false; + if ( + message.type === MESSAGE_TYPES.boardResult + && latestQueryRequestId + && text(message.requestId) + && text(message.requestId) !== latestQueryRequestId + ) { + return true; + } + const protocolError = getProtocolError(message); + if (protocolError) { + reportError(protocolError); + return true; + } + clearError(); + applyData(message, { + replaceTasks: message.type === MESSAGE_TYPES.boardResult, + reason: message.type, + }); + announce('任务看板已更新'); + return true; + } + + function queryFilters() { + return { + search: state.filters.search, + archived: state.filters.archived, + }; + } + + function refresh(nextFilters) { + if (isObject(nextFilters)) { + if (hasOwn(nextFilters, 'search')) state.filters.search = text(nextFilters.search); + if (hasOwn(nextFilters, 'query')) state.filters.search = text(nextFilters.query); + if (hasOwn(nextFilters, 'archived')) state.filters.archived = nextFilters.archived === true; + refs.searchInput.value = state.filters.search; + renderBoard(); + } + announce('正在刷新任务看板'); + const filters = queryFilters(); + latestQueryRequestId = adapters.requestId('task-board-query'); + return sendRequest({ + type: MESSAGE_TYPES.query, + requestId: latestQueryRequestId, + query: filters.search, + archived: filters.archived, + filters, + }, 'task-board-query'); + } + + function setFilters(nextFilters = {}, options = {}) { + if (hasOwn(nextFilters, 'search')) state.filters.search = text(nextFilters.search); + if (hasOwn(nextFilters, 'query')) state.filters.search = text(nextFilters.query); + if (hasOwn(nextFilters, 'archived')) state.filters.archived = nextFilters.archived === true; + refs.searchInput.value = state.filters.search; + renderBoard(); + emitStateChange('filters'); + if (options.refresh === true) refresh(); + return controller; + } + + function getState() { + return { + tasks: state.tasks.map((task) => ({ ...task })), + statusDefinitions: state.statusDefinitions.map((definition) => ({ ...definition })), + definitionsVersion: state.definitionsVersion, + filters: { ...state.filters }, + statusManagerOpen: state.statusManagerOpen, + pending: state.pending, + error: state.error ? { ...state.error } : null, + }; + } + + function openStatusManager() { + if (state.statusManagerOpen) return; + previousFocus = doc.activeElement; + state.statusManagerOpen = true; + refs.statusLayer.hidden = false; + header.setAttribute('inert', ''); + workspace.setAttribute('inert', ''); + refs.statusButton.setAttribute('aria-expanded', 'true'); + renderStatusManager(); + global.setTimeout(() => safeFocus(refs.statusClose), 0); + emitStateChange('statusManagerOpen'); + } + + function closeStatusManager() { + if (!state.statusManagerOpen) return; + state.statusManagerOpen = false; + refs.statusLayer.hidden = true; + header.removeAttribute('inert'); + workspace.removeAttribute('inert'); + refs.statusButton.setAttribute('aria-expanded', 'false'); + safeFocus(previousFocus || refs.statusButton); + previousFocus = null; + emitStateChange('statusManagerClose'); + } + + function focusablePanelNodes() { + return Array.from(refs.statusPanel.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])', + )).filter((node) => !node.hidden); + } + + function onDocumentKeydown(event) { + if (!state.statusManagerOpen) return; + if (event.key === 'Escape') { + event.preventDefault(); + closeStatusManager(); + return; + } + if (event.key !== 'Tab') return; + const nodes = focusablePanelNodes(); + if (!nodes.length) return; + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + if (event.shiftKey && doc.activeElement === first) { + event.preventDefault(); + safeFocus(last); + } else if (!event.shiftKey && doc.activeElement === last) { + event.preventDefault(); + safeFocus(first); + } + } + + function onSearchInput() { + state.filters.search = text(refs.searchInput.value); + renderBoard(); + if (searchTimer !== null) global.clearTimeout(searchTimer); + searchTimer = global.setTimeout(() => { + searchTimer = null; + if (adapters.send) refresh(); + }, 180); + } + + function onBoardChange(event) { + const select = event.target?.closest?.('[data-action="set-status"]'); + if (!select) return; + const sessionId = text(select.dataset.sessionId); + const task = state.tasks.find((item) => item.sessionId === sessionId); + sendRequest({ + type: MESSAGE_TYPES.statusSet, + sessionId, + statusId: text(select.value), + ...(task?.version === null ? {} : { expectedVersion: task?.version }), + }, 'task-status'); + } + + function onBoardClick(event) { + const actionNode = event.target?.closest?.('[data-action]'); + if (!actionNode) return; + const action = actionNode.dataset.action; + const sessionId = text(actionNode.dataset.sessionId); + if (action === 'open-session' && sessionId && adapters.openSession) { + adapters.openSession(sessionId); + return; + } + if (action === 'set-archive' && sessionId) { + const task = state.tasks.find((item) => item.sessionId === sessionId); + sendRequest({ + type: MESSAGE_TYPES.archiveSet, + sessionId, + archived: actionNode.dataset.archived === 'true', + ...(task?.version === null ? {} : { expectedVersion: task?.version }), + }, 'task-archive'); + } + } + + function onShellClick(event) { + const actionNode = event.target?.closest?.('[data-action]'); + if (!actionNode) return; + switch (actionNode.dataset.action) { + case 'toggle-archive': + state.filters.archived = !state.filters.archived; + renderBoard(); + if (adapters.send) refresh(); + break; + case 'manage-statuses': + openStatusManager(); + break; + case 'close-statuses': + closeStatusManager(); + break; + case 'remove-definition': { + const id = text(actionNode.dataset.definitionId); + const form = actionNode.closest('form'); + const migration = form?.querySelector('[data-migration-for]'); + sendRequest({ + type: MESSAGE_TYPES.definitionRemove, + statusId: id, + migrateTo: text(migration?.value) || null, + ...(state.definitionsVersion === null ? {} : { expectedVersion: state.definitionsVersion }), + }, 'task-status-definition'); + break; + } + default: + break; + } + } + + function onStatusSubmit(event) { + const form = event.target?.closest?.('[data-form-action="upsert-definition"]'); + if (!form) return; + event.preventDefault(); + submitDefinition(form); + } + + function destroy() { + if (state.destroyed) return; + state.destroyed = true; + if (searchTimer !== null) global.clearTimeout(searchTimer); + clearBoardRecords(); + boardEmpty?.remove(); + refs.searchInput.removeEventListener('input', onSearchInput); + refs.board.removeEventListener('change', onBoardChange); + refs.board.removeEventListener('click', onBoardClick); + refs.shell.removeEventListener('click', onShellClick); + refs.statusBody.removeEventListener('submit', onStatusSubmit); + doc.removeEventListener('keydown', onDocumentKeydown); + if (shell.parentNode === root) root.replaceChildren(); + } + + const controller = Object.freeze({ + element: shell, + setData, + handleMessage, + refresh, + setFilters, + getState, + openStatusManager, + closeStatusManager, + destroy, + }); + + refs.searchInput.addEventListener('input', onSearchInput); + refs.board.addEventListener('change', onBoardChange); + refs.board.addEventListener('click', onBoardClick); + refs.shell.addEventListener('click', onShellClick); + refs.statusBody.addEventListener('submit', onStatusSubmit); + doc.addEventListener('keydown', onDocumentKeydown); + + if (isObject(options.filters)) { + state.filters.search = text(options.filters.search || options.filters.query); + state.filters.archived = options.filters.archived === true; + refs.searchInput.value = text(state.filters.search); + } + if (isObject(options.initialData)) setData(options.initialData); + else renderBoard(); + if (options.autoLoad !== false && adapters.send) refresh(); + + return controller; + } + + function createTrackingControl(options = {}) { + const doc = options.document || global.document; + if (!doc || typeof doc.createElement !== 'function') { + throw new TypeError('CcwebTaskBoard.createTrackingControl 需要可用的 document'); + } + const send = typeof options.send === 'function' ? options.send : null; + const setTracking = typeof options.setTracking === 'function' ? options.setTracking : null; + const onChange = typeof options.onChanged === 'function' + ? options.onChanged + : (typeof options.onChange === 'function' ? options.onChange : null); + const onError = typeof options.onError === 'function' ? options.onError : null; + const requestId = typeof options.createRequestId === 'function' + ? options.createRequestId + : (typeof options.requestId === 'function' ? options.requestId : createRequestId); + const state = { + sessionId: text(options.sessionId), + enabled: options.enabled === true, + disabled: options.disabled === true, + pending: false, + version: finiteNumber(options.version, null), + statusMessage: text(options.statusLabel, options.enabled === true ? '已加入' : '未加入'), + destroyed: false, + }; + let pendingRequest = null; + + const element = createNode(doc, 'label', 'ccweb-task-tracking'); + const input = createNode(doc, 'input', 'ccweb-task-tracking__input'); + input.type = 'checkbox'; + input.checked = state.enabled; + input.setAttribute('role', 'switch'); + + const dot = createNode(doc, 'span', 'ccweb-task-tracking__dot'); + dot.setAttribute('aria-hidden', 'true'); + const title = createNode(doc, 'span', 'ccweb-task-tracking__title', text(options.label, '看板')); + const status = createNode(doc, 'span', 'ccweb-task-tracking__status'); + status.setAttribute('aria-live', 'polite'); + element.append(input, dot, title, status); + + function sync() { + input.checked = state.enabled; + input.disabled = state.disabled || state.pending || (!send && !setTracking) || !state.sessionId; + element.dataset.enabled = state.enabled ? 'true' : 'false'; + element.dataset.pending = state.pending ? 'true' : 'false'; + element.setAttribute('aria-busy', state.pending ? 'true' : 'false'); + const statusText = state.pending ? '保存中' : state.statusMessage; + status.textContent = statusText; + input.setAttribute('aria-label', state.enabled ? '移出任务看板' : '加入任务看板'); + } + + function completeFailure(error, request) { + if (pendingRequest && request.requestId !== pendingRequest.requestId) return; + state.enabled = request.previousEnabled; + state.pending = false; + state.statusMessage = text(error?.message, '保存失败'); + pendingRequest = null; + sync(); + if (onError) onError(error, request.message); + } + + function completeSuccess(result, request) { + if (pendingRequest && request.requestId !== pendingRequest.requestId) return; + const protocolError = getProtocolError(result); + if (protocolError) { + completeFailure(Object.assign(new Error(protocolError.message), protocolError), request); + return; + } + const envelope = collectEnvelope(result); + const task = isObject(envelope.task) ? envelope.task : {}; + const tracking = isObject(task.taskTracking) + ? task.taskTracking + : (isObject(envelope.taskTracking) ? envelope.taskTracking : envelope); + const nextVersion = finiteNumber(tracking.version, finiteNumber(envelope.version, null)); + if (nextVersion !== null) state.version = nextVersion; + state.enabled = hasOwn(tracking, 'enabled') ? tracking.enabled === true : request.nextEnabled; + state.pending = false; + state.statusMessage = state.enabled ? '已加入' : '未加入'; + pendingRequest = null; + sync(); + if (onChange) onChange(state.enabled, result, request.message); + } + + function persist(nextEnabled) { + const previous = state.enabled; + state.enabled = nextEnabled; + state.pending = true; + sync(); + const message = { + type: MESSAGE_TYPES.trackingSet, + requestId: requestId('task-tracking'), + sessionId: state.sessionId, + enabled: nextEnabled, + ...(state.version === null ? {} : { expectedVersion: state.version }), + }; + const request = { + requestId: message.requestId, + previousEnabled: previous, + nextEnabled, + message, + }; + pendingRequest = request; + let result; + try { + result = setTracking ? setTracking({ ...message }) : send({ ...message }); + } catch (error) { + completeFailure(error, request); + return null; + } + if (result && typeof result.then === 'function') { + return Promise.resolve(result) + .then((response) => completeSuccess(response, request)) + .catch((error) => completeFailure(error, request)); + } + if (isObject(result) || setTracking) { + completeSuccess(result, request); + } + return result; + } + + function onInputChange() { + persist(input.checked); + } + + function setState(next = {}) { + if (hasOwn(next, 'sessionId')) state.sessionId = text(next.sessionId); + if (hasOwn(next, 'enabled')) { + state.enabled = next.enabled === true; + state.statusMessage = state.enabled ? '已加入' : '未加入'; + } + if (hasOwn(next, 'disabled')) state.disabled = next.disabled === true; + if (hasOwn(next, 'version')) state.version = finiteNumber(next.version, null); + if (hasOwn(next, 'pending')) state.pending = next.pending === true; + if (hasOwn(next, 'statusMessage')) state.statusMessage = text(next.statusMessage); + if (hasOwn(next, 'statusLabel')) state.statusMessage = text(next.statusLabel); + if (hasOwn(next, 'label')) title.textContent = text(next.label, '看板'); + sync(); + return controller; + } + + function handleMessage(message) { + if (!isObject(message) || message.type !== MESSAGE_TYPES.trackingResult) return false; + const envelope = collectEnvelope(message); + const responseSessionId = text(envelope.sessionId || envelope.task?.sessionId || envelope.task?.id); + if (responseSessionId && responseSessionId !== state.sessionId) return false; + if (pendingRequest && text(message.requestId) && text(message.requestId) !== pendingRequest.requestId) return false; + const request = pendingRequest || { + requestId: text(message.requestId), + previousEnabled: state.enabled, + nextEnabled: hasOwn(envelope, 'enabled') ? envelope.enabled === true : state.enabled, + message: null, + }; + const protocolError = getProtocolError(message); + if (protocolError) completeFailure(Object.assign(new Error(protocolError.message), protocolError), request); + else completeSuccess(message, request); + return true; + } + + function getState() { + return { ...state }; + } + + function destroy() { + if (state.destroyed) return; + state.destroyed = true; + input.removeEventListener('change', onInputChange); + if (typeof element.remove === 'function') element.remove(); + } + + const controller = Object.freeze({ + element, + input, + setState, + update: setState, + handleMessage, + getState, + destroy, + }); + input.addEventListener('change', onInputChange); + sync(); + return controller; + } + + function renderTrackingControl(container, options = {}) { + if (!container || typeof container.replaceChildren !== 'function') { + throw new TypeError('CcwebTaskBoard.renderTrackingControl 需要有效的容器节点'); + } + const control = createTrackingControl({ ...options, document: options.document || container.ownerDocument }); + container.replaceChildren(control.element); + return control; + } + + const api = Object.freeze({ + version: VERSION, + messageTypes: MESSAGE_TYPES, + mount, + createTrackingControl, + createTrackingToggle: createTrackingControl, + renderTrackingControl, + mountTrackingControl: renderTrackingControl, + mountTrackingToggle: renderTrackingControl, + }); + + global.CcwebTaskBoard = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/scripts/ccweb-message-reply-unit.js b/scripts/ccweb-message-reply-unit.js new file mode 100644 index 0000000..dda19bb --- /dev/null +++ b/scripts/ccweb-message-reply-unit.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const REPO_DIR = path.resolve(__dirname, '..'); +const SERVER_PATH = path.join(REPO_DIR, 'server.js'); +const mcpServer = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server.js')); +const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + +function check(label, fn) { + try { + fn(); + } catch (err) { + err.message = `${label}: ${err.message}`; + throw err; + } +} + +function toolNames(tools) { + return tools.map((tool) => tool && tool.name).filter(Boolean); +} + +function findTool(tools, name) { + return tools.find((tool) => tool && tool.name === name); +} + +function replyModeValues(value) { + if (Array.isArray(value)) return value; + if (value && typeof value === 'object') return Object.values(value); + return []; +} + +function withoutNamespace(tool) { + const clone = { ...tool }; + delete clone.namespace; + return clone; +} + +function assertExactReplyModeEnum(actual, label) { + assert.deepEqual( + actual, + ['one_way', 'return_and_continue'], + `${label} must be exactly ["one_way", "return_and_continue"]`, + ); +} + +check('public TOOLS expose only the unified send communication tool', () => { + assert.ok(Array.isArray(mcpServer.TOOLS), 'TOOLS must be exported as an array'); + const names = toolNames(mcpServer.TOOLS); + assert.equal( + names.filter((name) => name === 'ccweb_send_message').length, + 1, + `expected exactly one ccweb_send_message, got: ${names.join(', ')}`, + ); + assert.equal( + names.includes('ccweb_request_reply'), + false, + `ccweb_request_reply must be hidden from public TOOLS; got: ${names.join(', ')}`, + ); +}); + +const publicSendTool = findTool(mcpServer.TOOLS, 'ccweb_send_message'); + +check('ccweb_send_message schema requires replyMode with the exact enum', () => { + assert.ok(publicSendTool, 'ccweb_send_message must exist in public TOOLS'); + const schema = publicSendTool.inputSchema || {}; + const required = schema.required || []; + assert.ok(required.includes('targetConversationId'), 'required must include targetConversationId'); + assert.ok(required.includes('content'), 'required must include content'); + assert.ok(required.includes('replyMode'), 'required must include replyMode'); + assertExactReplyModeEnum(schema.properties?.replyMode?.enum, 'replyMode enum'); +}); + +check('ccweb_send_message description states strong selection and non-blocking rules', () => { + const description = String(publicSendTool.description || ''); + assert.match(description, /one_way/, 'description must mention one_way'); + assert.match(description, /return_and_continue/, 'description must mention return_and_continue'); + assert.match(description, /立即返回/, 'description must say calls return immediately'); + assert.match(description, /(不阻塞|不会阻塞|不等待)/, 'description must state the call does not block/wait'); + assert.match( + description, + /(仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果[\s\S]*one_way|one_way[\s\S]*仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果)/, + 'description must say one_way is only for cases where the source does not need the result', + ); + assert.match( + description, + /(分析|实现|测试|验收|完成后汇报|后续依赖)[\s\S]*必须[\s\S]*return_and_continue|return_and_continue[\s\S]*必须[\s\S]*(分析|实现|测试|验收|完成后汇报|后续依赖)/, + 'description must say result-dependent work must use return_and_continue', + ); +}); + +check('future compatibility exports are present and keep hidden legacy callable', () => { + assertExactReplyModeEnum( + replyModeValues(mcpServer.CCWEB_REPLY_MODES).sort(), + 'CCWEB_REPLY_MODES values', + ); + assert.equal(typeof mcpServer.isCallableToolName, 'function', 'isCallableToolName must be exported'); + assert.equal( + mcpServer.isCallableToolName('ccweb_send_message'), + true, + 'ccweb_send_message must be callable', + ); + assert.equal( + mcpServer.isCallableToolName('ccweb_request_reply'), + true, + 'hidden legacy ccweb_request_reply must remain callable', + ); + assert.equal( + typeof mcpServer.codexAppCommunicationTools, + 'function', + 'codexAppCommunicationTools must be exported', + ); +}); + +check('Codex App fallback communication tools reuse the public send contract', () => { + const fallbackTools = mcpServer.codexAppCommunicationTools(); + assert.ok(Array.isArray(fallbackTools), 'codexAppCommunicationTools() must return an array'); + const names = toolNames(fallbackTools); + assert.equal( + names.includes('ccweb_request_reply'), + false, + `fallback tools must not expose ccweb_request_reply; got: ${names.join(', ')}`, + ); + assert.equal( + names.includes('ccweb_display_image'), + false, + 'fallback tools must not include ccweb_display_image', + ); + assert.equal( + names.includes('ccweb_prompt_user'), + false, + 'fallback tools must not include ccweb_prompt_user', + ); + assert.equal( + names.some((name) => name.startsWith('ccweb_task_')), + false, + `fallback tools must not include task-board tools; got: ${names.join(', ')}`, + ); + + const fallbackSendTool = findTool(fallbackTools, 'ccweb_send_message'); + assert.ok(fallbackSendTool, 'fallback tools must include ccweb_send_message'); + assert.deepEqual( + withoutNamespace(fallbackSendTool), + publicSendTool, + 'fallback ccweb_send_message must match public definition except namespace', + ); +}); + +check('server.js dynamic tools and shared call gates reuse the shared contract', () => { + assert.match( + serverSource, + /function\s+codexAppCommunicationDynamicTools\s*\([^)]*\)\s*{[\s\S]{0,600}codexAppCommunicationTools\s*\(/, + 'codexAppCommunicationDynamicTools() must delegate to codexAppCommunicationTools()', + ); + assert.match( + serverSource, + /case ['"]tools\/call['"]\s*:[\s\S]{0,1400}isCallableToolName\s*\(/, + 'shared MCP tools/call gate must use isCallableToolName()', + ); + assert.match( + serverSource, + /case ['"]ccweb_request_reply['"]\s*:/, + 'internal dispatcher must keep the hidden legacy ccweb_request_reply case', + ); +}); + +check('unified runtime persists the original request and maps the legacy reply alias', () => { + const sendStart = serverSource.indexOf('function sendCrossConversationMessage'); + const sendEnd = serverSource.indexOf('function requestCrossConversationReply', sendStart); + const sendSource = serverSource.slice(sendStart, sendEnd); + assert.ok(sendStart >= 0 && sendEnd > sendStart, 'sendCrossConversationMessage() source must be present'); + assert.match( + sendSource, + /(args\.replyMode|normalizeCrossConversationReplyMode)/, + 'ccweb_send_message runtime must read or normalize replyMode', + ); + assert.match( + sendSource, + /originalRequest:\s*content/, + 'return_and_continue pending state must persist the original request', + ); + + const legacyStart = serverSource.indexOf('function requestCrossConversationReply'); + const legacyEnd = serverSource.indexOf('function listPendingCrossConversationReplies', legacyStart); + const legacySource = serverSource.slice(legacyStart, legacyEnd); + assert.match( + legacySource, + /(RETURN_AND_CONTINUE|return_and_continue)/, + 'legacy ccweb_request_reply must map explicitly to return_and_continue', + ); +}); + +check('target runtime prompt explains both reply modes and prevents duplicate return', () => { + const promptStart = serverSource.indexOf('function buildCrossConversationRuntimeText'); + const promptEnd = serverSource.indexOf('function buildCrossConversationReplyContent', promptStart); + const promptSource = serverSource.slice(promptStart, promptEnd); + assert.match(promptSource, /one_way/, 'target prompt must identify one_way mode'); + assert.match(promptSource, /return_and_continue/, 'target prompt must identify return_and_continue mode'); + assert.match( + promptSource, + /(不会自动回传|不会自动写回来源)/, + 'one_way target prompt must state that no automatic return occurs', + ); + assert.match( + promptSource, + /(自动回传|自动写回来源)/, + 'return_and_continue target prompt must state that the system returns the result automatically', + ); + assert.match( + promptSource, + /(不要|禁止)[\s\S]*(手工|手动)[\s\S]*(回传|发送)/, + 'return_and_continue target prompt must prohibit duplicate manual return', + ); +}); + +check('source auto-run prompt carries correlation context and completion guardrail', () => { + const promptStart = serverSource.indexOf('function buildCrossConversationReplyAutoRunText'); + const promptEnd = serverSource.indexOf('function startCrossConversationReplyAutoRun', promptStart); + const promptSource = serverSource.slice(promptStart, promptEnd); + assert.match(promptSource, /requestId/, 'source auto-run prompt must include requestId'); + assert.match(promptSource, /targetSession\?\.id|targetSession\.id/, 'source auto-run prompt must include target conversation id'); + assert.match(promptSource, /originalRequest/, 'source auto-run prompt must include the original request'); + assert.match(promptSource, /已返回不等于已完成/, 'source auto-run prompt must distinguish returned from completed'); + assert.match(promptSource, /完整/, 'source auto-run prompt must require a completeness check'); +}); + +console.log('ccweb message reply unit checks passed.'); diff --git a/scripts/failed-insert-card-unit.js b/scripts/failed-insert-card-unit.js new file mode 100644 index 0000000..5c168b9 --- /dev/null +++ b/scripts/failed-insert-card-unit.js @@ -0,0 +1,443 @@ +'use strict'; + +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const REPO_DIR = path.resolve(__dirname, '..'); +const APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); +const STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css'); + +function extractFunctionSource(source, name) { + const marker = `function ${name}`; + const start = source.indexOf(marker); + assert(start >= 0, `缺少函数 ${name}`); + const paramsOpen = source.indexOf('(', start); + assert(paramsOpen >= 0, `函数 ${name} 缺少参数列表`); + let paramDepth = 0; + let paramsClose = -1; + for (let index = paramsOpen; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') paramDepth += 1; + if (ch === ')') { + paramDepth -= 1; + if (paramDepth === 0) { + paramsClose = index; + break; + } + } + } + assert(paramsClose >= 0, `函数 ${name} 参数列表未闭合`); + const open = source.indexOf('{', paramsClose); + assert(open >= 0, `函数 ${name} 缺少函数体`); + let depth = 0; + for (let index = open; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + } + throw new Error(`函数 ${name} 未闭合`); +} + +class FakeClassList { + constructor(element) { + this.element = element; + } + + values() { + return String(this.element.className || '').split(/\s+/).filter(Boolean); + } + + contains(name) { + return this.values().includes(name); + } + + add(...names) { + const next = new Set(this.values()); + names.forEach((name) => next.add(String(name))); + this.element.className = Array.from(next).join(' '); + } + + remove(...names) { + const removed = new Set(names.map(String)); + this.element.className = this.values().filter((name) => !removed.has(name)).join(' '); + } + + toggle(name, force) { + const enabled = force === undefined ? !this.contains(name) : !!force; + if (enabled) this.add(name); + else this.remove(name); + return enabled; + } +} + +function dataKey(name) { + return name.slice(5).split('-').map((part, index) => ( + index === 0 ? part : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}` + )).join(''); +} + +function attributeValue(element, name) { + if (name === 'class') return element.className; + if (name === 'id') return element.id || null; + if (name === 'disabled') return element.disabled ? '' : null; + if (name.startsWith('data-')) { + const key = dataKey(name); + return Object.prototype.hasOwnProperty.call(element.dataset, key) ? String(element.dataset[key]) : null; + } + return element.attributes.get(name) ?? null; +} + +function matchesSelector(element, selector) { + const raw = String(selector || '').trim().replace(/^:scope\s*/, ''); + if (!raw) return false; + const tag = raw.match(/^[A-Za-z][A-Za-z0-9-]*/)?.[0]; + if (tag && element.tagName !== tag.toUpperCase()) return false; + const classes = Array.from(raw.matchAll(/\.([A-Za-z0-9_-]+)/g), (match) => match[1]); + if (classes.some((name) => !element.classList.contains(name))) return false; + const attrs = Array.from(raw.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g)); + for (const [, name, expected] of attrs) { + const actual = attributeValue(element, name); + if (actual === null) return false; + if (expected !== undefined && actual !== expected) return false; + } + return true; +} + +class FakeElement { + constructor(tagName) { + this.tagName = String(tagName).toUpperCase(); + this.children = []; + this.parentNode = null; + this.dataset = {}; + this.attributes = new Map(); + this.listeners = new Map(); + this.className = ''; + this.id = ''; + this.type = ''; + this.disabled = false; + this._textContent = ''; + this.classList = new FakeClassList(this); + } + + get textContent() { + return `${this._textContent}${this.children.map((child) => child.textContent).join('')}`; + } + + set textContent(value) { + this._textContent = value == null ? '' : String(value); + this.children.forEach((child) => { child.parentNode = null; }); + this.children = []; + } + + appendChild(child) { + if (child.parentNode) child.remove(); + child.parentNode = this; + this.children.push(child); + return child; + } + + remove() { + if (!this.parentNode) return; + this.parentNode.children = this.parentNode.children.filter((child) => child !== this); + this.parentNode = null; + } + + setAttribute(name, value) { + this.attributes.set(String(name), String(value)); + } + + getAttribute(name) { + return this.attributes.get(String(name)) ?? null; + } + + removeAttribute(name) { + this.attributes.delete(String(name)); + } + + addEventListener(type, handler) { + const list = this.listeners.get(type) || []; + list.push(handler); + this.listeners.set(type, list); + } + + click() { + const event = { + preventDefault() {}, + stopPropagation() {}, + }; + (this.listeners.get('click') || []).forEach((handler) => handler(event)); + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + + querySelectorAll(selector) { + const selectors = String(selector || '').split(',').map((item) => item.trim()).filter(Boolean); + const results = []; + const visit = (node) => { + node.children.forEach((child) => { + if (selectors.some((item) => matchesSelector(child, item))) results.push(child); + visit(child); + }); + }; + visit(this); + return results; + } +} + +class FakeDocument { + constructor() { + this.root = new FakeElement('div'); + } + + createElement(tagName) { + return new FakeElement(tagName); + } + + querySelector(selector) { + return this.root.querySelector(selector); + } +} + +function createHarness() { + const source = fs.readFileSync(APP_PATH, 'utf8'); + const document = new FakeDocument(); + const messagesDiv = document.root; + const sent = []; + const errors = []; + let outlineUpdates = 0; + let scrollbarUpdates = 0; + + const functionNames = [ + 'codexAppSteerStatusLabel', + 'cloneCodexAppSteerAttachments', + 'registerCodexAppSteerRecord', + 'clearCodexAppSteerRecord', + 'commitCodexAppSteerMessage', + 'registerCodexAppSteerSessionMessage', + 'retryCodexAppSteerMessage', + 'deleteCodexAppSteerMessage', + 'setCodexAppSteerStatusElement', + 'updateCodexAppSteerMessage', + ]; + const functions = functionNames.map((name) => extractFunctionSource(source, name)).join('\n\n'); + const script = ` + const userMessageIndex = new Map(); + const codexAppSteerRecords = new Map(); + let currentSessionId = 'session-a'; + let currentMode = 'yolo'; + let currentAgent = 'codexapp'; + let isGenerating = true; + let currentSessionRunning = true; + let currentSessionMessageCount = 4; + let ws = { readyState: 1, send(payload) { sent.push(JSON.parse(payload)); } }; + function cssEscape(value) { return String(value || '').replace(/"/g, '\\\\"'); } + function isCodexAppAgent(agent) { return agent === 'codexapp'; } + function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); } + function appendError(message) { errors.push(String(message || '')); } + function updateUserOutlinePanel() { outlineUpdates += 1; } + function updateScrollbar() { scrollbarUpdates += 1; } + function scrollToBottom() {} + function markSessionMessageElement(element, index) { + element.dataset.sessionMessage = 'true'; + element.dataset.messageIndex = String(index); + } + ${functions} + function makeMessage(id) { + const element = document.createElement('div'); + element.className = 'msg user'; + element.dataset.messageId = id; + const bubble = document.createElement('div'); + bubble.className = 'msg-bubble'; + element.appendChild(bubble); + messagesDiv.appendChild(element); + userMessageIndex.set(id, { id, element, content: '原文本', timestamp: '2026-08-11T00:00:00.000Z' }); + return element; + } + ({ + makeMessage, + registerCodexAppSteerRecord, + registerCodexAppSteerSessionMessage, + updateCodexAppSteerMessage, + setCodexAppSteerStatusElement, + retryCodexAppSteerMessage, + deleteCodexAppSteerMessage, + records: codexAppSteerRecords, + index: userMessageIndex, + messagesDiv, + sent, + errors, + get outlineUpdates() { return outlineUpdates; }, + get scrollbarUpdates() { return scrollbarUpdates; }, + get currentSessionMessageCount() { return currentSessionMessageCount; }, + setSession(id) { currentSessionId = id; }, + setGenerating(value) { isGenerating = value; currentSessionRunning = value; }, + setWsReady(value) { ws.readyState = value; }, + }); + `; + return vm.runInNewContext(script, { document, messagesDiv, sent, errors, outlineUpdates, scrollbarUpdates }); +} + +function assertFailedActionsContract() { + const harness = createHarness(); + const element = harness.makeMessage('retry-target'); + harness.registerCodexAppSteerRecord('retry-target', { + element, + text: '原文本', + attachments: [{ id: 'img-a', filename: 'a.png' }], + sessionId: 'session-a', + mode: 'plan', + agent: 'codexapp', + }); + + assert.strictEqual(harness.updateCodexAppSteerMessage('retry-target', 'failed', '插入失败'), true); + const failedActions = element.querySelector('.codex-steer-actions'); + assert(failedActions, 'failed 状态应渲染操作区'); + const retry = failedActions.querySelector('.codex-steer-retry'); + const remove = failedActions.querySelector('.codex-steer-delete'); + assert(retry && remove, 'failed 状态应同时渲染重试和删除按钮'); + assert.strictEqual(retry.textContent, '重试'); + assert.strictEqual(remove.textContent, '删除'); + assert.strictEqual(retry.getAttribute('aria-label'), '重试插入此消息'); + assert.strictEqual(remove.getAttribute('aria-label'), '删除失败插入项'); + + retry.click(); + retry.click(); + assert.strictEqual(harness.sent.length, 1, '连续快速点击重试只应发送一次'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), { + type: 'message', + text: '原文本', + attachments: [{ id: 'img-a', filename: 'a.png' }], + sessionId: 'session-a', + mode: 'plan', + agent: 'codexapp', + clientMessageId: 'retry-target', + }); + assert.strictEqual(element.getAttribute('aria-busy'), 'true', 'pending 状态应暴露 aria-busy=true'); + assert.strictEqual(element.querySelector('.codex-steer-actions'), null, 'pending 状态不应展示失败操作'); + + harness.updateCodexAppSteerMessage('retry-target', 'failed', '仍然失败'); + assert(element.querySelector('.codex-steer-actions'), '再次失败后应恢复操作'); + harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入'); + assert.strictEqual(element.querySelector('.codex-steer-actions'), null, '成功后不应展示失败操作'); + assert.strictEqual(element.dataset.sessionMessage, 'true', '成功后才提交消息索引'); + assert.strictEqual(element.dataset.messageIndex, '4', '成功提交应使用当前会话消息序号'); + assert.strictEqual(harness.currentSessionMessageCount, 5, '成功提交只应递增一次'); + harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入'); + assert.strictEqual(harness.currentSessionMessageCount, 5, '重复 inserted 不应重复递增'); +} + +function assertDeleteAndBlockedRetryContract() { + const harness = createHarness(); + const first = harness.makeMessage('delete-target'); + const second = harness.makeMessage('keep-target'); + harness.registerCodexAppSteerRecord('delete-target', { + element: first, + text: '删除目标', + attachments: [{ id: 'same-name', filename: 'same.png' }], + sessionId: 'session-a', + mode: 'yolo', + agent: 'codexapp', + }); + harness.registerCodexAppSteerRecord('keep-target', { + element: second, + text: '保留目标', + attachments: [{ id: 'same-name-2', filename: 'same.png' }], + sessionId: 'session-a', + mode: 'yolo', + agent: 'codexapp', + }); + harness.updateCodexAppSteerMessage('delete-target', 'failed', '插入失败'); + harness.updateCodexAppSteerMessage('keep-target', 'failed', '插入失败'); + first.querySelector('.codex-steer-delete').click(); + assert.strictEqual(harness.messagesDiv.children.includes(first), false, '删除只移除目标临时项'); + assert.strictEqual(harness.messagesDiv.children.includes(second), true, '删除不应影响同名其他项'); + assert.strictEqual(harness.index.has('delete-target'), false, '删除应清理用户消息索引'); + assert.strictEqual(harness.records.has('delete-target'), false, '删除应清理临时插入记录'); + assert.strictEqual(harness.records.has('keep-target'), true, '删除不应清理其他临时记录'); + + const blocked = harness.makeMessage('blocked-target'); + harness.registerCodexAppSteerRecord('blocked-target', { + element: blocked, + text: '断线重试', + attachments: [], + sessionId: 'session-a', + mode: 'yolo', + agent: 'codexapp', + }); + harness.updateCodexAppSteerMessage('blocked-target', 'failed', '插入失败'); + harness.setWsReady(3); + blocked.querySelector('.codex-steer-retry').click(); + assert.strictEqual(harness.sent.length, 0, 'WebSocket 断开时不能误发重试'); + assert(blocked.querySelector('.codex-steer-actions'), 'WebSocket 断开时应保持失败态可恢复'); + assert.strictEqual(blocked.getAttribute('aria-busy'), 'false', 'WebSocket 断开时不能永久卡 pending'); + + harness.setWsReady(1); + harness.setSession('other-session'); + blocked.querySelector('.codex-steer-retry').click(); + assert.strictEqual(harness.sent.length, 0, '会话不匹配时不能误发重试'); + assert(blocked.querySelector('.codex-steer-actions'), '会话不匹配时应保持失败态'); + + harness.setSession('session-a'); + harness.setGenerating(false); + blocked.querySelector('.codex-steer-retry').click(); + assert.strictEqual(harness.sent.length, 0, 'turn 已结束时不能误发重试'); + assert(blocked.querySelector('.codex-steer-actions'), 'turn 已结束时应保持失败态'); +} + +function assertProgrammaticSessionMessageRetryContract() { + const harness = createHarness(); + const element = harness.makeMessage('programmatic-target'); + harness.registerCodexAppSteerSessionMessage('session-a', { + id: 'programmatic-target', + role: 'user', + content: '表单答案', + attachments: [{ id: 'img-programmatic', filename: 'programmatic.png' }], + timestamp: '2026-08-11T00:00:01.000Z', + codexAppSteerStatus: 'pending', + codexAppSteerMessage: '引导中...', + }, element); + + assert.strictEqual(harness.records.has('programmatic-target'), true, '程序化 session_message 应补建可重试记录'); + harness.updateCodexAppSteerMessage('programmatic-target', 'failed', '插入失败'); + const retry = element.querySelector('.codex-steer-retry'); + assert(retry, '程序化失败消息应展示可用重试按钮'); + retry.click(); + assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), { + type: 'message', + text: '表单答案', + attachments: [{ id: 'img-programmatic', filename: 'programmatic.png' }], + sessionId: 'session-a', + mode: 'yolo', + agent: 'codexapp', + clientMessageId: 'programmatic-target', + }); + harness.updateCodexAppSteerMessage('programmatic-target', 'inserted', '已插入'); + assert.strictEqual(element.dataset.sessionMessage, 'true', '程序化临时项成功后才提交消息索引'); + assert.strictEqual(element.dataset.messageIndex, '4', '程序化临时项应复用当前持久化消息序号'); + assert.strictEqual(harness.currentSessionMessageCount, 5, '程序化临时项成功后只递增一次消息计数'); +} + +function assertStaticContracts() { + const appSource = fs.readFileSync(APP_PATH, 'utf8'); + const styleSource = fs.readFileSync(STYLE_PATH, 'utf8'); + assert(appSource.includes('const codexAppSteerRecords = new Map();'), '前端应维护独立临时插入记录'); + assert(appSource.includes("clientMessageId: id"), '重试应复用稳定 clientMessageId'); + assert(appSource.includes("aria-busy"), 'pending 卡片应暴露 aria-busy'); + assert(styleSource.includes('.codex-steer-actions'), '失败操作样式应限定在 codex-steer 区段'); + assert(styleSource.includes('.codex-steer-retry'), '应存在重试按钮样式'); + assert(styleSource.includes('.codex-steer-delete'), '应存在删除按钮样式'); +} + +assertStaticContracts(); +assertFailedActionsContract(); +assertDeleteAndBlockedRetryContract(); +assertProgrammaticSessionMessageRetryContract(); + +console.log('Failed insert card frontend unit checks passed.'); diff --git a/scripts/regression.js b/scripts/regression.js index 6fbe7b1..c9b50bc 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -604,12 +604,38 @@ function assertFrontendSidebarCollapseContract() { const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart); const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd); assert( - /\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle), - 'Compact-mobile user locator should open left from the second grid column' + /\.user-outline-anchor,\s*\.ccweb-prompt-outline-anchor\s*\{[^}]*position:\s*static;/.test(compactMobileStyle), + 'Compact-mobile locator anchors should use the shared chat-controls positioning context' ); assert( - /\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle), - 'Compact-mobile pending-form locator should open right from the first grid column' + /\.user-outline-panel,\s*\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*0;[^}]*width:\s*auto;[^}]*bottom:\s*calc\(100% \+ 6px\);/.test(compactMobileStyle), + 'Compact-mobile locator panels should span the chat controls width without horizontal overflow' + ); + assert( + !/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle) + && !/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle), + 'Compact-mobile locator panels should not be overridden back to per-button anchor positioning' + ); + const compactMobileChatControls = compactMobileStyle.match(/\.chat-controls\s*\{[^}]*\}/)?.[0] || ''; + assert( + /display:\s*flex;/.test(compactMobileChatControls) && /flex-wrap:\s*wrap;/.test(compactMobileChatControls), + 'Compact-mobile chat controls should keep flex wrapping instead of switching to a two-column grid' + ); + assert( + !/grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/.test(compactMobileStyle), + 'Compact-mobile chat controls should not force two equal columns' + ); + assert( + !/\.mode-select,\s*\.user-outline-btn,\s*\.ccweb-prompt-outline-btn,\s*\.reload-mcp-btn,\s*\.chat-runtime-state\s*\{[^}]*width:\s*100%;/.test(compactMobileStyle), + 'Compact-mobile short chat controls should keep content-width sizing' + ); + assert( + /\.chat-cwd\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*none;/.test(compactMobileStyle), + 'Compact-mobile cwd header control should retain its previous full-width behavior' + ); + assert( + /\.ccweb-prompt-outline-anchor\s*\{[^}]*width:\s*auto;/.test(compactMobileStyle), + 'Compact-mobile temporary form control should keep content width and wrap without overflow' ); assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment'); assert( @@ -629,6 +655,96 @@ function assertFrontendSidebarCollapseContract() { ); } +function assertTaskBoardIntegrationContract() { + const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const taskBoardServiceSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-service.js'), 'utf8'); + const taskBoardMcpSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-mcp.js'), 'utf8'); + const taskBoardLifecycleSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-lifecycle.js'), 'utf8'); + const taskBoardFrontendSource = fs.readFileSync(path.join(PUBLIC_DIR, 'task-board.js'), 'utf8'); + const taskBoardStyleSource = fs.readFileSync(path.join(PUBLIC_DIR, 'task-board.css'), 'utf8'); + const trackingIdCount = (indexSource.match(/id="task-tracking-control"/g) || []).length; + + assert(trackingIdCount === 1, 'Task tracking should reuse exactly one DOM mount point'); + assert( + /
]*>[\s\S]*?